Skip to content

Make Escape close top-most modal#202

Merged
vkarpov15 merged 1 commit intomainfrom
codex/make-escape-button-close-modals
Mar 31, 2026
Merged

Make Escape close top-most modal#202
vkarpov15 merged 1 commit intomainfrom
codex/make-escape-button-close-modals

Conversation

@vkarpov15
Copy link
Copy Markdown
Member

Motivation

  • Ensure pressing Escape consistently closes modals while avoiding accidental closure of background dialogs by only targeting the active/top-most modal.

Description

  • Added an onEscape handler to the shared modal component in frontend/src/modal/modal.js and wired it up with window.addEventListener('keydown') on mount and cleanup on beforeUnmount.
  • onEscape checks event.key === 'Escape', finds all .modal-mask elements, determines whether this instance is the top-most modal, and if so invokes the modal close control by clicking .modal-exit or [data-modal-close] to reuse existing per-modal close logic.
  • Robustified element resolution so the component works whether this.$el is the mask element or contains it.
  • Added unit tests test/frontend/modal.test.js to verify Escape closes the top-most modal and does nothing for non-top-most modals.

Testing

  • Ran the frontend test suite with npm run test:frontend and all frontend tests passed (10 passing).

Codex Task

Copilot AI review requested due to automatic review settings March 31, 2026 18:32
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
studio Ready Ready Preview, Comment Mar 31, 2026 6:33pm

@vkarpov15 vkarpov15 merged commit 87d19b2 into main Mar 31, 2026
32 checks passed
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7923a115f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return;
}

const closeButton = currentMask.querySelector('.modal-exit, [data-modal-close]');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle Escape for modals without .modal-exit controls

The new Escape handler only closes a modal by clicking .modal-exit or [data-modal-close], so top-most modals that use other close controls never close on Escape. This already affects existing modal bodies that use plain clickable × elements without either selector (for example in frontend/src/tasks/tasks.html, frontend/src/task-single/task-single.html, and frontend/src/chat/chat-message-script/chat-message-script.html), which makes Escape behavior inconsistent across the app despite this shared modal change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes the shared frontend modal component respond to the Escape key by closing only the active/top-most modal, preventing background modals from being closed accidentally.

Changes:

  • Added a global keydown listener in the modal component to handle Escape and close only the top-most modal.
  • Implemented “top-most modal” detection via .modal-mask DOM ordering and re-used existing close logic by clicking the close control.
  • Added frontend unit tests validating top-most vs non-top-most Escape behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
frontend/src/modal/modal.js Adds Escape key handling + lifecycle registration/cleanup for a global keydown handler.
test/frontend/modal.test.js Adds unit tests for Escape behavior (top-most modal closes; non-top-most does not).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +15
window.addEventListener('keydown', this.onEscape);
},
beforeUnmount() {
window.removeEventListener('keydown', this.onEscape);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mounted()/beforeUnmount() call window.addEventListener/removeEventListener unconditionally. In this repo's frontend test setup global.window is a plain object without these methods, and other components guard on typeof window !== 'undefined' && window.addEventListener. Add the same guard here to prevent runtime errors in non-browser/test contexts.

Suggested change
window.addEventListener('keydown', this.onEscape);
},
beforeUnmount() {
window.removeEventListener('keydown', this.onEscape);
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('keydown', this.onEscape);
}
},
beforeUnmount() {
if (typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('keydown', this.onEscape);
}

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +31
const currentMask = this.$el?.classList?.contains('modal-mask') ? this.$el : this.$el?.querySelector('.modal-mask') || this.$el;
const isTopMostModal = modalMasks.length > 0 && modalMasks[modalMasks.length - 1] === currentMask;

if (!isTopMostModal) {
return;
}

const closeButton = currentMask.querySelector('.modal-exit, [data-modal-close]');
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.$el?.querySelector('.modal-mask') will still throw if $el exists but does not implement querySelector (e.g., comment/fragment roots). Use optional chaining on the method (e.g., this.$el?.querySelector?.(...)) and/or ensure currentMask is an Element before calling currentMask.querySelector(...) to avoid Escape key crashes.

Suggested change
const currentMask = this.$el?.classList?.contains('modal-mask') ? this.$el : this.$el?.querySelector('.modal-mask') || this.$el;
const isTopMostModal = modalMasks.length > 0 && modalMasks[modalMasks.length - 1] === currentMask;
if (!isTopMostModal) {
return;
}
const closeButton = currentMask.querySelector('.modal-exit, [data-modal-close]');
const root = this.$el;
const currentMask = root?.classList?.contains('modal-mask') ? root : root?.querySelector?.('.modal-mask') || root;
const isTopMostModal = modalMasks.length > 0 && modalMasks[modalMasks.length - 1] === currentMask;
if (!isTopMostModal) {
return;
}
const closeButton = typeof currentMask?.querySelector === 'function'
? currentMask.querySelector('.modal-exit, [data-modal-close]')
: null;

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +33
};

global.document = {
querySelectorAll: () => [currentMask]
};

componentDef.methods.onEscape.call({ $el: currentMask }, { key: 'Escape' });
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests overwrite global.document but never restore it, which can leak state into other frontend tests depending on execution order. Save the previous value and restore it in afterEach/after (or use a sinon stub) to keep the suite isolated.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +11
describe('modal component escape handling', function() {
it('clicks the close button for the top-most modal when escape is pressed', function() {
const componentDef = modalComponent({ component: (_name, def) => def });

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current tests only exercise methods.onEscape directly and don't cover the newly added lifecycle behavior (registering/removing the keydown listener). Consider adding a test that mounts/unmounts the component (or at least calls mounted/beforeUnmount) and asserts listener registration/cleanup to prevent handler leaks across modal lifetimes.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants