Skip to content

fix(web): wire active-row a11y on search listbox#403

Merged
intendednull merged 1 commit into
mainfrom
claude/issue-344-search-listbox-active
Apr 27, 2026
Merged

fix(web): wire active-row a11y on search listbox#403
intendednull merged 1 commit into
mainfrom
claude/issue-344-search-listbox-active

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Why

<ResultsList> pass every row selected=Signal::derive(|| false). No roving tabindex, no aria-activedescendant on input. Keyboard user not see which row Enter target. Screen reader never see selected option in listbox. Spec say listbox MUST mark active option — this regress a11y baseline. Issue tag medium severity, [GEN-01].

Fix

Track active_index: ReadSignal<usize> in SearchUiState. Default 0. Reset to 0 whenever result set or scope change (Effect in surface) — stale index never outlive its data.

Per-row selected derive from flat (in-display-order) index match: Signal::derive(move || active_index.get() == flat). flat = group_offset + intra_group_idx. Same flat_ordered() helper used by input so input + listbox agree on what "row N" mean.

Input keydown handle Up/Down/Home/End to move active_index. Up/Down wrap (listbox-pattern default; match command palette behavior). aria-activedescendant on input point at id="search-row-{message_id}" of active row, omitted (Option::None) when results empty per WAI-ARIA.

Enter still push to recents — not change in this PR. Enter-activate-active-row deserve separate change (touch surface callback wiring); track as follow-up.

Runner-up rejected: track active-row by message_id instead of flat index. Survive reorder better but cost extra lookup on every keystroke + complicate Home/End semantic. Flat index simpler, reset-on-change keep stale-pointer risk bounded.

Verify

  • cargo clippy --workspace --all-targets -- -D warnings — clean.
  • cargo fmt --check — clean.
  • cargo check -p willow-web --target wasm32-unknown-unknown — clean.
  • cargo test -p willow-web --lib — 68 pass.
  • New wasm-pack browser tests in crates/web/tests/browser.rs (phase_2e_search_active_row mod) — three test:
    • active row carry aria-selected="true", others false;
    • moving active_index swap selection reactively;
    • flat index walk pick right row under BTreeMap-grouped AllGrovesAndLetters scope (regression guard for "indexes raw vec, not display order" bug).
  • wasm-pack / Firefox / geckodriver not present in this env — browser tests not run locally. Compile-time verify via clippy --all-targets (cover the wasm test target).

Closes #344


Generated by Claude Code

Listbox row pass `selected=Signal::derive(|| false)` always —
keyboard + AT users see no active option. Add `active_index`
signal in `SearchUiState`, derive `selected` per row from flat
in-display-order index, set `aria-activedescendant` on input,
move index on Up/Down/Home/End. Reset on result/scope change.

Browser test prove active row carry `aria-selected="true"`,
others `false`, swap reactively, and cross-group flat-index
walk pick right row under BTreeMap-grouped scope.

Closes #344
@intendednull intendednull merged commit 3b12f32 into main Apr 27, 2026
7 checks passed
@intendednull intendednull deleted the claude/issue-344-search-listbox-active branch April 27, 2026 08:56
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.

[GEN-01] Search results listbox passes hardcoded selected=false

2 participants