Skip to content

R8a: Escape scoping and focus-trap hardening (findings #16, #17) - #173

Merged
dovvnloading merged 1 commit into
mainfrom
r8a/overlay-escape-focus-trap
Jul 29, 2026
Merged

R8a: Escape scoping and focus-trap hardening (findings #16, #17)#173
dovvnloading merged 1 commit into
mainfrom
r8a/overlay-escape-focus-trap

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

Two related overlay-system bugs from the R8a UI/UX audit:

  • Finding Refactor navigation pins and interaction surfaces #16 (Escape scoping): the document-level Escape-to-close listener ran in the capture phase and unconditionally called stopPropagation(), so it fired and closed the surface before any inner field's own Escape handler could run. Rename inputs, inline-edit textareas, and the Pins editor all had Escape silently discard their own local edit and close (or in PinOverlay's case, had no Escape handling at all — Escape closed the whole popover).
  • Finding Refactor navigation pins and interaction surfaces #17 (focus trap): the Tab-trap's FOCUSABLE selector counted disabled buttons as valid stops. When the true last focusable element was disabled (e.g. Approve/Deny both disabled while busy), the wrap-boundary check never matched the real last element, and Tab walked focus straight out of the panel.

Change

  • overlays.tsx: moved the Escape listener to the bubble phase and gated it on event.defaultPrevented, so an inner handler can claim Escape via preventDefault() — the standard DOM convention — without the outer surface closing underneath it.
  • Applied the same preventDefault() fix to NoteNodeView, GroupNodeView (inline label editors), and PinOverlay (pin title/note editor, which had no Escape handling before this).
  • overlays.tsx and CodeExecutionApprovalPanel.tsx: excluded [disabled] elements from the FOCUSABLE selector.
  • Added a Tab-gated focus-escape backstop as defense in depth (useFocusEscapeBackstop, shared between Dialog and CodeExecutionApprovalPanel): if focus lands outside the panel immediately after a Tab keydown, it's redirected back in. Gated on the preceding key being Tab so it can't fight a dialog's own legitimate close()-triggered focus restoration, and so two simultaneously-open panels don't redirect focus into each other indefinitely.

Two related-but-distinct issues were flagged during review and are intentionally out of scope here: CommandPalette claims a focus trap it doesn't actually implement, and NodeMenu's global capture-phase Escape handler has no target check and can swallow unrelated inline edits elsewhere on the canvas. Both should be tracked as separate follow-ups.

Test plan

Layer Result
npx tsc --noEmit clean
npx vitest run (full suite) 868/868 passed
python -m pytest -q (backend, unaffected) 1033/1033 passed
npm run lint / npm run build clean

New/updated coverage: overlays.test.tsx (Escape-claim scoping, disabled-button wrap, Tab-gated backstop, close()-restore race, mouse-click-after-Tab reset), NoteNodeView.test.tsx / GroupNodeView.test.tsx (Escape claims the event), ChatLibraryDialog.test.tsx (corrected a stale test that had codified the old buggy behavior as intended), PinOverlay.test.tsx (Escape cancels edit without closing the popover), CodeExecutionApprovalPanel.test.tsx (disabled-button Tab wrap, Tab-gated backstop).

Live-browser verification of PinOverlay was attempted but blocked by the preview pane's non-composited state, which prevents React Flow's viewport from initializing (a known environment limitation, unrelated to this change); the fix is covered by real-DOM, real-React-reconciliation unit tests instead.

Fixes the overlay system's Escape handling and Tab focus trap, plus two
related completeness gaps found in review.

- Escape (finding #16): the document-level Escape-to-close listener ran in
  the capture phase and unconditionally called stopPropagation(), so it
  fired and closed the surface before any inner field's own Escape handler
  (rename inputs, inline edit textareas) ever ran. Moved the listener to
  the bubble phase and gated it on event.defaultPrevented, so an inner
  handler can claim Escape by calling preventDefault() - the standard DOM
  convention - without the outer surface closing underneath it. Applied the
  same preventDefault() fix to NoteNodeView and GroupNodeView's inline-edit
  Escape handlers, and to PinOverlay's pin editor (which had no Escape
  handling at all, so Escape fell through and closed the whole Pins
  popover).

- Focus trap (finding #17): the FOCUSABLE selector counted disabled
  buttons as valid Tab stops, so a trap whose last element was disabled
  could never detect the Tab-wrap boundary and would leak focus out of the
  panel. Excluded [disabled] elements from the selector in both
  overlays.tsx and CodeExecutionApprovalPanel.tsx. Added a Tab-gated focus
  escape backstop (new shared useFocusEscapeBackstop hook) as defense in
  depth: on any focusin that lands outside the panel immediately after a
  Tab keydown, focus is redirected back in. Gated on the preceding key
  being Tab (not applied unconditionally) so it can't fight a dialog's own
  legitimate close()-triggered focus restoration, and so two simultaneously
  open panels (CodeExecutionApprovalPanel supports this) don't redirect
  focus back and forth into each other indefinitely.

CommandPalette's missing focus trap and NodeMenu's untargeted global
Escape handler are related but distinct issues, out of scope here, and
should be tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit c425176 into main Jul 29, 2026
2 checks passed
@dovvnloading
dovvnloading deleted the r8a/overlay-escape-focus-trap branch July 29, 2026 02:51
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