Skip to content

Layered Escape dismissal (dismiss stack) + cat-modal fixes#52

Merged
drewda merged 2 commits into
mainfrom
escape-dismiss-stack
Jun 10, 2026
Merged

Layered Escape dismissal (dismiss stack) + cat-modal fixes#52
drewda merged 2 commits into
mainfrom
escape-dismiss-stack

Conversation

@drewda

@drewda drewda commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

The widget accessibility audit's last serious cross-cutting finding (related to interline-io/calact-network-analysis-tool#390, where datepickers are composed inside the Feed Archive modal): Escape pressed while a popup is open inside a modal closed both layers at once, destroying in-progress form state. This adds a shared LIFO dismiss stack so Escape dismisses only the topmost open layer, and folds in the remaining cat-modal audit findings since they share the file.

Layered Escape dismissal

  • New src/util/dismiss-stack.ts: one document-level keydown listener (bubble phase) that, on Escape, dismisses only the top entry and consumes the event. It ignores defaultPrevented so components that handle Escape on their own element and let it bubble (cat-dropdown's menu) are respected, and runs in bubble (not capture) phase so components that stopPropagation on their own element (cat-taginput's input) keep Escape from reaching it at all.
  • useDismissablePopup registers/unregisters its layer on open/close instead of holding a direct document Escape listener, so cat-dropdown and cat-datepicker participate automatically. cat-modal registers its own layer. A popup inside a modal now closes on the first Escape, the modal on the second. A non-closable modal still registers a layer, so it swallows Escape rather than letting it dismiss a surface beneath it.

cat-modal fixes (audit P11)

  • Open-state side effects (html clipping, focus capture, initial focus, overflow tracking) now run when the modal is mounted with modelValue already true, not only on the false-to-true transition. Extracted into named functions called from both the watch and onMounted.
  • The focus trap filters out hidden focusable candidates (checkVisibility), so Tab no longer dead-ends on a display:none button.
  • An overflowing modal-card-body becomes a focusable named region (tabindex="0", role="region", labeled by the title or aria-label) so keyboard users can scroll it; Safari does not make scroll containers focusable automatically. Overflow is tracked with a ResizeObserver while open and the section already matched the focus-trap selector, so it joins the Tab cycle without extra wiring.
  • New tests: layered dropdown-in-modal Escape ordering, non-closable swallowing Escape, mounted-already-open side effects, hidden-candidate focus trap (skipped where jsdom lacks checkVisibility), overflowing-body region, and an axe smoke test.

Test plan

  • In the playground (/controls/modal), open a modal containing a dropdown or datepicker; open that popup; press Escape once (popup closes, modal stays) and again (modal closes).
  • Open a modal with content taller than the viewport; Tab should reach the scrollable body region and arrow keys should scroll it.
  • Confirm a standalone dropdown/datepicker (no modal) still closes on a single Escape.

Generated with Claude Code

Escape inside a modal containing an open dropdown or datepicker closed
both at once. A new LIFO dismiss-stack util dismisses only the topmost
open layer per Escape press; modal/dropdown/datepicker register through
it (popups via useDismissablePopup), taginput keeps self-consuming on
its input. Modal also: runs open side effects when mounted already
open, skips hidden focusable candidates in the focus trap, makes an
overflowing body a focusable named region, and gains an axe test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 10, 2026

Copy link
Copy Markdown

Deploying catenary with  Cloudflare Pages  Cloudflare Pages

Latest commit: cf53e92
Status: ✅  Deploy successful!
Preview URL: https://09d773ad.catenary-5bw.pages.dev
Branch Preview URL: https://escape-dismiss-stack.catenary-5bw.pages.dev

View logs

A new modal demo holds a dropdown and a datepicker so reviewers can
feel the fix: Escape closes only the open popup first, the modal
second, with form state preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@drewda drewda merged commit e3c3dc5 into main Jun 10, 2026
6 checks passed
@drewda drewda deleted the escape-dismiss-stack branch June 10, 2026 23:30
@github-actions github-actions Bot mentioned this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant