A11Y: shared focus trap accepts hidden controls and misses phone Settings initial focus #740

Open
opened 2026-10-02 13:08:05 +00:00 by kayg · 6 comments
Owner

Source audit from rev-a11y (#427), base c4a61e8cf090170f35b1bed3350d9de20c83ecd5. No product changes or production build were made.

A1 — Shared focus containment accepts hidden controls

Priority: P1. Family: focus traps and Settings surfaces. Tracking: this issue.
Criteria: 2.4.3 Focus Order, 2.1.1 Keyboard.

Evidence:

  • apps/web/src/lib/a11y/focusTrap.ts:82: tabbables() checks the control's
    own hidden flag and an inert ancestor. It does not check a hidden
    ancestor, CSS visibility, or hidden input types.
  • apps/web/src/lib/a11y/focusTrap.ts:164: moveFocusIn() accepts an
    explicit target without a visibility check. A failed focus has no fallback.
  • apps/web/src/routes/settings/[...path]/+page.svelte:304: initial focus
    can select .settings-shell .shell-back.
  • apps/web/src/routes/settings/[...path]/+page.svelte:523: the phone
    layout hides the content's .settings-detail-nav, which contains that
    Back button. The visible Back button is in the shared sheet chrome,
    outside .settings-shell.

Effect: a phone Settings detail link can request focus on a hidden Back
button. Hidden candidates can also prevent Tab wrapping at the visible end
of a surface.

Fix: use one visibility-aware focus candidate predicate. Apply it to
initial focus and containment. Check hidden/inert ancestors and hidden
input types. Resolve CSS visibility only during focus operations, with no
per-frame observers. If the requested target cannot take focus, use the
first visible control or the surface. Select the visible sheet Back button
for phone Settings.

Test: open a Settings detail link with a keyboard at 390 px. Check that
focus is inside the visible dialog. Add hidden first/last candidates to the
focus-trap tests. Check Tab and Shift+Tab wrapping and Escape restoration.

Duplicate check: searched all-state Forgejo issues for focus trap and accessibility. #162 is a closed test-sweep issue; #132 concerns the server configuration editor, not this focus candidate defect.

Use the existing component and tests. Check phone (390 px), tablet (820 px) and desktop (1440 px), light and dark, with macOS shortcut rendering. Keep keyboard and pointer motion durations equal; retain reduced-motion support.

Source audit from rev-a11y (#427), base `c4a61e8cf090170f35b1bed3350d9de20c83ecd5`. No product changes or production build were made. ### A1 — Shared focus containment accepts hidden controls Priority: P1. Family: focus traps and Settings surfaces. Tracking: this issue. Criteria: 2.4.3 Focus Order, 2.1.1 Keyboard. Evidence: - `apps/web/src/lib/a11y/focusTrap.ts:82`: `tabbables()` checks the control's own `hidden` flag and an inert ancestor. It does not check a hidden ancestor, CSS visibility, or hidden input types. - `apps/web/src/lib/a11y/focusTrap.ts:164`: `moveFocusIn()` accepts an explicit target without a visibility check. A failed focus has no fallback. - `apps/web/src/routes/settings/[...path]/+page.svelte:304`: initial focus can select `.settings-shell .shell-back`. - `apps/web/src/routes/settings/[...path]/+page.svelte:523`: the phone layout hides the content's `.settings-detail-nav`, which contains that Back button. The visible Back button is in the shared sheet chrome, outside `.settings-shell`. Effect: a phone Settings detail link can request focus on a hidden Back button. Hidden candidates can also prevent Tab wrapping at the visible end of a surface. Fix: use one visibility-aware focus candidate predicate. Apply it to initial focus and containment. Check hidden/inert ancestors and hidden input types. Resolve CSS visibility only during focus operations, with no per-frame observers. If the requested target cannot take focus, use the first visible control or the surface. Select the visible sheet Back button for phone Settings. Test: open a Settings detail link with a keyboard at 390 px. Check that focus is inside the visible dialog. Add hidden first/last candidates to the focus-trap tests. Check Tab and Shift+Tab wrapping and Escape restoration. Duplicate check: searched all-state Forgejo issues for focus trap and accessibility. #162 is a closed test-sweep issue; #132 concerns the server configuration editor, not this focus candidate defect. Use the existing component and tests. Check phone (390 px), tablet (820 px) and desktop (1440 px), light and dark, with macOS shortcut rendering. Keep keyboard and pointer motion durations equal; retain reduced-motion support.
Author
Owner

Additional source-audit evidence from job rev-a11y. A 5.14 KB bundle uses the actual apps/web/src/lib/a11y/focusTrap.ts and its two local dependencies. A single Chromium browser at 390 px runs a behavior fixture, with macOS platform emulation. It is not a standalone visual mockup or a production screenshot.

Output (exit 0):

REPRODUCED #740: hidden explicit initial target leaves focus outside the trap.
REPRODUCED #740: a hidden last candidate lets Tab leave the trap.

The fixture's hidden ancestor uses display:none. In the first case, a hidden requested target leaves focus on the outside opener. In the second case, a hidden last button prevents the wrap condition and native Tab reaches an outside button. Full Settings route rendering remains an acceptance test for the fix.

Additional source-audit evidence from job rev-a11y. A 5.14 KB bundle uses the actual `apps/web/src/lib/a11y/focusTrap.ts` and its two local dependencies. A single Chromium browser at 390 px runs a behavior fixture, with macOS platform emulation. It is not a standalone visual mockup or a production screenshot. Output (exit 0): ```text REPRODUCED #740: hidden explicit initial target leaves focus outside the trap. REPRODUCED #740: a hidden last candidate lets Tab leave the trap. ``` The fixture's hidden ancestor uses `display:none`. In the first case, a hidden requested target leaves focus on the outside opener. In the second case, a hidden last button prevents the wrap condition and native Tab reaches an outside button. Full Settings route rendering remains an acceptance test for the fix.
Author
Owner

Exact line-reference correction from the final report, at the same audited base c4a61e8cf090170f35b1bed3350d9de20c83ecd5: tabbables() starts at apps/web/src/lib/a11y/focusTrap.ts:87, moveFocusIn() at :142, and the Settings initial-focus selector at apps/web/src/routes/settings/[...path]/+page.svelte:294. The CSS hiding the duplicate Back group remains at :523. The earlier comment/body quoted nearby line numbers for the first three anchors. The finding and the two browser reproductions are unchanged.

Exact line-reference correction from the final report, at the same audited base `c4a61e8cf090170f35b1bed3350d9de20c83ecd5`: `tabbables()` starts at `apps/web/src/lib/a11y/focusTrap.ts:87`, `moveFocusIn()` at `:142`, and the Settings initial-focus selector at `apps/web/src/routes/settings/[...path]/+page.svelte:294`. The CSS hiding the duplicate Back group remains at `:523`. The earlier comment/body quoted nearby line numbers for the first three anchors. The finding and the two browser reproductions are unchanged.
Author
Owner

Additional source check: the Settings initial-focus selector can match the desktop Back button even when CSS hides it on phone; the visible Back button lives in the sheet chrome outside .settings-shell. The shared focus trap also misses hidden ancestors and type=hidden, so I am testing those cases and a visible fallback.

Additional source check: the Settings initial-focus selector can match the desktop Back button even when CSS hides it on phone; the visible Back button lives in the sheet chrome outside `.settings-shell`. The shared focus trap also misses hidden ancestors and `type=hidden`, so I am testing those cases and a visible fallback.
Author
Owner

Started work on job/undo-a11y-a11y, based on 9281e63b0164fd0297d959f37eb0008bfa0aa4d3. Scope: issues #740, #741, #744, #745, #819 and #820. I am reproducing each reported behavior with a regression test before changing implementation.

Started work on `job/undo-a11y-a11y`, based on `9281e63b0164fd0297d959f37eb0008bfa0aa4d3`. Scope: issues #740, #741, #744, #745, #819 and #820. I am reproducing each reported behavior with a regression test before changing implementation.
Author
Owner

#740 reproduction before implementation: TMPDIR=/home/kayg/Developer/calternal-wt/undo-a11y-a11y/target/tmp bun run test -- src/lib/a11y/focusTrap.test.ts exited 1. Both new regressions failed: Tab from the last visible button was not prevented because hidden descendants remained candidates, and initialFocus left focus on a button inside a display:none ancestor instead of using the visible fallback. The phone Settings assertion is also added to the production E2E route check; I will run it after the web build is available.

#740 reproduction before implementation: `TMPDIR=/home/kayg/Developer/calternal-wt/undo-a11y-a11y/target/tmp bun run test -- src/lib/a11y/focusTrap.test.ts` exited 1. Both new regressions failed: Tab from the last visible button was not prevented because hidden descendants remained candidates, and `initialFocus` left focus on a button inside a `display:none` ancestor instead of using the visible fallback. The phone Settings assertion is also added to the production E2E route check; I will run it after the web build is available.
Author
Owner

Starting #740 on branch job/undo-a11y. Current head: 13e28c4a031e78590740e287443618c257852380; origin/dev base: c4a61e8cf090170f35b1bed3350d9de20c83ecd5. I will first finish the #758 saved-search Undo checkpoint specified in the job handoff, then work through this issue.

Starting #740 on branch `job/undo-a11y`. Current head: `13e28c4a031e78590740e287443618c257852380`; `origin/dev` base: `c4a61e8cf090170f35b1bed3350d9de20c83ecd5`. I will first finish the #758 saved-search Undo checkpoint specified in the job handoff, then work through this issue.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#740
No description provided.