Skip to content

Commit faaa549

Browse files
committed
modal.js: Prevent unintentional closing
This tracks the user's interactions by observing the events `keydown`, `paste` and `change` to detect changes to forms inside a modal. Upon any change, the modal cannot be closed anymore by pushing Escape or clicking outside the modal. Instead, the modal will *wobble* for a short period. resolves #5307
1 parent 6409782 commit faaa549

2 files changed

Lines changed: 110 additions & 10 deletions

File tree

public/css/icinga/modal.less

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
align-items: center;
3030
justify-content: center;
3131
}
32+
33+
&.wobble .modal-window {
34+
/* The duration must match what modal.js.wobble expects (1s) */
35+
.wobble-effect(@distance: 10px; @rotation: 0deg);
36+
}
3237
}
3338

3439
#modal-content {

public/js/icinga/behavior/modal.js

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
Icinga.Behaviors = Icinga.Behaviors || {};
99

10+
const iplWebFunctions = require('icinga/icinga-php-library/functions');
11+
1012
/**
1113
* Behavior for modal dialogs.
1214
*
@@ -18,14 +20,19 @@
1820
this.icinga = icinga;
1921
this.$layout = $('#layout');
2022
this.$ghost = $('#modal-ghost');
23+
this.hasChanges = false;
24+
this._wobbleTimeout = null;
2125

2226
this.on('submit', '#modal form', this.onFormSubmit, this);
2327
this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this);
2428
this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit, this);
2529
this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick, this);
2630
this.on('mousedown', '#layout > #modal', this.onModalLeave, this);
2731
this.on('click', '.modal-header > button', this.onModalClose, this);
28-
this.on('keydown', this.onKeyDown, this);
32+
this.on('paste', '#modal form', this.onPaste, this);
33+
this.on('change', '#modal form', this.onChange, this);
34+
this.on('keydown', '#modal form', this.onKeyDown, this);
35+
this.on('keydown', this.onEscapeKey, this);
2936
};
3037

3138
Modal.prototype = new Icinga.EventListener();
@@ -181,7 +188,34 @@
181188
var $target = $(event.target);
182189

183190
if ($target.is('#modal')) {
184-
_this.hide($target);
191+
if (_this.hasChanges) {
192+
_this.wobble($target);
193+
} else {
194+
_this.hide($target);
195+
}
196+
}
197+
};
198+
199+
/**
200+
* Event handler for closing the modal. Closes it when the user pushes ESC.
201+
*
202+
* @param event {KeyboardEvent} The `keydown` event triggered by pushing a key
203+
*/
204+
Modal.prototype.onEscapeKey = function(event) {
205+
if (event.key !== 'Escape') {
206+
return;
207+
}
208+
209+
const _this = event.data.self;
210+
const $modal = _this.$layout.children('#modal');
211+
if (! $modal.length) {
212+
return;
213+
}
214+
215+
if (_this.hasChanges) {
216+
_this.wobble($modal);
217+
} else if (! event.isDefaultPrevented()) {
218+
_this.hide($modal);
185219
}
186220
};
187221

@@ -197,21 +231,51 @@
197231
};
198232

199233
/**
200-
* Event handler for closing the modal. Closes it when the user pushed ESC.
234+
* Event handler for pasting into the modal form. Sets the hasChanges flag to true.
235+
*
236+
* @param event The `paste` event triggered by pasting into the form
237+
*/
238+
Modal.prototype.onPaste = function(event) {
239+
const _this = event.data.self;
240+
241+
/** @type {ClipboardEvent} */
242+
const originalEvent = event.originalEvent;
243+
if (originalEvent.clipboardData.types.length) {
244+
// Only set hasChanges flag if clipboard data is present
245+
_this.hasChanges = true;
246+
}
247+
};
248+
249+
/**
250+
* Event handler for input into the modal form. Sets the hasChanges flag to true.
201251
*
202-
* @param event {Event} The `keydown` event triggered by pushing a key
252+
* This is needed to detect changes in the form, as the `change` event is not always reliable.
253+
* Unless a text input or textarea is blurred, the `change` event might not be triggered.
254+
* Pushing Escape in this case would still close the modal without this.
255+
*
256+
* @param event {KeyboardEvent} The `keydown` event triggered by pushing a key
203257
*/
204258
Modal.prototype.onKeyDown = function(event) {
205-
var _this = event.data.self;
259+
const _this = event.data.self;
206260

207-
if (! event.isDefaultPrevented() && event.key === 'Escape') {
208-
let $modal = _this.$layout.children('#modal');
209-
if ($modal.length) {
210-
_this.hide($modal);
211-
}
261+
if (! iplWebFunctions.isSpecialKeyPress(event)) {
262+
_this.hasChanges = true;
212263
}
213264
};
214265

266+
/**
267+
* Event handler to register whether the modal form has been changed.
268+
*
269+
* In addition to `onKeyDown`, this is needed because checkboxes or select elements
270+
* do only trigger the `change` event, but at least rather reliably.
271+
*
272+
* @param event {Event} The change event
273+
*/
274+
Modal.prototype.onChange = function(event) {
275+
const _this = event.data.self;
276+
_this.hasChanges = true;
277+
};
278+
215279
/**
216280
* Make final preparations and add the modal to the DOM
217281
*
@@ -240,6 +304,36 @@
240304
this.icinga.ui.focusElement($modal.find('.modal-window'));
241305
};
242306

307+
/**
308+
* Wobble the modal
309+
*
310+
* @param $modal {jQuery} The modal element
311+
*/
312+
Modal.prototype.wobble = function($modal) {
313+
const modal = $modal[0];
314+
let timingOffset = 0;
315+
if (this._wobbleTimeout !== null) {
316+
clearTimeout(this._wobbleTimeout);
317+
// Do not interrupt the animation by removing the class too early.
318+
// This is done by identifying the running animation and synchronizing the timeout with it.
319+
for (const animation of modal.getAnimations({ subtree: true })) {
320+
if (animation.effect?.target?.matches('.modal-window')) {
321+
timingOffset = animation.currentTime;
322+
323+
break;
324+
}
325+
}
326+
} else {
327+
modal.classList.add("wobble");
328+
}
329+
330+
const _this = this;
331+
this._wobbleTimeout = setTimeout(function () {
332+
modal.classList.remove("wobble");
333+
_this._wobbleTimeout = null;
334+
}, 1000 - timingOffset);
335+
};
336+
243337
/**
244338
* Hide the modal and remove it from the DOM
245339
*
@@ -249,6 +343,7 @@
249343
// Remove pointerEvent none style to make the button clickable again
250344
this.modalOpener.style.pointerEvents = '';
251345
this.modalOpener = null;
346+
this.hasChanges = false;
252347

253348
$modal.removeClass('active');
254349
// Using `setTimeout` here to let the transition finish

0 commit comments

Comments
 (0)