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 -0Ignore whitespace
public/scripts/popup.js+53 -0
@@ -1,5 +1,6 @@
1import dialogPolyfill from '../lib/dialog-polyfill.esm.js';1import dialogPolyfill from '../lib/dialog-polyfill.esm.js';
2import { shouldSendOnEnter } from './RossAscends-mods.js';2import { shouldSendOnEnter } from './RossAscends-mods.js';
3import { t } from './i18n.js';
3import { power_user, toastPositionClasses } from './power-user.js';4import { power_user, toastPositionClasses } from './power-user.js';
4import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';5import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
56
@@ -173,6 +174,8 @@ export class Popup {
173 /** @type {Promise<any>} */ #promise;174 /** @type {Promise<any>} */ #promise;
174 /** @type {(result: any) => any} */ #resolver;175 /** @type {(result: any) => any} */ #resolver;
175 /** @type {boolean} */ #isClosingPrevented;176 /** @type {boolean} */ #isClosingPrevented;
177 /** @type {number} */ #lastEscapePress = 0;
178 /** @type {boolean} */ #isShowingForceCloseConfirm = false;
176179
177 /**180 /**
178 * Constructs a new Popup object with the given text content, type, inputValue, and options181 * Constructs a new Popup object with the given text content, type, inputValue, and options
@@ -476,6 +479,56 @@ export class Popup {
476479
477 // Bind dialog listeners manually, so we can be sure context is preserved480 // Bind dialog listeners manually, so we can be sure context is preserved
478 const cancelListener = async (evt) => {481 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
479 evt.preventDefault();532 evt.preventDefault();
480 evt.stopPropagation();533 evt.stopPropagation();
481 await this.complete(POPUP_RESULT.CANCELLED);534 await this.complete(POPUP_RESULT.CANCELLED);