Skip to content

fix(Modal): allow Escape to close ContextualMenu before closing Modal#1339

Open
koushik717 wants to merge 2 commits intocanonical:mainfrom
koushik717:fix/contextual-menu-escape-in-modal
Open

fix(Modal): allow Escape to close ContextualMenu before closing Modal#1339
koushik717 wants to merge 2 commits intocanonical:mainfrom
koushik717:fix/contextual-menu-escape-in-modal

Conversation

@koushik717
Copy link
Copy Markdown

@koushik717 koushik717 commented Apr 1, 2026

Problem

Fixes #1305

When a ContextualMenu is open inside a Modal, 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.tsx called stopImmediatePropagation() unconditionally. Because ContextualMenu renders via a Portal (a DOM sibling, not a React child), its own document keydown 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.ts

A module-level LIFO array of callbacks. A single document keydown listener calls only the top-of-stack handler and then calls stopImmediatePropagation() — no component needs to know about any other.

// low-level imperative API (used internally)
const unregister = pushEscapeHandler(() => closeMyOverlay());

// React hook API (for consumers)
useEscapeStack(() => closeMyOverlay(), { isActive: isOpen });

useOnEscapePressed — delegates to pushEscapeHandler

No API change; existing call-sites are unaffected.

Modal — uses useOnEscapePressed

The 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 open

A 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 useEscapeStack or pushEscapeHandler to join the same stack).

Two-step dismiss for Modal + ContextualMenu:

  1. First Escape → ContextualMenu closes (top of stack)
  2. Second Escape → Modal closes (now top of stack)

Changes

  • src/hooks/useEscapeStack.ts — New: LIFO stack + useEscapeStack hook + pushEscapeHandler
  • src/hooks/useEscapeStack.test.ts — New: unit tests for stack ordering and propagation
  • src/hooks/useOnEscapePressed.ts — Delegates to pushEscapeHandler
  • src/components/Modal/Modal.tsx — Uses useOnEscapePressed; Escape removed from keydown map
  • src/components/Modal/Modal.test.tsx — Updated + regression test for ContextualMenu cannot be closed with ESCAPE key when inside a Modal #1305
  • src/external/usePortal.ts — Registers on escape stack only while portal is open
  • src/hooks/index.ts / src/index.ts — Export new hook and utility

Testing

yarn lint  ✓  (0 errors)
yarn test  ✓  (837 tests, 103 suites)

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
@webteam-app
Copy link
Copy Markdown

koushik717 is not a collaborator of the repo

koushik717 added a commit to koushik717/cloud-init-builder that referenced this pull request Apr 3, 2026
- 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
Copy link
Copy Markdown
Member

@jmuzina jmuzina left a comment

Choose a reason for hiding this comment

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

Thanks for the contribution @koushik717 and apologies that this slipped beneath the radar - leaving a comment below with a suggestion for improvement!

Comment thread src/components/Modal/Modal.tsx Outdated
Comment on lines +94 to +106
// 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;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

@koushik717 koushik717 Apr 29, 2026

Choose a reason for hiding this comment

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

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ContextualMenu cannot be closed with ESCAPE key when inside a Modal

3 participants