Fix: Prevent Escape Key from Closing Non-Dismissable Popups (#5327) * fix(popup): prevent Escape key from closing popups when both cancel and close buttons are hidden Add validation in cancel listener to check visibility of cancel and close buttons before allowing Escape key to close popup. Set isClosingPrevented flag to block subsequent close events when neither button is visible. * feat(popup): add double-escape force-close mechanism for blocking popups Add double-escape detection (within 500ms) to allow force-closing blocking popups that have both cancel and close buttons hidden. Show confirmation dialog warning about potential inconsistent state before allowing force-close. Track last escape press timestamp and confirmation popup state to prevent duplicate dialogs. Gracefully cancel force-close confirmation if main popup closes naturally.

7766d41c42e6a7163877f7173814b00c8b585d05

Wolfsblvt <wolfsblvt@gmail.com>

Signed
1 files changed, +53 -0Showing whitespace changes
public/scripts/popup.js+53 -0
@@ -1,5 +1,6 @@
11import dialogPolyfill from '../lib/dialog-polyfill.esm.js';
22import { shouldSendOnEnter } from './RossAscends-mods.js';
3+import { t } from './i18n.js';
34import { power_user, toastPositionClasses } from './power-user.js';
45import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
56
@@ -173,6 +174,8 @@ export class Popup {
173174 /** @type {Promise<any>} */ #promise;
174175 /** @type {(result: any) => any} */ #resolver;
175176 /** @type {boolean} */ #isClosingPrevented;
177+ /** @type {number} */ #lastEscapePress = 0;
178+ /** @type {boolean} */ #isShowingForceCloseConfirm = false;
176179
177180 /**
178181 * Constructs a new Popup object with the given text content, type, inputValue, and options
@@ -476,6 +479,56 @@ export class Popup {
476479
477480 // Bind dialog listeners manually, so we can be sure context is preserved
478481 const cancelListener = async (evt) => {
482+ // If neither cancel button nor close button is visible or present, don't allow escape to close the popup
483+ const hasCancelButton = this.cancelButton?.offsetParent !== null && this.buttonControls?.offsetParent !== null;
484+ const hasCloseButton = this.closeButton?.offsetParent !== null;
485+ if (!hasCancelButton && !hasCloseButton) {
486+ evt.preventDefault();
487+ evt.stopPropagation();
488+ // Set flag so closeListener also blocks the close event (browser may fire it after multiple Escape presses)
489+ this.#isClosingPrevented = true;
490+
491+ // Check for double-escape within 500ms to allow force-closing
492+ const now = Date.now();
493+ const timeSinceLastEscape = now - this.#lastEscapePress;
494+ this.#lastEscapePress = now;
495+
496+ if (timeSinceLastEscape < 500 && !this.#isShowingForceCloseConfirm) {
497+ this.#isShowingForceCloseConfirm = true;
498+
499+ // Defer to next frame to escape the current event context,
500+ // allowing the confirmation popup to stack properly on top
501+ requestAnimationFrame(async () => {
502+ const confirmPopup = new Popup(
503+ PopupUtils.BuildTextWithHeader(
504+ t`Force-close Blocking Popup`, `
505+ <p>${t`This action is blocking and not meant to be closed manually.`}</p>
506+ <p>${t`Force-closing may leave the application in an inconsistent state.`}</p>
507+ <p><strong>${t`Are you sure you want to force-close?`}</strong></p>`),
508+ POPUP_TYPE.CONFIRM,
509+ '',
510+ { okButton: t`Force Close`, cancelButton: t`Cancel` });
511+
512+ // If the the main popup closes while the force-close popup is still being displayed, we gracefully cancel that.
513+ const originalOnClose = this.onClose;
514+ this.onClose = async (x) => {
515+ if (originalOnClose) await originalOnClose;
516+ await confirmPopup.completeCancelled();
517+ };
518+
519+
520+ const result = await confirmPopup.show();
521+ this.#isShowingForceCloseConfirm = false;
522+ if (result === POPUP_RESULT.AFFIRMATIVE) {
523+ // Force-close by bypassing the normal close prevention
524+ this.#isClosingPrevented = false;
525+ await this.complete(POPUP_RESULT.CANCELLED);
526+ }
527+ });
528+ }
529+ return;
530+ }
531+
479532 evt.preventDefault();
480533 evt.stopPropagation();
481534 await this.complete(POPUP_RESULT.CANCELLED);