diff --git a/src/widgets/chat/chat-widget/ChatWidget.ts b/src/widgets/chat/chat-widget/ChatWidget.ts index 58c591d46..66ee21180 100644 --- a/src/widgets/chat/chat-widget/ChatWidget.ts +++ b/src/widgets/chat/chat-widget/ChatWidget.ts @@ -434,6 +434,17 @@ export class ChatWidget extends EntityScrollerWidget { messageElement.className = `message-row ${isCurrentUser ? 'right' : 'left'}${postingClass}`; // CRITICAL: Add entity ID to DOM for testing/debugging (test expects 'message-id') messageElement.setAttribute('message-id', message.id); + // A11Y (#1099 phase 2). Each message row gets a screen-reader + // label and role=article so the chat transcript can be navigated + // message-by-message instead of word-by-word. The transcript + // container already carries role=log + aria-live=polite from + // phase 1, so new messages auto-announce. + messageElement.setAttribute('role', 'article'); + const ts = new Date(message.timestamp).toLocaleString(); + messageElement.setAttribute( + 'aria-label', + `${senderName} at ${ts}${message.status === 'sending' ? ', sending' : ''}` + ); // Build message structure with DOM APIs (no innerHTML for static structure) const bubble = globalThis.document.createElement('div'); diff --git a/src/widgets/chat/room-list/RoomListWidget.ts b/src/widgets/chat/room-list/RoomListWidget.ts index f5dfb0368..9cb6f623f 100644 --- a/src/widgets/chat/room-list/RoomListWidget.ts +++ b/src/widgets/chat/room-list/RoomListWidget.ts @@ -182,7 +182,11 @@ export class RoomListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
${showNewDM && hasDMs ? html`
+ Start a conversation
` : ''} @@ -191,6 +195,25 @@ export class RoomListWidget extends ReactiveListWidget { `; } + // === A11Y === (#1099 phase 2 + 3a) + protected override isItemIdSelected(id: string): boolean { + return id === this.currentRoomId; + } + + protected override getItemLabel(room: RoomEntity): string { + if (this.isDM(room)) { + const info = this.getDMDisplayInfo(room); + const memberCount = room.members?.length ?? 0; + const isGroup = memberCount > 2; + return isGroup + ? `Group DM: ${info.name}, ${memberCount} members` + : `Direct message with ${info.name}`; + } + const name = room.displayName ?? room.name ?? 'Room'; + const topic = room.topic ?? ''; + return topic ? `Room ${name} — ${topic}` : `Room ${name}`; + } + // === FILTERING === private isDM(room: RoomEntity): boolean { return room.type === 'direct' || (room.tags ?? []).includes('dm'); diff --git a/src/widgets/chat/user-list/UserListWidget.ts b/src/widgets/chat/user-list/UserListWidget.ts index e943c42f5..4e7484e53 100644 --- a/src/widgets/chat/user-list/UserListWidget.ts +++ b/src/widgets/chat/user-list/UserListWidget.ts @@ -177,12 +177,28 @@ export class UserListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
${this.renderFooter()}
`; } + // === A11Y === (#1099 phase 2 + 3a) + protected override isItemIdSelected(id: string): boolean { + return id === this._selectedUserId; + } + + protected override getItemLabel(user: UserEntity): string { + const name = user.displayName ?? 'Unknown user'; + const typeLabel = user.type === 'persona' ? 'persona' : user.type === 'agent' ? 'agent' : 'user'; + const status = user.status ?? 'offline'; + return `${name}, ${typeLabel}, ${status}`; + } + // === ITEM RENDERING === renderItem(user: UserEntity): TemplateResult { const displayName = user.displayName ?? 'Unknown User'; diff --git a/src/widgets/shared/ReactiveListWidget.ts b/src/widgets/shared/ReactiveListWidget.ts index 75d47677d..db1be5e95 100644 --- a/src/widgets/shared/ReactiveListWidget.ts +++ b/src/widgets/shared/ReactiveListWidget.ts @@ -114,7 +114,11 @@ export abstract class ReactiveListWidget extends ReactiveE return html`
${this.renderHeader()} -
+
${this.renderFooter()} @@ -130,15 +134,145 @@ export abstract class ReactiveListWidget extends ReactiveE const div = document.createElement('div'); div.className = 'list-item'; div.dataset.id = item.id; + // ARIA listbox semantics (#1099 phase 2 + 3a). The container has + // role="listbox"; each item is role="option". Roving tabindex + // (only the active item gets tabindex=0, others -1) is managed + // here for initial render and updated dynamically by + // syncSelection() after every Lit update + onListKeydown after + // arrow-key navigation. + div.setAttribute('role', 'option'); + const isSel = this.isItemIdSelected(item.id); + div.tabIndex = isSel ? 0 : -1; + const label = this.getItemLabel(item); + if (label) div.setAttribute('aria-label', label); + div.setAttribute('aria-selected', String(isSel)); render(this.renderItem(item), div); div.addEventListener('click', (e) => { e.stopPropagation(); this.onItemClick(item); }); + // Enter or Space activates the item — same effect as a mouse click. + // The click handler above already handles selection updates. + div.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.onItemClick(item); + } + }); return div; }; } + /** + * Accessible name for a list item. Default uses `displayName` or `name` + * fields if present on the entity, otherwise empty (which omits the + * aria-label and lets the screen reader fall back to the rendered + * text content). Subclasses override to provide a richer label — + * for example ", members". + */ + protected getItemLabel(item: T): string { + const e = item as unknown as { displayName?: string; name?: string }; + return e.displayName ?? e.name ?? ''; + } + + /** + * Keyboard navigation handler attached to the listbox container in + * `firstUpdated()`. ArrowDown/Up move focus to the next/previous + * `.list-item`, Home/End jump to first/last, Enter/Space activate. + * Updates roving tabindex so only the focused item is in the Tab + * order (others get tabindex=-1) — keeps the list a single tab stop + * instead of one per item. + */ + private onListKeydown = (e: KeyboardEvent): void => { + const items = Array.from( + this.shadowRoot?.querySelectorAll(`.${this.containerClass} > .list-item`) ?? [] + ); + if (items.length === 0) return; + + const active = this.shadowRoot?.activeElement as HTMLElement | null; + const currentIdx = active ? items.indexOf(active) : -1; + + let nextIdx: number | null = null; + switch (e.key) { + case 'ArrowDown': + nextIdx = currentIdx < 0 ? 0 : Math.min(currentIdx + 1, items.length - 1); + break; + case 'ArrowUp': + nextIdx = currentIdx < 0 ? items.length - 1 : Math.max(currentIdx - 1, 0); + break; + case 'Home': + nextIdx = 0; + break; + case 'End': + nextIdx = items.length - 1; + break; + default: + return; + } + if (nextIdx !== null) { + e.preventDefault(); + // Roving tabindex: only the about-to-be-focused item is in the + // Tab order. Others step out so Tab from outside the list lands + // on this one item. + items.forEach((el, i) => { el.tabIndex = i === nextIdx ? 0 : -1; }); + items[nextIdx].focus(); + } + }; + + protected override firstUpdated(): void { + super.firstUpdated(); + const container = this.shadowRoot?.querySelector(`.${this.containerClass}`); + container?.addEventListener('keydown', this.onListKeydown as EventListener); + } + + /** + * After every Lit re-render, walk the rendered `.list-item` wrappers + * and update `aria-selected` + the roving `tabindex` to reflect the + * subclass's selection state. The visual `.active` class is already + * reactive via Lit (subclasses re-render their inner template); this + * hook keeps the ARIA attributes on the static EntityScroller-managed + * outer wrapper in sync without re-rendering the wrapper. + * + * If no item is currently selected (e.g., first load before any + * click), the first item gets tabindex=0 so the list remains a + * tab stop. Otherwise the selected item gets tabindex=0, others -1. + */ + protected override updated(changed: Map): void { + super.updated(changed); + this.syncListSelection(); + } + + private syncListSelection(): void { + const items = this.shadowRoot?.querySelectorAll( + `.${this.containerClass} > .list-item` + ); + if (!items || items.length === 0) return; + let selectedFound = false; + items.forEach(item => { + const id = item.dataset.id; + if (!id) return; + const sel = this.isItemIdSelected(id); + item.setAttribute('aria-selected', String(sel)); + item.tabIndex = sel ? 0 : -1; + if (sel) selectedFound = true; + }); + if (!selectedFound && items[0]) { + items[0].tabIndex = 0; + } + } + + /** + * Whether an item with the given id is the currently-selected one. + * Base implementation uses `this.selectedId`. Subclasses with their + * own selection state override this — RoomList uses `currentRoomId`, + * UserList uses `_selectedUserId`. Drives both `aria-selected` and + * the roving tabindex. + */ + protected isItemIdSelected(id: string): boolean { + return id === this.selectedId; + } + protected getLoadFunction(): LoadFn { return async (cursor?: string, limit?: number) => { const result = await DataList.execute({