Skip to content

a11y: dynamic aria-selected + roving tabindex (phase 3a canary intake)#1156

Merged
joelteply merged 1 commit into
canaryfrom
intake/a11y-phase3a-canary
May 14, 2026
Merged

a11y: dynamic aria-selected + roving tabindex (phase 3a canary intake)#1156
joelteply merged 1 commit into
canaryfrom
intake/a11y-phase3a-canary

Conversation

@joelteply
Copy link
Copy Markdown
Contributor

Summary

Refs #1127
Refs #1099
Supersedes the canary-intake portion of #1112.

Proof

  • Precommit: TypeScript compilation passed; staged ESLint held at baseline 5462; browser smoke skipped because local stack did not answer ./jtag ping.
  • Pre-push: TypeScript clean; ESLint ratchet held at 5462; Rust compile/tests skipped because no Rust-relevant changes; Docker skipped because no Rust/docker changes.
  • Conflict resolution: kept First-run UX: welcome flow, empty states, tutorial-persona auto-invite (post-#336) #1101 empty states and phase-2 keyboard activation while adding phase-3a dynamic aria-selected and roving tabindex.

Layered on phase 2 (PR #1111). Completes the listbox correctness
story by making `aria-selected` and the tab order respond to selection
changes after initial render.

ReactiveListWidget — base class additions:
  - New virtual `protected isItemIdSelected(id): boolean`. Default
    matches `selectedId`; subclasses override to use their own state.
    Drives both aria-selected and the roving tabindex.
  - New Lit `updated()` override walks `.list-item` wrappers after
    every render and syncs aria-selected + tabindex via the new
    `syncListSelection()` helper. The visual `.active` class was
    already reactive via Lit (subclasses re-render their inner
    template); this hook keeps the ARIA state on the static
    EntityScroller-managed outer wrapper in sync without re-rendering
    the wrapper.
  - Initial `getRenderFunction`: tabindex now depends on
    `isItemIdSelected` (selected → 0, others → -1) rather than the
    blanket `tabindex=0` from phase 2.
  - Fallback: if no item is currently selected, the first item gets
    tabindex=0 so the list remains a single Tab stop.
  - Arrow-key navigation in `onListKeydown` updates roving tabindex
    as focus moves — newly-focused item gets tabindex=0, all others
    -1. Keeps the list a single tab stop after the user has navigated.

RoomListWidget:
  - Overrides `isItemIdSelected`: `id === this.currentRoomId`.
    When the active room changes, the @reactive currentRoomId
    triggers a Lit update → updated() → syncListSelection() walks
    the DOM and the new room becomes aria-selected="true" with
    tabindex=0, old room drops to "false" / -1.

UserListWidget:
  - Overrides `isItemIdSelected`: `id === this._selectedUserId`.
    Same reactive pattern.

Out of scope (further phase 3 follow-ups, not blockers):
  - Color-contrast audit across themes
  - <div onclick> → <button> migration
  - axe-core lint gate in CI
  - Focus restoration when a selected item is removed/filtered out

`npm run build:ts` is green. Stacked on PR #1111; once that merges,
this PR's diff against main reduces to just the phase-3a changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@joelteply joelteply merged commit 6d07a58 into canary May 14, 2026
4 checks passed
@joelteply joelteply deleted the intake/a11y-phase3a-canary branch May 14, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant