Skip to content

fix(web): role=button + aria-label on search result rows (#700)#808

Merged
memtomem merged 1 commit intomainfrom
fix/web-a11y-search-result-role
May 5, 2026
Merged

fix(web): role=button + aria-label on search result rows (#700)#808
memtomem merged 1 commit intomainfrom
fix/web-a11y-search-result-role

Conversation

@memtomem
Copy link
Copy Markdown
Owner

@memtomem memtomem commented May 5, 2026

Summary

Search result rows (.result-item) are rendered as bare <div> with tabindex="0" only — screen readers announce them as anonymous "group" landmarks, and keyboard users can't tell from the focus ring whether a row is interactive. The row already had click + Enter/Space handlers wiring it as a button; the role and accessible name were the only thing missing.

This PR fixes the search results surface only — first of three split PRs against #700. Source detail chunk cards and Timeline rows ship separately. The umbrella tracker (#702) explicitly notes the fix can land incrementally.

Changes

  • app.js _buildResultItem — add role="button" + dynamic aria-label assembled from filename, line range, non-default namespace, and relative age. Same pattern .home-source-item already uses (app.js:1703).
  • tests/test_web_a11y.py — new source-scan pin test so neither attribute can drop silently. Mutation-validated: removing either line fails its own test.

Why this surface, this scope

  • The row is fully selectable (toggles .selected, opens detail) — role="button" is the right semantic, not role="listitem".
  • The aria-label is generated from the same data already on the row, so no new server data or i18n keys are needed.
  • The internal .result-checkbox keeps its native semantics (compound widget); same as home-source-item precedent.

Out of scope

  • Source detail chunk cards (app.js:3650 .chunk-card) — separate PR.
  • Timeline rows (timeline.js:188 .timeline-item) — separate PR.
  • Parent #results-list role="list" — skipped because children are buttons, not listitems.

Test plan

  • uv run pytest packages/memtomem/tests/test_web_a11y.py — new pin test passes.
  • Mutation check — removing role= or aria-label= each fail their own test.
  • uv run ruff check packages/memtomem/src packages/memtomem/tests clean.
  • uv run pytest packages/memtomem/tests/test_i18n.py packages/memtomem/tests/test_web_routes.py — adjacent web tests still pass.
  • Manual: focus a result-item with keyboard, confirm screen reader announces ", lines X-Y, namespace Z, ago — button".

Refs #700, umbrella #702.

🤖 Generated with Claude Code

Search results were rendered as bare <div> with tabindex=0 only, so
screen readers announced them as anonymous "group" landmarks and
keyboard users couldn't tell from the focus ring whether a row was
interactive. The row already had click + Enter/Space handlers wiring
it as a button — only the role and accessible name were missing.

Add role="button" + a dynamic aria-label assembled from filename, line
range, non-default namespace, and relative age — the same pattern that
.home-source-item already uses (app.js:1703).

Pin the contract with a source-scan test in test_web_a11y.py so neither
attribute can drop silently. Mutation-validated: removing role= or
aria-label= each fail their own test.

Scope: search results only. Source detail chunk cards and Timeline rows
ship in separate PRs; the umbrella (#702) explicitly notes the fix can
land incrementally.

Refs #700, umbrella #702.

Co-Authored-By: Claude <[email protected]>
@memtomem memtomem merged commit 399d8f0 into main May 5, 2026
11 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators May 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants