Skip to content

fix(web): keep Tab trapped inside open modals (useModalA11y) - #91

Merged
falbany merged 3 commits into
mainfrom
fix/modal-focus-trap-tab
Nov 3, 2026
Merged

falbany merged 3 commits into
mainfrom
fix/modal-focus-trap-tab

Conversation

@falbany

@falbany falbany commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Bug

With a workflow modal open (any useModalA11y modal), pressing Tab could move focus to the app behind the modal — file list, scan/launch controls — where a stray Enter/Space would then act on files mid-workflow.

Root cause

The Tab half of the focus trap lived in the handler attached to the dialog element's onKeyDown. That handler only sees key events that bubble through the dialog. When focus sat on document.body — after a backdrop click, or once a focused node unmounted (re-render churn) — a Tab keydown targeted the body, never reached the dialog handler, and the browser's native traversal moved focus to the first tabbable element in document order: the app behind the modal. From there it never came back.

Fix

The trap now owns every Tab press through a window-level capture listener installed while the modal is open (useModalA11y, the shared hook — all six modals inherit the fix):

  • Focus outside the dialog → preventDefault, pull focus back to the first (or last, with Shift) control inside.
  • Tab from the last focusable → wraps to the first; Shift+Tab from the first → wraps to the last. Also covers focus resting on the dialog root itself.
  • Focus mid-dialog → untouched; traversal between the dialog's own controls stays native (inputs, selects, roving-tabindex rows all keep working).
  • One listener per open modal; for stacked dialogs the most recently opened modal's handler runs last and wins — nesting resolves in favor of the topmost modal.

The returned onKeyDown handler keeps only Escape; its Tab branch was now-redundant duplicate logic and is removed. No consumer changes needed.

Verification

The Tab half of the focus trap lived in the dialog-level onKeyDown,
which only sees keys that bubble through the dialog element. When
focus sat on document.body — after a backdrop click, or once a focused
node unmounted — a Tab keydown never reached it and the browser moved
focus into the app behind the modal, where Enter/Space could then
activate scan/launch controls mid-workflow.

The trap now owns every Tab press via a window-level capture listener
installed while the modal is open: focus that drifted out is pulled
back to the first (or last, with Shift) control inside the dialog,
edges wrap in both directions, and traversal between a dialog's own
controls stays native. One listener per open modal; for stacked
dialogs the most recently opened one runs last and wins, resolving
nesting in favor of the topmost modal.

The returned onKeyDown handler keeps only Escape; its Tab branch is
now redundant and removed. All six modals using the hook (workflow
runner, shortcuts, barcode, camera, confirm, timer) inherit the fix.
New useModalA11y.test.tsx covers pull-back from body, both wrap
edges, repeated Tabs, and the unchanged Escape routing.
Buttons not carrying the .btn class (workflow step rows, context-menu
items, header icon buttons, ...) had no focus indicator at all — with
the Tab trap now keeping keyboard focus inside modals, those elements
were focused invisibly.

One :where() baseline rule in index.css gives every interactive
element a visible 2px indigo outline on :focus-visible (WCAG 2.4.7).
Zero specificity by design: elements that handle focus locally via
focus-visible:ring utilities keep their ring and suppress the outline,
so existing visuals are untouched and the rule only fills the gaps.
@falbany

falbany commented Nov 2, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit 8e52d09 (same keyboard-navigation concern): while testing the trap, buttons that don't carry the .btn class — workflow step rows, context-menu items, header icon buttons — turned out to have no focus indicator at all, so keyboard focus landed on them invisibly.

Added a zero-specificity baseline rule in index.css: every interactive element (button, inputs, links, [tabindex], …) shows a 2px indigo outline on :focus-visible (WCAG 2.4.7). Elements that already handle focus locally via focus-visible:ring utilities keep their ring and suppress the outline — the rule only fills the gaps, so no existing visuals change.

@falbany
falbany merged commit 5cb153d into main Nov 3, 2026
1 check passed
@falbany
falbany deleted the fix/modal-focus-trap-tab branch November 4, 2026 23:29
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