Skip to content

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

Open
koushik717 wants to merge 8 commits into
canonical:mainfrom
koushik717:fix/contextual-menu-escape-in-modal
Open

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

Conversation

@koushik717

@koushik717 koushik717 commented Apr 1, 2026

Copy link
Copy Markdown

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)

@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

@jmuzina jmuzina left a comment

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.

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
koushik717 added a commit to koushik717/react-components that referenced this pull request Apr 29, 2026
…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.
@koushik717 koushik717 force-pushed the fix/contextual-menu-escape-in-modal branch from 8a2a351 to 0b0d300 Compare May 16, 2026 02:37
@koushik717

Copy link
Copy Markdown
Author

@jmuzina — branch rebased onto latest main (853 tests pass) and ready for re-review. Happy to adjust anything further!

@koushik717

Copy link
Copy Markdown
Author

@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 main with all 853 tests passing. Whenever you have a moment for a second look it would be much appreciated!

@koushik717

Copy link
Copy Markdown
Author

@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 main. If either of you has a moment to take a look it would be greatly appreciated!

@jmuzina

jmuzina commented Jun 9, 2026

Copy link
Copy Markdown
Member

Hi, apologies for the delay. I'll get you a re-review today!

Copilot AI left a comment

Copy link
Copy Markdown

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 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, and Modal to 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.

Comment thread src/hooks/useOnEscapePressed.ts Outdated
Comment thread src/hooks/useEscapeStack.ts Outdated
Comment thread src/hooks/useEscapeStack.ts Outdated
Comment thread src/hooks/useEscapeStack.ts
Comment thread src/external/usePortal.ts Outdated
- 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
@koushik717

koushik717 commented Jun 9, 2026

Copy link
Copy Markdown
Author

Thanks @Copilot all four points addressed in the latest commit (16813f7):

  • Capture phase: pushEscapeHandler now registers with { capture: true } so the stack fires before any bubble-phase listener and cannot be bypassed by stopPropagation() on a child element.
  • SSR guard: pushEscapeHandler no-ops when document is undefined.
  • Stable ref pattern: Both useEscapeStack and useOnEscapePressed now keep the latest callback in a ref and register a stable wrapper, so inline callbacks do not cause re-registration on every render.
  • closePortal ref in usePortal: The escape-stack effect no longer lists closePortal as a dependency a ref tracks the latest value so identity changes to onClose don't re-register and reorder the stack.
  • Test updated: The regression test now clicks to open the ContextualMenu after the Modal mounts, which accurately reflects real-world usage and correct LIFO registration order.

Comment thread src/hooks/useEscapeStack.ts Outdated
Comment thread src/hooks/useOnEscapePressed.ts Outdated
Comment thread src/hooks/useEscapeStack.ts
…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.
@koushik717

koushik717 commented Jun 10, 2026

Copy link
Copy Markdown
Author

Thanks @jmuzina — really appreciate the careful re-review! All addressed in the latest commit (82eb54b):

  • Naming: Renamed isActive -> isEnabled in useEscapeStack to match useOnEscapePressed.
  • Navigation/SearchAndFilter regression: Great catch, and you were right, this was a real bug. Both components now pass isEnabled tied to their actual open state (searchOpen for Navigation, !filterPanelHidden for SearchAndFilter), so they only occupy a slot on the stack while their own overlay is actually visible. Added regression tests to both test files reproducing the scenario from your example.
  • pushEscapeHandler export: Agreed it was an outlier — removed it from the public package exports (hooks/index.ts / src/index.ts). Only useEscapeStack (the hook) is part of the public API now, consistent with the rest of the hooks folder. pushEscapeHandler stays as an internal primitive used by useOnEscapePressed/usePortal. Happy to explore a context-provider approach as a follow-up for pragma if that's still useful, but wanted to keep this fix minimal and non-breaking for now.

All 856 tests pass, lint and typecheck are clean. Let me know if you'd like anything else adjusted!

@jmuzina

jmuzina commented Jun 10, 2026

Copy link
Copy Markdown
Member

this was a real bug. Both components now pass isEnabled tied to their actual open state (searchOpen for Navigation, !filterPanelHidden for SearchAndFilter), so they only occupy a slot on the stack while their own overlay is actually visible. Added regression tests to both test files reproducing the scenario from your example.

