a11y phase 2: listbox/option semantics + keyboard nav + message-row labels (#1099)#1111
a11y phase 2: listbox/option semantics + keyboard nav + message-row labels (#1099)#1111RebelTechPro wants to merge 1 commit 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>
|
Substantive review (claude tab #2 / claude-tab-2, picked this up via continuum#1127 intake card on the AIRC queue). I have UI/widget context from earlier #1103 a11y baseline review. Architecture / approachClean. The split into phase 2 (semantic markup + arrow-key nav) vs phase 3a (dynamic aria-selected + roving tabindex in #1112) is the right cleavage — each PR is independently reviewable and rollback-safe. Listbox + option + arrow-keys is the canonical pattern; nothing exotic here. The new virtual methods ( CorrectnessVerified the references against the existing
Things to consider (none are blockers)
What you flagged honestly
RecommendationLGTM to land. Mark draft → ready, ping for merge. The phase split is well-conceived; treat the four nits above as either follow-ups or rolled into #1112's review pass. Thanks for stacking these instead of one giant a11y PR — this is much easier to reason about. |
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
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
role=listboxon the container,role=option+aria-labelon each item, arrow-key navigation, Enter/Space to activate.role=article+ anaria-label(sender + timestamp), so screen readers can jump message-by-message through the transcript that phase 1 already exposed as a liverole=log.What changed
ReactiveListWidget— base classrender()addsrole=\"listbox\"andaria-label(fromlistTitle) on the container. Subclasses that overriderender()add the same locally.getRenderFunctionsetsrole=\"option\",tabindex=0,aria-label(via new virtualgetItemLabel()), andaria-selectedon each.list-itemwrapper.onListKeydownhandler attached infirstUpdated(): ArrowDown / ArrowUp move focus between sibling items; Home / End jump to first/last. Scoped to the container so it doesn't interfere with the chat composer or other widgets.RoomListWidgetrole=\"listbox\"+aria-label=\"Rooms and direct messages\"in the render override.getItemLabel():Room {name} — {topic}(topic omitted when empty)Direct message with {name}Group DM: {name}, {count} membersUserListWidgetrole=\"listbox\"+aria-label=\"Users and personas\"in the render override.getItemLabel():{name}, {persona|agent|user}, {status}so the screen reader announces both kind (persona/agent/human) and presence (online/away/offline).ChatWidget.getRenderFunctionrole=\"article\"+aria-labelon each.message-rowcontaining sender name, timestamp, and (for in-flight) " sending".role=log+aria-live=politeon the transcript container — new messages auto-announce, individual rows are nameable.Out of scope (phase 3 follow-ups, not blockers for this PR)
aria-selectedupdates when the active room/user changes after initial render. Currently set once at item-creation time. The visual.activeclass still updates correctly via Lit. Real fix needs a Litupdated()hook that walks DOM after every render.tabindex=0, so Tab cycles through all items. The proper pattern is "active item is tabindex=0, others are -1." Arrow keys already handle within-list navigation; this is a refinement.<div onclick>→<button>migration for click-only divs (semantic HTML).Test plan
npm run build:ts→ green (verified locally){sender} at {time}. Phase 1's auto-announce of new messages still works..list-itemwrapper styling is unaffected (only attributes added).Relationship to other open PRs