Skip to content

fix(ui): restore Escape dismissal in settings and while tooltips show - #272

Draft
kabarza wants to merge 1 commit into
CoreBunch:mainfrom
kabarza:fix/escape-dismissal
Draft

fix(ui): restore Escape dismissal in settings and while tooltips show#272
kabarza wants to merge 1 commit into
CoreBunch:mainfrom
kabarza:fix/escape-dismissal

Conversation

@kabarza

@kabarza kabarza commented Jul 25, 2026

Copy link
Copy Markdown

Summary

Escape silently stopped dismissing surfaces in two independent ways. Both are reproducible in the running dev admin.

1. SettingsModal — Escape died as soon as focus left the dialog.

The modal bound Escape as a React onKeyDown on the dialog element, so it only fired while the event target was inside the dialog subtree. Clicking any non-focusable spot in the panel — a label, the section header, empty space — blurs to <body>, which is outside that subtree. From that point Escape did nothing for the rest of the session, while backdrop click kept working (which is why it reads as "only Esc is broken").

Live trace, clicking empty panel space and then pressing Escape:

active: BODY, insideDialog: false
stages: window-capture|BODY → document-capture|BODY → document-bubble|BODY → window-bubble|BODY
dialogStillPresent: true

Note the absence of any #root stage — the event never enters the React root container, so the delegated handler is structurally unreachable.

SettingsModal was the only modal built this way. Dialog, ModuleInserterDialog, MediaPickerModal, StepUpDialog and ConfirmDeleteDialog all bind on document/window and are immune. This moves it onto a document listener to match, with two supporting changes:

  • The listener is gated so only the topmost dialog reacts. Surfaces opened from inside settings (the media picker behind "Browse library…") portal to document.body and therefore follow this dialog in document order; without the gate, one Escape collapsed the entire stack at once.
  • tabIndex={-1} on the panel, so a click on dead space lands on the panel instead of <body>. This keeps the Tab trap honest — previously the next Tab could walk the page behind the modal.

2. Tooltip — a visible tooltip swallowed Escape app-wide.

The capture-phase window handler called preventDefault() + stopPropagation() and only hid the tooltip. Tooltips open on plain hover, so resting the pointer on any trigger consumed the next Escape globally — no modal, context menu or spotlight could close.

Live trace, hovering a toolbar button until its tooltip shows, then pressing Escape:

tooltipVisible: true
stages: ["window-capture"]      ← propagation stops dead here

Escape now hides the tooltip and keeps propagating to whatever surface owns the keystroke. Capture is retained so the tooltip still hides if a downstream handler stops propagation.

3. Dead focus-return branch (drive-by, same root cause).

SettingsModal restored focus to the trigger in an open === false branch, but all three layouts unmount the modal on close, so open never transitions to false while mounted. The branch never ran and focus was never returned to the trigger, contrary to WCAG 2.4.3 / Guideline #225 stated in the file's own header. Restoring in the effect cleanup makes it actually run.

Note for reviewers

This inverts two existing tests that asserted the broken behaviour, which I'd rather flag than have discovered:

  • tooltip.test.tsx had consumes Escape before a parent panel handler can close, asserting parentEscapeCount === 0. That assertion is what the app-wide Escape blackout looked like from the inside. It is now hides on Escape without consuming it, asserting the key reaches other handlers and is not defaultPrevented.
  • toolbar.test.ts had three source-string tests matching the dead triggerRef branch. Replaced with one asserting the restore lives in the effect cleanup, plus one asserting Escape is bound on document rather than as a React onKeyDown.

Tooltip is a shared primitive, so the behaviour change reaches every surface that uses one. The trade-off is deliberate: Escape now dismisses the tooltip and the surface underneath, rather than the tooltip alone. Silently eating a global dismissal key because the pointer happened to rest somewhere is the worse failure.

New behavioural coverage: focus-independent close, stacked-dialog layering, and focus return on unmount.

Verification

  • bun run build
  • bun test — 6282 pass, 0 fail
  • bun run lint
  • Docker/deployment check, if relevant — n/a, no deployment surface touched

Also verified by hand in the running dev admin, before and after, for each path: Escape after clicking dead panel space, Escape with focus forced to <body>, Escape with a tooltip visible, and Escape with the media picker stacked on top of settings (closes the picker only, then settings on the second press).

Checklist

  • Tests cover behavior changes.
  • Docs were updated when behavior, config, deployment, or public surfaces changed — docs/reference/ui-primitives.md, both the Dialog and Tooltip sections.
  • No compatibility shim was added for old pre-release behavior.
  • No secrets, local databases, uploads, or generated artifacts are included.

Escape silently stopped closing surfaces in two independent ways.

SettingsModal bound Escape as a React `onKeyDown` on the dialog element,
so it only fired while focus sat inside the dialog subtree. Clicking any
non-focusable spot in the panel (a label, the section header, empty space)
blurs to `<body>` — outside that subtree — after which Escape was dead for
the rest of the session while backdrop click kept working. It was the only
modal built this way; Dialog, ModuleInserterDialog, MediaPickerModal,
StepUpDialog and ConfirmDeleteDialog all bind on document/window. Moved it
onto a document listener to match, gated so only the topmost dialog reacts
(a nested media picker must not collapse the whole stack), and added
tabIndex={-1} to the panel so dead-space clicks keep focus inside and the
Tab trap holds.

Tooltip's window capture-phase handler called preventDefault() +
stopPropagation() on Escape and only hid the tooltip. Tooltips open on
plain hover, so resting the pointer on any trigger swallowed Escape
app-wide — no modal, context menu or spotlight could close. It now hides
the tooltip and lets the key through.

Also fixes the focus-return branch in SettingsModal, which was dead: all
three layouts unmount the modal on close, so `open` never transitioned to
false while mounted and focus was never returned to the trigger (WCAG
2.4.3 / Guideline CoreBunch#225). Restoring in the effect cleanup makes it run.

The gate tests asserted the broken behaviour — one required Tooltip to
consume Escape, three matched the dead triggerRef branch as source
strings. Replaced with behavioural coverage for focus-independent close,
stacked-dialog layering and focus return on unmount.

Verified: bun test (6282 pass), bun run build, bun run lint, plus a live
repro in the running dev admin before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se93H46Eck37iJBJwjmWV2
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