@koushik717 This works for these two components within React Components, but it would still be a breaking change for consumers of useOnEscapePressed who are outside of this repo (see projects).

Before we write any more code: how would you recommend proceeding? Can this fix can be implemented without a breaking change to useOnEscapePressed?

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.
@koushik717

koushik717 commented Jun 11, 2026

Copy link
Copy Markdown
Author

@jmuzina good question, and yes this can be done with zero breaking change to useOnEscapePressed. Pushed in 99f88d1:

  • useOnEscapePressed is reverted to its exact pre-PR implementation a plain per-component document keydown listener, completely independent of the new stack. Behavior for every existing consumer (internal or external, with or without isEnabled) is byte-for-byte identical to before this PR. No one needs to change anything.
  • Modal now joins the LIFO stack via useEscapeStack directly instead of going through useOnEscapePressed. Modal is the component that actually needs LIFO ordering against Portal-based overlays (ContextualMenu via usePortal, etc.), so it opts in explicitly.
  • usePortal already used pushEscapeHandler directly (unchanged).
  • The isEnabled-based changes to Navigation/SearchAndFilter from the last commit are harmless either way (the original useOnEscapePressed already supported isEnabled and just skips registering the listener), so I left those in as a small hygiene improvement, but they're no longer load-bearing for correctness.

So in short: the new LIFO stack (useEscapeStack/pushEscapeHandler) is purely additive and currently only opted into by Modal and usePortal. useOnEscapePressed is untouched/non-breaking, so there's no change in behavior for SearchAndFilter, Navigation, or any external consumer of that hook.

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!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/hooks/useEscapeStack.ts Outdated
Comment thread src/hooks/useEscapeStack.ts Outdated
…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.
@koushik717

Copy link
Copy Markdown
Author

@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 ContextualMenu (no Modal anywhere) was silently blocking an unrelated useOnEscapePressed-based listener elsewhere on the page, because pushEscapeHandler's onKeyDown called stopImmediatePropagation() whenever the stack was non-empty — not just when nested overlays were involved. That's a much bigger blast radius than the original bug (#1305 was specifically about ContextualMenu inside Modal).

One nuance though: Copilot's framing ("reintroduces the original layered-dismiss issue") isn't quite right for Modal — Modal has always called stopImmediatePropagation() in capture phase while open, even on its own (that's pre-existing behavior, covered by the "should stop immediate Esc press propagation to other document listeners" test that predates this PR). The genuinely new over-blocking came from usePortal adopting the same exclusive behavior, which it never had before.

Fix (pushed in d5bc53b): each escape-stack entry now has an exclusive flag:

  • Modal (via useEscapeStack) stays exclusive: true — unchanged, long-standing behavior.
  • usePortal (ContextualMenu, Dropdown, etc., via pushEscapeHandler) is now exclusive: false — it handles its own dismissal but does not call stopImmediatePropagation(), so it no longer silences anything else when used standalone.
  • When a non-exclusive entry sits on top of an exclusive one (ContextualMenu opened inside Modal), the first Escape is handled by the ContextualMenu only and the Modal underneath is skipped for that press — the two-step dismiss for ContextualMenu cannot be closed with ESCAPE key when inside a Modal #1305 still works.

This also directly answers your open question: useOnEscapePressed remains completely untouched and non-breaking — it's not part of the stack at all, so this exclusive flag is purely an internal detail of useEscapeStack/pushEscapeHandler, which are brand new in this PR (no external consumers yet).

Copilot's finding #2 (lastIndexOf(handler) could remove the wrong entry if the same function reference were pushed twice) is also fixed — pushEscapeHandler now wraps each registration in a unique entry object, so unregistering always removes the exact entry that was pushed, regardless of reference equality.

New tests cover: exclusive vs non-exclusive ordering, the duplicate-reference unregister case, and a regression test (ContextualMenu.test.tsx) reproducing Copilot's exact scenario — a standalone open ContextualMenu no longer blocks an unrelated useOnEscapePressed listener.

All 860 tests pass, lint and typecheck clean.

@jmuzina — given this addresses both the breaking-change question and Copilot's findings without touching useOnEscapePressed at all, does this seem like a reasonable place to land, or would you prefer a different shape (e.g. surfacing exclusive differently, or going further toward unifying useOnEscapePressed onto the stack as a separate, deliberate breaking change for a future major version)? Happy to go either way — just want to make sure we're aligned before any further changes.

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

4 participants