You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The shared anchored-dialog primitive does not fully own keyboard traversal. It only handles wrapping at the first and last declared focusable elements and delegates every intermediate Tab step to the browser's native sequential-focus policy.
On WebKit/Safari with the default macOS setting where Tab does not highlight every webpage control, native traversal skips checkboxes and buttons. In the multi-select popover this means keyboard focus never reaches Clear, Cancel, or Apply; it can also leave the modal dialog before the trap sees focus at its declared last element.
The existing WebKit E2E failures are therefore not merely a test-environment preference problem. They expose a product accessibility defect in the shared modal-popover focus trap used by both:
src/ui/multi-select-field.ts;
src/ui/time-range-field.ts.
Fix the shared primitive so its declared focus order is deterministic across Chromium, Firefox, and WebKit without requiring a host OS/browser preference.
Observed failure
Current tests/e2e/multi-select.spec.js opens the multi-select dialog, presses Tab until the Apply button should be focused, and then verifies its focus-visible outline in both themes.
WebKit fails both theme cases:
[webkit] › tests/e2e/multi-select.spec.js
› keeps enabled, hover, disabled, focus, and pressed Apply states distinct in dark theme
› keeps enabled, hover, disabled, focus, and pressed Apply states distinct in light theme
Error: expected outline not to be "none"
Chromium and Firefox reach Apply and pass. WebKit does not focus Apply, so the focus-visible style is never active.
This reproduces against untouched current main; it is not caused by an open feature branch.
Current implementation
openAnchoredDialog() in src/ui/popover.ts recomputes its focusable set on every Tab press:
It intervenes only at the boundaries. From any middle element it intentionally does nothing and assumes the browser will focus the next member of focusableEls().
The unit suite encodes that assumption explicitly:
it('Tab from a middle element does not trap (browser default order applies)', ...)
That assumption is not portable. WebKit's native sequential-focus policy can skip controls that the primitive itself considers focusable.
Concrete multi-select sequence
When the multi-select popover opens, initialFocus places focus on its search input. The primitive's declared order is approximately:
On affected WebKit/Safari configurations, native Tab traversal does not visit all of those controls. Because Apply is never active:
the E2E focus-state assertion fails;
Apply is not keyboard-reachable with ordinary Tab;
the modal trap can allow focus to escape rather than wrapping from Apply to Search;
the product behavior differs across supported desktop engines.
The time-range popover has the same structural risk: text inputs, caret/buttons, dynamically rendered recent/constant buttons, Cancel, and Apply all share this primitive.
Required behavior
While an anchored dialog is open:
ordinary Tab moves to the next currently eligible element in the primitive's declared DOM order;
Shift+Tab moves to the previous currently eligible element;
traversal wraps within the dialog in both directions;
disabled elements are skipped;
elements inside a [hidden] ancestor are skipped;
the eligible set is recomputed on every key press because consumers mutate visibility and disabled state while open;
if focus is outside the dialog or on a node no longer present in the eligible set:
Tab moves to the first eligible element;
Shift+Tab moves to the last eligible element;
a one-element dialog keeps focus on that element;
an empty eligible set preserves the current no-op behavior and lets the browser handle the key;
keyboard-driven movement retains a visible :focus-visible indication;
Equivalent code is acceptable, but the behavioral contract above is fixed.
Important constraints
Keep recomputing the eligible list for every press.
Keep the current dialog-scoped listener; do not move a global Tab listener onto document.
Do not call stopPropagation() unless an existing event contract demonstrably requires it; preventing native traversal is sufficient.
Do not mutate consumer elements with positive tabindex values.
Do not add permanent tab stops.
Do not reorder the DOM to influence browser traversal.
Do not duplicate focus-trap logic in either consumer.
Keep the current input, button scope unless a concrete existing consumer requires another native element. Broadening the primitive to arbitrary links, selects, textareas, or [tabindex] is outside this bug unless necessary for current rendered content.
Preserve the current disabled and hidden filtering semantics exactly.
Focus-visible requirement
The existing CSS focus treatment is not the root cause. The style is absent in the failing test because Apply never becomes focused.
The shared keydown handler must move focus synchronously as a result of the keyboard event so the target is treated as keyboard-focused and visibly matches :focus-visible in supported engines.
Do not solve this by:
changing the product rule from :focus-visible to unconditional :focus;
adding a permanent outline to Apply;
directly focusing Apply only inside the test;
asserting a CSS declaration without proving the button became the active element.
If a supported engine does not apply :focus-visible to focus moved synchronously from the Tab key handler, document that with a minimal reproduction and implement the smallest shared keyboard-modality treatment in the primitive. Do not add a multi-select-only workaround.
Unit tests
Update tests/unit/popover.test.ts so the tests describe deterministic primitive-owned traversal rather than native browser delegation.
Required coverage:
Forward traversal
Tab from the first element focuses the second.
Tab from a middle element focuses the next element.
Tab from the last element wraps to the first.
The event is preventDefault()-ed for every non-empty traversal.
Backward traversal
Shift+Tab from the last element focuses the previous element.
Shift+Tab from a middle element focuses the previous element.
Shift+Tab from the first element wraps to the last.
Dynamic eligibility
A disabled middle or last element is skipped immediately on the next press.
An element inside a hidden ancestor is skipped.
Re-enabling or unhiding an element makes it eligible again without reopening the dialog.
A focused element that is removed or disabled before the next key press is treated as no longer in the set:
Tab chooses the first;
Shift+Tab chooses the last.
Edge cases
One eligible element retains focus in both directions.
No eligible elements leave the event unhandled, preserving the current empty-set guard.
Non-Tab keys do not move focus or become prevented.
The listener remains scoped to the live dialog and is removed on close.
Replace the current test asserting that middle-element Tab is left to browser default; that behavior is the defect.
Playwright regression coverage
Update tests/e2e/multi-select.spec.js to distinguish focus traversal from visual-state styling.
Keyboard sequence test
In at least one theme, assert the real consumer sequence:
open the multi-select dialog;
verify initial focus is the Search input;
press Tab through the checkbox and footer controls;
verify each expected control becomes document.activeElement in DOM order;
verify Apply is reached using ordinary Tab;
verify Apply matches :focus-visible and its computed outline is not none;
press Tab once more and verify focus wraps to Search;
press Shift+Tab and verify focus wraps back to Apply;
verify focus never leaves the dialog during the sequence.
The test must pass in Chromium, Firefox, and WebKit. Do not browser-gate the core traversal assertions.
Existing visual-state test
Keep coverage that enabled, hover, disabled, focus-visible, and pressed states remain distinct in both dark and light themes. Strengthen its focus step to assert both:
A bounded loop may remain only if it fails with a clear assertion that Apply was never reached. Prefer an exact, readable sequence based on the fixture's stable controls.
Shared-consumer regression
Add or extend coverage for the time-range consumer where existing E2E infrastructure makes this straightforward:
open the time-range dialog;
traverse from its initial input through at least one dynamically rendered button and the footer actions;
verify focus stays inside and reaches Apply;
change the dynamic right-column content and verify the next Tab uses the newly rendered eligible set.
A direct shared-primitive browser fixture is acceptable instead if it is smaller and still proves dynamic hidden/disabled recomputation in WebKit. Do not create duplicate consumer logic solely for the test.
Test-environment and CI requirements
The repository supports current desktop Chromium, Firefox, and Safari/WebKit. playwright.config.js intentionally runs all three projects, and the scheduled/manual/release E2E job installs all three engines.
The resolution must work with the current dependency lock and existing CI model.
Do not:
skip or mark the WebKit cases expected-to-fail;
add retries to hide the deterministic failure;
condition assertions on browserName !== 'webkit';
require developers or CI to enable a macOS Safari preference;
pin or downgrade Playwright/WebKit to an older build;
use Alt+Tab/Option+Tab as the only WebKit path;
remove Apply from the keyboard-order test;
convert the E2E check into a unit-only CSS assertion.
The exact WebKit version where the failure was first observed is diagnostic context, not the contract. The product must not depend on one engine build's native sequential-focus preference.
Regression boundaries
The change must not alter:
initial focus selected by each consumer;
dialog role, aria-modal, or accessible name;
aria-expanded lifecycle on the trigger;
Escape handling;
backdrop dismissal;
focus return to the trigger on ordinary close;
skipFocus behavior;
close-before-commit ordering;
keyboard-owner registration and cleanup;
dynamic busy-state disabling in the multi-select;
time-range validation or dynamic recent/constant rendering;
mouse and pointer activation;
visual enabled, hover, disabled, active, or focus styles;
focus behavior outside anchored dialogs.
Acceptance criteria
openAnchoredDialog deterministically handles every Tab and Shift+Tab transition for its current focusable set.
Buttons and checkboxes remain reachable in WebKit without changing a system/browser preference.
Focus cannot escape either anchored modal dialog through ordinary forward or backward Tab traversal.
Hidden, disabled, removed, and newly restored controls are handled from a freshly recomputed set.
Multi-select Apply is reached by ordinary Tab in Chromium, Firefox, and WebKit.
Apply visibly matches :focus-visible when reached by keyboard.
The dark- and light-theme action-state assertions remain green.
The time-range popover retains correct keyboard traversal with dynamic content.
Existing Escape, backdrop, focus-return, lifecycle, and commit-order tests remain green.
No browser-specific skip, direct-test focus shortcut, OS preference, retry, or version pin is introduced.
The inbox label is removed and the issue remains classified as bug + accessibility.
Verification
The implementation PR must report results for:
npm test
npx tsc --noEmit
npm run build
npx playwright test tests/e2e/multi-select.spec.js --project=chromium
npx playwright test tests/e2e/multi-select.spec.js --project=firefox
npx playwright test tests/e2e/multi-select.spec.js --project=webkit
npm run test:e2e
If a dedicated shared-popover or time-range E2E spec is added, include it explicitly in the focused commands as well.
Summary
The shared anchored-dialog primitive does not fully own keyboard traversal. It only handles wrapping at the first and last declared focusable elements and delegates every intermediate
Tabstep to the browser's native sequential-focus policy.On WebKit/Safari with the default macOS setting where Tab does not highlight every webpage control, native traversal skips checkboxes and buttons. In the multi-select popover this means keyboard focus never reaches Clear, Cancel, or Apply; it can also leave the modal dialog before the trap sees focus at its declared last element.
The existing WebKit E2E failures are therefore not merely a test-environment preference problem. They expose a product accessibility defect in the shared modal-popover focus trap used by both:
src/ui/multi-select-field.ts;src/ui/time-range-field.ts.Fix the shared primitive so its declared focus order is deterministic across Chromium, Firefox, and WebKit without requiring a host OS/browser preference.
Observed failure
Current
tests/e2e/multi-select.spec.jsopens the multi-select dialog, presses Tab until the Apply button should be focused, and then verifies its focus-visible outline in both themes.WebKit fails both theme cases:
Chromium and Firefox reach Apply and pass. WebKit does not focus Apply, so the focus-visible style is never active.
This reproduces against untouched current
main; it is not caused by an open feature branch.Current implementation
openAnchoredDialog()insrc/ui/popover.tsrecomputes its focusable set on every Tab press:That dynamic filtering is correct and must remain.
The problem is the traversal logic:
It intervenes only at the boundaries. From any middle element it intentionally does nothing and assumes the browser will focus the next member of
focusableEls().The unit suite encodes that assumption explicitly:
That assumption is not portable. WebKit's native sequential-focus policy can skip controls that the primitive itself considers focusable.
Concrete multi-select sequence
When the multi-select popover opens,
initialFocusplaces focus on its search input. The primitive's declared order is approximately:On affected WebKit/Safari configurations, native Tab traversal does not visit all of those controls. Because Apply is never active:
The time-range popover has the same structural risk: text inputs, caret/buttons, dynamically rendered recent/constant buttons, Cancel, and Apply all share this primitive.
Required behavior
While an anchored dialog is open:
[hidden]ancestor are skipped;:focus-visibleindication;No system preference, browser launch flag, or manual developer setup may be required beyond the existing Playwright browser installation.
Required implementation
Change the shared
onTabTrapimplementation so it owns every Tab transition rather than only boundary wrapping.An acceptable implementation is:
Equivalent code is acceptable, but the behavioral contract above is fixed.
Important constraints
document.stopPropagation()unless an existing event contract demonstrably requires it; preventing native traversal is sufficient.tabindexvalues.input, buttonscope unless a concrete existing consumer requires another native element. Broadening the primitive to arbitrary links, selects, textareas, or[tabindex]is outside this bug unless necessary for current rendered content.Focus-visible requirement
The existing CSS focus treatment is not the root cause. The style is absent in the failing test because Apply never becomes focused.
The shared keydown handler must move focus synchronously as a result of the keyboard event so the target is treated as keyboard-focused and visibly matches
:focus-visiblein supported engines.Do not solve this by:
:focus-visibleto unconditional:focus;If a supported engine does not apply
:focus-visibleto focus moved synchronously from the Tab key handler, document that with a minimal reproduction and implement the smallest shared keyboard-modality treatment in the primitive. Do not add a multi-select-only workaround.Unit tests
Update
tests/unit/popover.test.tsso the tests describe deterministic primitive-owned traversal rather than native browser delegation.Required coverage:
Forward traversal
preventDefault()-ed for every non-empty traversal.Backward traversal
Dynamic eligibility
Edge cases
Replace the current test asserting that middle-element Tab is left to browser default; that behavior is the defect.
Playwright regression coverage
Update
tests/e2e/multi-select.spec.jsto distinguish focus traversal from visual-state styling.Keyboard sequence test
In at least one theme, assert the real consumer sequence:
document.activeElementin DOM order;:focus-visibleand its computed outline is notnone;The test must pass in Chromium, Firefox, and WebKit. Do not browser-gate the core traversal assertions.
Existing visual-state test
Keep coverage that enabled, hover, disabled, focus-visible, and pressed states remain distinct in both dark and light themes. Strengthen its focus step to assert both:
before asserting the computed outline.
A bounded loop may remain only if it fails with a clear assertion that Apply was never reached. Prefer an exact, readable sequence based on the fixture's stable controls.
Shared-consumer regression
Add or extend coverage for the time-range consumer where existing E2E infrastructure makes this straightforward:
A direct shared-primitive browser fixture is acceptable instead if it is smaller and still proves dynamic hidden/disabled recomputation in WebKit. Do not create duplicate consumer logic solely for the test.
Test-environment and CI requirements
The repository supports current desktop Chromium, Firefox, and Safari/WebKit.
playwright.config.jsintentionally runs all three projects, and the scheduled/manual/release E2E job installs all three engines.The resolution must work with the current dependency lock and existing CI model.
Do not:
browserName !== 'webkit';Alt+Tab/Option+Tabas the only WebKit path;The exact WebKit version where the failure was first observed is diagnostic context, not the contract. The product must not depend on one engine build's native sequential-focus preference.
Regression boundaries
The change must not alter:
aria-modal, or accessible name;aria-expandedlifecycle on the trigger;skipFocusbehavior;Acceptance criteria
openAnchoredDialogdeterministically handles every Tab and Shift+Tab transition for its current focusable set.:focus-visiblewhen reached by keyboard.inboxlabel is removed and the issue remains classified asbug+accessibility.Verification
The implementation PR must report results for:
If a dedicated shared-popover or time-range E2E spec is added, include it explicitly in the focused commands as well.
Related work
src/ui/popover.tsis the single shared lifecycle and focus-trap implementation; fix the behavior there rather than in either consumer.