Skip to content

fix(#439): make anchored-dialog Tab navigation deterministic on Safari/WebKit - #442

Merged
BorisTyshkevich merged 2 commits into
mainfrom
fix/anchored-dialog-tab-order-439
Jul 25, 2026
Merged

fix(#439): make anchored-dialog Tab navigation deterministic on Safari/WebKit#442
BorisTyshkevich merged 2 commits into
mainfrom
fix/anchored-dialog-tab-order-439

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What & why

openAnchoredDialog's shared focus trap (src/ui/popover.ts) only handled wrapping at the first/last declared focusable element and delegated every middle-of-list Tab press to native browser sequential-focus. WebKit/Safari's default "Tab highlights every item on a webpage" preference is off, so native traversal there could skip checkboxes and buttons — in the multi-select and time-range popovers this meant keyboard focus could never reach Clear/Cancel/Apply, and the modal trap could let focus escape rather than wrapping.

The trap now owns every Tab/Shift+Tab transition: the freshly recomputed eligible set's current index (or -1 if focus is outside the set) plus modular arithmetic decides the next stop, in both directions, with wrap. Disabled/hidden/removed elements drop out immediately; restored ones rejoin without reopening the dialog.

Follow-up finding from review: disabling the currently-focused control natively blurs it out of the dialog entirely (to <body>), past the trap's dialog-scoped reach — a plain Tab press right after never re-enters the modal. The multi-select's busy state (a Filter source loading while the popover stays open) disables every row but Cancel, so a focused Search/option/Clear/Apply could escape undetected; the new unit tests couldn't catch this because they dispatch synthetic keydowns directly on the dialog, bypassing the real event-path gap. Added handle.reclaimFocus() to the shared primitive — recomputes the eligible set and, if the active element is no longer in it, moves focus to the first eligible element — and multi-select now calls it the moment busy state lands. Verified the regression reproduces (and is fixed) with a real Playwright test across Chromium, Firefox, and WebKit (confirmed by temporarily reverting the fix and watching the new test fail in all three engines, then re-applying it).

Closes #439.

Checklist

  • npm test passes (the per-file coverage gate is non-negotiable)
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency
  • README / CHANGELOG.md ([Unreleased]) updated
  • Reconciled affected tracked work — not tracked in roadmap Roadmap to 1.0.0 #68's checklist; no ADR touched

Verification

npm test                                                          # 5263 passed
npx tsc --noEmit                                                  # clean
npm run build                                                     # dist/sql.html built
npx playwright test tests/e2e/multi-select.spec.js --project=chromium  # 4 passed
npx playwright test tests/e2e/multi-select.spec.js --project=firefox   # 4 passed
npx playwright test tests/e2e/multi-select.spec.js --project=webkit    # 4 passed (previously 2 failing here)
npm run test:e2e                                                  # 309 passed, all 3 engines

🤖 Generated with Claude Code

https://claude.ai/code/session_01KyBqpFXWKxkVrScz3N1E3v

BorisTyshkevich and others added 2 commits July 25, 2026 18:26
…i/WebKit

openAnchoredDialog's focus trap only wrapped at the first/last declared
focusable element and delegated every middle-of-list Tab to native browser
traversal. WebKit/Safari's default "Tab highlights every item" preference is
off, so that native traversal skipped checkboxes/buttons in the multi-select
and time-range popovers, stranding keyboard users before Clear/Cancel/Apply.
The trap now owns every Tab/Shift+Tab transition via modular index arithmetic
over the freshly recomputed eligible set, verified end-to-end in Chromium,
Firefox, and WebKit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyBqpFXWKxkVrScz3N1E3v
Review finding: disabling the currently-focused control natively blurs it
out of the dialog entirely (to <body>), past the shared Tab trap's
dialog-scoped reach — a plain Tab press right after never re-enters the
modal. The multi-select's busy state (a Filter source loading while the
popover stays open) disables every row but Cancel, so a focused Search/
option/Clear/Apply could escape.

Adds `handle.reclaimFocus()` to openAnchoredDialog: recomputes the eligible
set and, if the active element is no longer in it, moves focus to the first
eligible element. Multi-select calls it the moment busy state lands.
Verified the regression reproduces (and is fixed) in Chromium, Firefox, and
WebKit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyBqpFXWKxkVrScz3N1E3v
@BorisTyshkevich
BorisTyshkevich merged commit 9eaab8a into main Jul 25, 2026
7 checks passed
@BorisTyshkevich
BorisTyshkevich deleted the fix/anchored-dialog-tab-order-439 branch August 6, 2026 15:28
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.

Make anchored-dialog Tab navigation deterministic on Safari/WebKit

1 participant