Adds a keyboard focus trap to the shared useOverlayBehavior hook in web/src/components/overlay.tsx, so Tab / Shift+Tab cycle only among the focusable elements inside the open panel and can never reach controls on the background screen. Because both Dialog and SlideOver (and therefore ConfirmDialog) share this hook, the fix closes the a11y gap for every SPA dialog at once.
How
Folded the Tab handling into the existing document keydown effect that already handles Escape (keyed on open), matching the file's idiom; deps become [open, panelRef].
Focusable set is recomputed on each Tab via panel.querySelectorAll with the standard selector, so dynamic dialog contents are handled; the panel's own tabIndex={-1} excludes it from the stops.
Boundary-only interception: Shift+Tab at the first stop (or focus off the stops) → last; Tab at the last stop (or off the stops) → first; middle moves left to the browser. Focus sitting outside the panel is redirected back in (index === -1 case). Empty-focusable edge refocuses the panel to avoid a dead Tab.
No new dependency.
Test
Extended web/src/components/overlay.test.tsx (dependency-free fireEvent, matching existing tests) with a trap test rendering <Dialog open> (two body buttons + Close) alongside an outside button: two forward cycles stay inside and never hit the outside control, forward wrap last→first, Shift+Tab first→last, and focus parked outside is redirected back in. Given a per-test { timeout: 15000 } per the heavy-render web-test convention.
Verification
npm run typecheck clean; npm run build OK; eslint clean on touched files.
Overlay suite: 13 passed. Full web suite: 922 passed (102 files).
Pure-SPA leaf, no server-state effect → independent cross-model review skipped per the review rubric; author cold-review done.
Docs
No doc update required: no route/endpoint/convention change (shared component internals only).
Fixes #248.
## What
Adds a keyboard focus trap to the shared `useOverlayBehavior` hook in `web/src/components/overlay.tsx`, so Tab / Shift+Tab cycle only among the focusable elements inside the open panel and can never reach controls on the background screen. Because both `Dialog` and `SlideOver` (and therefore `ConfirmDialog`) share this hook, the fix closes the a11y gap for **every** SPA dialog at once.
## How
- Folded the Tab handling into the existing document `keydown` effect that already handles Escape (keyed on `open`), matching the file's idiom; deps become `[open, panelRef]`.
- Focusable set is recomputed on each Tab via `panel.querySelectorAll` with the standard selector, so dynamic dialog contents are handled; the panel's own `tabIndex={-1}` excludes it from the stops.
- Boundary-only interception: Shift+Tab at the first stop (or focus off the stops) → last; Tab at the last stop (or off the stops) → first; middle moves left to the browser. Focus sitting outside the panel is redirected back in (`index === -1` case). Empty-focusable edge refocuses the panel to avoid a dead Tab.
- No new dependency.
## Test
Extended `web/src/components/overlay.test.tsx` (dependency-free `fireEvent`, matching existing tests) with a trap test rendering `<Dialog open>` (two body buttons + Close) alongside an outside button: two forward cycles stay inside and never hit the outside control, forward wrap last→first, Shift+Tab first→last, and focus parked outside is redirected back in. Given a per-test `{ timeout: 15000 }` per the heavy-render web-test convention.
## Verification
- `npm run typecheck` clean; `npm run build` OK; eslint clean on touched files.
- Overlay suite: 13 passed. Full web suite: **922 passed (102 files)**.
- Pure-SPA leaf, no server-state effect → independent cross-model review skipped per the review rubric; author cold-review done.
## Docs
No doc update required: no route/endpoint/convention change (shared component internals only).
focus trap added to shared useOverlayBehavior (covers Dialog + SlideOver);
Tab/Shift+Tab now cycle within the panel; test added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold author review of the focus-trap diff (pure-SPA leaf, shared useOverlayBehavior internals only — cross-model review skipped per the independent-review rubric). Boundary interception is correct (Tab last→first / off-stops→first; Shift+Tab first→last / off-stops→last; middle left to the browser; empty-focusable refocuses panel to avoid a dead Tab). Escape + scroll-lock behavior preserved; deps [open, panelRef] correct. Full web suite green (922). Noted minor out-of-scope edge: stacked modals each attach a document keydown listener — not a regression, not exercised by current UI.
Cold author review of the focus-trap diff (pure-SPA leaf, shared `useOverlayBehavior` internals only — cross-model review skipped per the independent-review rubric). Boundary interception is correct (Tab last→first / off-stops→first; Shift+Tab first→last / off-stops→last; middle left to the browser; empty-focusable refocuses panel to avoid a dead Tab). Escape + scroll-lock behavior preserved; deps `[open, panelRef]` correct. Full web suite green (922). Noted minor out-of-scope edge: stacked modals each attach a document keydown listener — not a regression, not exercised by current UI.
Review-verdict: MERGEABLE @ ae4408c6
timothy
merged commit 5c73c7a4bd into main2026-07-22 19:30:15 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #248.
What
Adds a keyboard focus trap to the shared
useOverlayBehaviorhook inweb/src/components/overlay.tsx, so Tab / Shift+Tab cycle only among the focusable elements inside the open panel and can never reach controls on the background screen. Because bothDialogandSlideOver(and thereforeConfirmDialog) share this hook, the fix closes the a11y gap for every SPA dialog at once.How
keydowneffect that already handles Escape (keyed onopen), matching the file's idiom; deps become[open, panelRef].panel.querySelectorAllwith the standard selector, so dynamic dialog contents are handled; the panel's owntabIndex={-1}excludes it from the stops.index === -1case). Empty-focusable edge refocuses the panel to avoid a dead Tab.Test
Extended
web/src/components/overlay.test.tsx(dependency-freefireEvent, matching existing tests) with a trap test rendering<Dialog open>(two body buttons + Close) alongside an outside button: two forward cycles stay inside and never hit the outside control, forward wrap last→first, Shift+Tab first→last, and focus parked outside is redirected back in. Given a per-test{ timeout: 15000 }per the heavy-render web-test convention.Verification
npm run typecheckclean;npm run buildOK; eslint clean on touched files.Docs
No doc update required: no route/endpoint/convention change (shared component internals only).
Cold author review of the focus-trap diff (pure-SPA leaf, shared
useOverlayBehaviorinternals only — cross-model review skipped per the independent-review rubric). Boundary interception is correct (Tab last→first / off-stops→first; Shift+Tab first→last / off-stops→last; middle left to the browser; empty-focusable refocuses panel to avoid a dead Tab). Escape + scroll-lock behavior preserved; deps[open, panelRef]correct. Full web suite green (922). Noted minor out-of-scope edge: stacked modals each attach a document keydown listener — not a regression, not exercised by current UI.Review-verdict: MERGEABLE @
ae4408c6