fix(Modal): allow Escape to close ContextualMenu before closing Modal#1339
fix(Modal): allow Escape to close ContextualMenu before closing Modal#1339koushik717 wants to merge 8 commits into
Conversation
|
koushik717 is not a collaborator of the repo |
- Schema-driven forms for 8 cloud-init modules (users, packages, runcmd, write_files, ssh, hostname, timezone, ntp) - Real-time YAML output with Monaco Editor starting with #cloud-config - Client-side validation with Ajv against official cloud-init JSON schema - Server-side validation via FastAPI backend - 5 built-in templates: Ubuntu Server, Docker Host, Kubernetes Node, Web Server, Developer Workstation - Shareable config links via lz-string URL encoding - Full keyboard navigation and ARIA accessibility - axe-core accessibility tests in CI - Built with @canonical/react-components (Vanilla Framework) - Motivated by canonical/cloud-init#6796 and canonical/react-components#1339
jmuzina
left a comment
There was a problem hiding this comment.
Thanks for the contribution @koushik717 and apologies that this slipped beneath the radar - leaving a comment below with a suggestion for improvement!
…stack Addresses review feedback on canonical#1339. The previous approach queried the DOM for a ContextualMenu-specific selector, making the fix tightly coupled to one component and invisible to custom consumer overlays. This replaces it with a component-agnostic solution used by every major overlay library (Radix, Headless UI, etc.): a module-level LIFO handler stack. - Add src/hooks/useEscapeStack.ts — exports pushEscapeHandler (low-level) and useEscapeStack (React hook). Only the top-of-stack handler fires per Escape keypress; stopImmediatePropagation() blocks raw document listeners. - Rewrite useOnEscapePressed to delegate to pushEscapeHandler. - Rewrite Modal to use useOnEscapePressed; remove Escape from its keydown map. - Rewrite usePortal to register on the stack only while the portal is open, so the LIFO order faithfully reflects the visual overlay stack. Result: the most recently opened overlay always handles Escape first, regardless of component type, DOM position, or portal placement.
When a ContextualMenu is rendered inside a Modal, pressing Escape failed to close the menu because Modal's handleEscKey called stopImmediatePropagation() unconditionally. Since ContextualMenu renders via a Portal (a DOM sibling), its document-level keydown listener was silenced before it could run. Fix by checking for an open contextual menu dropdown (.p-contextual-menu__dropdown[aria-hidden="false"]) before stopping propagation. If one exists, the Escape event is allowed to continue so the menu's own handler can close it first. A subsequent Escape press will then close the Modal as expected. Fixes canonical#1305
…stack Addresses review feedback on canonical#1339. The previous approach queried the DOM for a ContextualMenu-specific selector, making the fix tightly coupled to one component and invisible to custom consumer overlays. This replaces it with a component-agnostic solution used by every major overlay library (Radix, Headless UI, etc.): a module-level LIFO handler stack. - Add src/hooks/useEscapeStack.ts — exports pushEscapeHandler (low-level) and useEscapeStack (React hook). Only the top-of-stack handler fires per Escape keypress; stopImmediatePropagation() blocks raw document listeners. - Rewrite useOnEscapePressed to delegate to pushEscapeHandler. - Rewrite Modal to use useOnEscapePressed; remove Escape from its keydown map. - Rewrite usePortal to register on the stack only while the portal is open, so the LIFO order faithfully reflects the visual overlay stack. Result: the most recently opened overlay always handles Escape first, regardless of component type, DOM position, or portal placement.
8a2a351 to
0b0d300
Compare
|
@jmuzina — branch rebased onto latest main (853 tests pass) and ready for re-review. Happy to adjust anything further! |
|
@jmuzina — just a gentle follow-up in case this got lost in the queue. The LIFO escape-stack approach from the last revision fully addresses the coupling concern, and the branch is rebased on current |
|
@kimanhou @Ninfa-Jeon — apologies for the extra ping, but in case it's helpful: this PR fixes a real accessibility bug (#1305) where pressing Escape inside a Modal dismisses the Modal instead of the open ContextualMenu first. The fix uses a component-agnostic LIFO escape-key stack (same pattern as Radix UI / Headless UI), all 853 tests pass, and the branch is rebased on current |
|
Hi, apologies for the delay. I'll get you a re-review today! |
There was a problem hiding this comment.
Pull request overview
This PR fixes Escape-key dismissal ordering for nested overlays (e.g., ContextualMenu inside Modal) by introducing a global LIFO “escape handler stack” so only the topmost overlay handles Escape, independent of DOM/portal structure.
Changes:
- Added a module-level Escape handler stack (
pushEscapeHandler,useEscapeStack) and tests. - Updated
useOnEscapePressed,usePortal, andModalto register Escape handling via the global stack. - Added/updated regression tests to ensure nested overlay Escape dismisses in LIFO order.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.ts | Exports the new escape-stack APIs from the package entrypoint. |
| src/hooks/useOnEscapePressed.ts | Migrates Escape handling to the global escape-stack. |
| src/hooks/useEscapeStack.ts | Introduces the global LIFO Escape handler stack + React hook wrapper. |
| src/hooks/useEscapeStack.test.ts | Adds unit tests for LIFO ordering and propagation behavior. |
| src/hooks/index.ts | Re-exports the new escape-stack APIs from the hooks barrel. |
| src/external/usePortal.ts | Registers Escape-to-close for portals via the global stack only while open. |
| src/components/Modal/Modal.tsx | Removes Modal’s manual Escape handling and uses useOnEscapePressed instead. |
| src/components/Modal/Modal.test.tsx | Updates Escape propagation test and adds regression coverage for #1305. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Use capture phase in pushEscapeHandler so the stack fires before any bubble-phase listeners and cannot be bypassed by stopPropagation() on a focused child element - Guard pushEscapeHandler for SSR (no-op when document is undefined) - Use a ref in useEscapeStack and useOnEscapePressed so inline callbacks do not cause re-registration on every re-render, which would incorrectly reorder the LIFO stack - Use a ref for closePortal in usePortal so identity changes to onClose prop do not trigger unnecessary re-registration - Update regression test to open ContextualMenu via click (after Modal mounts) so effect registration order reflects real-world usage
|
Thanks @Copilot all four points addressed in the latest commit (16813f7):
|
…ck regression, trim public API - Rename useEscapeStack's isActive option to isEnabled for consistency with useOnEscapePressed. - Navigation and SearchAndFilter now only register their escape handler while their own overlay (search box / filter panel) is actually open, fixing a regression where they could permanently occupy the top of the LIFO stack and swallow Escape presses meant for other overlays. - Remove pushEscapeHandler from the public package exports; only the useEscapeStack hook is part of the public API now, consistent with the rest of the hooks folder.
|
Thanks @jmuzina — really appreciate the careful re-review! All addressed in the latest commit (82eb54b):
All 856 tests pass, lint and typecheck are clean. Let me know if you'd like anything else adjusted! |
@koushik717 This works for these two components within React Components, but it would still be a breaking change for consumers of Before we write any more code: how would you recommend proceeding? Can this fix can be implemented without a breaking change to A breaking change is OK if absolutely necessary, we just need to be sure of it amongst ourselves and within Canonical's React Working Group |
… change useOnEscapePressed is restored to its original implementation (a plain per-component document keydown listener). It no longer participates in the global LIFO escape stack, so its behavior for all existing consumers (internal and external) is byte-for-byte identical to before this PR. Modal now joins the LIFO stack directly via useEscapeStack instead of useOnEscapePressed, since Modal is the component that actually needs LIFO-ordered dismissal alongside Portal-based overlays (ContextualMenu, etc. via usePortal). This decouples the new opt-in stack from the existing widely-consumed hook entirely, addressing the breaking-change concern for external consumers of useOnEscapePressed.
|
@jmuzina good question, and yes this can be done with zero breaking change to
So in short: the new LIFO stack ( All 856 tests pass, lint and typecheck clean. Let me know if this addresses the concern or if you'd like the LIFO opt-in surfaced differently! |
…ape stack Copilot correctly identified that the previous implementation's stopImmediatePropagation() ran whenever the stack was non-empty, so a single open ContextualMenu/Dropdown (via usePortal) would silently block unrelated document keydown listeners elsewhere on the page, including useOnEscapePressed-based components and any external consumer of that hook. This was a real, broader regression than the original Modal-only behavior (Modal has always claimed Escape exclusively while open - this is pre-existing, intentional, and covered by an existing test). Each escape-stack entry now has an `exclusive` flag (default true): - Exclusive entries (Modal, via useEscapeStack) claim Escape entirely while on top of the stack, preserving Modal's long-standing behaviour. - Non-exclusive entries (portal-based overlays, via usePortal) handle their own dismissal but let the event continue propagating, so they don't silence unrelated listeners when used standalone. When a non-exclusive entry sits above an exclusive one (e.g. ContextualMenu opened inside a Modal), the first Escape is handled by the ContextualMenu without claiming the event, and the Modal underneath is skipped for that press - preserving the two-step dismiss fix for canonical#1305. Also fixes a related Copilot finding: pushEscapeHandler now stores a unique entry object per registration instead of removing by handler-reference (lastIndexOf), so registering the same function reference more than once can no longer corrupt the stack. Added tests for exclusive/non-exclusive ordering, the duplicate-reference unregister case, and a regression test confirming a standalone ContextualMenu no longer blocks an unrelated useOnEscapePressed listener.
|
@jmuzina @Copilot — Copilot's two new comments were both legit, and the first one in particular gets right at the heart of your "before we write any more code" question, so let me address it properly here rather than with another drive-by patch. Copilot's finding #1 was correct, and worse than it sounds. I reproduced it locally before fixing: a single open One nuance though: Copilot's framing ("reintroduces the original layered-dismiss issue") isn't quite right for Modal — Modal has always called Fix (pushed in d5bc53b): each escape-stack entry now has an
This also directly answers your open question: Copilot's finding #2 ( New tests cover: exclusive vs non-exclusive ordering, the duplicate-reference unregister case, and a regression test ( All 860 tests pass, lint and typecheck clean. @jmuzina — given this addresses both the breaking-change question and Copilot's findings without touching |
Problem
Fixes #1305
When a
ContextualMenuis open inside aModal, pressing Escape closes the Modal immediately rather than the menu first. This breaks the expected layered-dismiss UX and is an accessibility issue.Root cause
Modal.tsxcalledstopImmediatePropagation()unconditionally. BecauseContextualMenurenders via a Portal (a DOM sibling, not a React child), its owndocumentkeydown listener was silenced before it could fire.Fix — component-agnostic global LIFO escape-key stack
Following @edlerd's exploration in #1305, this implements the global handler-stack approach used by every major overlay library (Radix UI, Headless UI, Chakra UI).
New:
src/hooks/useEscapeStack.tsA module-level LIFO array of callbacks. A single
documentkeydown listener calls only the top-of-stack handler and then callsstopImmediatePropagation()— no component needs to know about any other.useOnEscapePressed— delegates topushEscapeHandlerNo API change; existing call-sites are unaffected.
Modal— usesuseOnEscapePressedThe Escape branch is removed from Modal's manual keydown map. Modal now registers via the stack, so it naturally yields to any overlay opened after it.
usePortal— registers on the stack only while openA new
useEffect([isOpen, closeOnEsc, …])registers the handler when the portal opens and pops it when it closes. The stack always reflects the current visual overlay depth.Result
The most recently opened overlay handles Escape first, regardless of component type, DOM position, or whether it is a consumer-defined overlay (consumers can call
useEscapeStackorpushEscapeHandlerto join the same stack).Two-step dismiss for Modal + ContextualMenu:
Changes
src/hooks/useEscapeStack.ts— New: LIFO stack +useEscapeStackhook +pushEscapeHandlersrc/hooks/useEscapeStack.test.ts— New: unit tests for stack ordering and propagationsrc/hooks/useOnEscapePressed.ts— Delegates topushEscapeHandlersrc/components/Modal/Modal.tsx— UsesuseOnEscapePressed; Escape removed from keydown mapsrc/components/Modal/Modal.test.tsx— Updated + regression test forContextualMenucannot be closed with ESCAPE key when inside a Modal #1305src/external/usePortal.ts— Registers on escape stack only while portal is opensrc/hooks/index.ts/src/index.ts— Export new hook and utilityTesting