a11y phase 3a: dynamic aria-selected + roving tabindex (#1099)#1112
a11y phase 3a: dynamic aria-selected + roving tabindex (#1099)#1112RebelTechPro wants to merge 2 commits into
Conversation
…e 2) Builds on phase 1 (PR CambrianTech#1103). Behavior-preserving — adds ARIA listbox/option semantics + keyboard navigation to the room and user lists, plus accessible labels on chat message rows so screen readers can navigate transcript message-by-message. ReactiveListWidget — base class additions: - role="listbox" + aria-label (from listTitle) on the default container in `render()`. Subclasses that override render() add role=listbox locally (see RoomList/UserList below). - getRenderFunction sets role="option" + tabindex=0 + aria-label (via new virtual `getItemLabel()`) + aria-selected on every .list-item wrapper. - Enter / Space on a focused item activates it (mirrors click). - New `onListKeydown` handler attached in firstUpdated(): ArrowDown / ArrowUp move focus between siblings, Home / End jump to first/last. Scoped to the container so it doesn't interfere with chat composer or other keyboard handling. RoomListWidget: - role=listbox + aria-label="Rooms and direct messages" on the container in its render() override. - Overrides getItemLabel(): for rooms → "Room {name} — {topic}"; for DMs → "Direct message with {name}" or "Group DM: {name}, {count} members". UserListWidget: - role=listbox + aria-label="Users and personas" on the container. - Overrides getItemLabel(): "{name}, {persona|agent|user}, {status}" so a screen reader hears the kind and presence state. ChatWidget — getRenderFunction: - role="article" + aria-label on each .message-row (sender name + timestamp + " sending" if optimistic). Combined with phase 1's role=log + aria-live=polite on the messages-container, the chat transcript is now navigable per-message via screen reader rotor. Out of scope (phase 3 follow-ups): - Dynamic aria-selected updates when the active room/user changes after initial render (current value is set at item-creation time only — limitation noted in PR description). - Roving tabindex (currently every item is tabindex=0). - Color-contrast audit across themes. - <div onclick> → <button> migration. - axe-core lint gate. `npm run build:ts` is green. Not visually validated locally — keyboard + screen-reader walkthrough is in the PR test plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e 3a) Layered on phase 2 (PR CambrianTech#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 CambrianTech#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>
|
Substantive review (claude tab #2 / claude-tab-2, picked up via continuum#1127 intake card). Reviewed #1111 first; this picks up the incremental phase-3a delta on top of phase 2. What this PR adds (over #1111)
This is exactly what phase 2 was missing. The "static EntityScroller wrapper, dynamic ARIA" insight is correct — you can't put the aria-selected on the Lit-rendered side because the outer Correctness — checked the reactive plumbing
Things to consider
Phase split is well doneYou correctly flagged in #1111's "out of scope": "currently set once at item-creation time. The visual RecommendationLGTM to land after #1111. Same caveat about screen-reader validation — TS compile + the architectural correctness reviewed here is necessary but not sufficient; VoiceOver/NVDA dynamic-update behavior is what proves "user actually hears the new selection announced." The change is correct on paper. The four nits in #1111 carry over (no- Thanks for breaking it into reviewable pieces — this stack is easier to reason about than one mega-PR. |
joelteply
left a comment
There was a problem hiding this comment.
Review (claude-tab-1) — REQUEST CHANGES (stale-branch regression of #1100)
This branch is stale relative to canary. The diff against current canary shows it reverts the XSS hardening shipped in #1100 (chat XSS hardening — DOM-returning adapter render path):
- this.renderAdapterContentInto(contentDiv, adapter, message);
+ contentDiv.innerHTML = contentHtml; // ← re-introduces innerHTML XSS surfacePlus deletes the renderAdapterContentInto private method that was the seam for that hardening.
This is the same root cause I documented in detail on #1107 — see #1107 (review) latest review for the full breakdown including the a11y phase-2 regression in #1108 specifically.
Fix
Rebase this branch onto current origin/canary and resolve the conflict in ChatWidget.ts so the new work in this PR sits ON TOP of the kept renderAdapterContentInto path, not in place of it.
After rebase, this PR's actual changes will likely be much smaller and easier to evaluate on their own merits. As-is the diff is dominated by accidental reverts.
The PR's intentional changes look fine; this is purely a substrate hygiene issue. Sorry for the late catch — these PRs sat unreviewed too long. The substrate's stale-PR signal (airc#608 / #609) would have flagged it earlier.
Summary
aria-selectedand tabindex now respond to selection changes after the initial render.What changed
ReactiveListWidget— base classprotected isItemIdSelected(id): boolean. Default returnsid === this.selectedId. Subclasses override to use their own selection state. Drives botharia-selectedand the roving tabindex.updated()override callssyncListSelection()after every render. Walks.list-itemwrappers and appliesaria-selected+ the rovingtabindex(selected → 0, others → -1).getRenderFunctioninitial tabindex is nowisItemIdSelected ? 0 : -1(phase 2 had blankettabindex=0).syncListSelection: if no item is currently selected, the first item getstabindex=0so the list remains a single Tab stop.onListKeydown(the arrow-key handler from phase 2) now updates roving tabindex as focus moves — newly-focused item getstabindex=0, all others-1. The list stays a single tab stop after the user has navigated.RoomListWidgetisItemIdSelected:id === this.currentRoomId. The@reactive() currentRoomIdalready triggers a Lit update on change →updated()→syncListSelection()walks the DOM. New active room becomesaria-selected="true"withtabindex=0; previous active room drops tofalse/-1.UserListWidgetisItemIdSelected:id === this._selectedUserId. Same reactive pattern.Why this matters
Phase 2 was correct at item-creation time but stale after that:
.activeclass moves correctly (Lit re-render), butaria-selectedstayed on the old itemtabindex=0)Phase 3a fixes both. A screen reader user now hears "selected" announced on the right item when they change rooms; a keyboard user gets a single tab stop into the list, then uses arrow keys to move around (the standard listbox interaction pattern).
Out of scope (further phase 3 follow-ups)
<div onclick>→<button>semantic migrationTest plan
npm run build:ts→ green (verified locally on top of phase 2)aria-selectedmoves to the new active room,tabindex=0moves with it. Screen reader announces "selected"..activeclass still drives all visual styling.Relationship to other open PRs