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 2 commits intocanonical:mainfrom
Conversation
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
|
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!
| // If there is an open contextual menu dropdown inside (or alongside) this | ||
| // modal, let the Escape event propagate so that the menu's own keydown | ||
| // handler can close it first. The modal should only intercept Escape when | ||
| // no child overlay is open. This fixes the bug where a ContextualMenu | ||
| // rendered inside a Modal cannot be closed with the Escape key because | ||
| // stopImmediatePropagation() was called unconditionally, preventing the | ||
| // menu's document-level keydown listener from ever firing. | ||
| const hasOpenDropdown = !!document.querySelector( | ||
| '.p-contextual-menu__dropdown[aria-hidden="false"]', | ||
| ); | ||
| if (hasOpenDropdown) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
This would make this behavior reliant on maintaining a list of other components that register their own escape focus handlers within Modal - making it quite tightly coupled with other components and tough to maintain. It would also reduce interoperability with custom consumer components that might register their own escape focus handlers (as they are not included in this query selector). I think any solution to this issue needs to be agnostic to the specific relationships between components.
@edlerd posted a thorough exploration of this problem with some options to try out - maybe that can help improve this solution
There was a problem hiding this comment.
Thanks for the thorough feedback! I've reworked the fix completely the CSS selector approach is gone.
The new implementation introduces a component-agnostic global LIFO escape-key stack (src/hooks/useEscapeStack.ts), following the approach @edlerd outlined. A single document listener sits in front of the stack and invokes only the top handler per keypress no component needs any knowledge of any other.
Both Modal (via useOnEscapePressed) and usePortal (with an open-aware useEffect) now register through the same stack. The most recently opened overlay always handles Escape first, regardless of component type, DOM position, or portal placement. Custom consumer overlays can join the same stack via the exported useEscapeStack hook or pushEscapeHandler utility.
All 837 tests pass. Happy to adjust anything further!
…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.
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