Skip to content

Commit 256caeb

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 256caeb

2 files changed

Lines changed: 116 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: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77

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

10+
let functions = null;
11+
12+
try {
13+
functions = require('icinga/icinga-php-library/functions');
14+
} catch (error) {
15+
console.error('Failed to require library:', error);
16+
}
17+
1018
/**
1119
* Behavior for modal dialogs.
1220
*
@@ -18,14 +26,19 @@
1826
this.icinga = icinga;
1927
this.$layout = $('#layout');
2028
this.$ghost = $('#modal-ghost');
29+
this.hasChanges = false;
30+
this._wobbleTimeout = null;
2131

2232
this.on('submit', '#modal form', this.onFormSubmit, this);
2333
this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this);
2434
this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit, this);
2535
this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick, this);
2636
this.on('mousedown', '#layout > #modal', this.onModalLeave, this);
2737
this.on('click', '.modal-header > button', this.onModalClose, this);
28-
this.on('keydown', this.onKeyDown, this);
38+
this.on('paste', '#modal form', this.onPaste, this);
39+
this.on('change', '#modal form', this.onChange, this);
40+
this.on('keydown', '#modal form', this.onKeyDown, this);
41+
this.on('keydown', this.onEscapeKey, this);
2942
};
3043

3144
Modal.prototype = new Icinga.EventListener();
@@ -181,7 +194,34 @@
181194
var $target = $(event.target);
182195

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

@@ -197,21 +237,51 @@
197237
};
198238

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

207-
if (! event.isDefaultPrevented() && event.key === 'Escape') {
208-
let $modal = _this.$layout.children('#modal');
209-
if ($modal.length) {
210-
_this.hide($modal);
211-
}
267+
if (! functions?.isSpecialKeyPress(event)) {
268+
_this.hasChanges = true;
212269
}
213270
};
214271

272+
/**
273+
* Event handler to register whether the modal form has been changed.
274+
*
275+
* In addition to `onKeyDown`, this is needed because checkboxes or select elements
276+
* do only trigger the `change` event, but at least rather reliably.
277+
*
278+
* @param event {Event} The change event
279+
*/
280+
Modal.prototype.onChange = function(event) {
281+
const _this = event.data.self;
282+
_this.hasChanges = true;
283+
};
284+
215285
/**
216286
* Make final preparations and add the modal to the DOM
217287
*
@@ -240,6 +310,36 @@
240310
this.icinga.ui.focusElement($modal.find('.modal-window'));
241311
};
242312

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

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

0 commit comments

Comments
 (0)