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..d17b57983 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,21 @@ export class RoomListWidget extends ReactiveListWidget { `; } + // === A11Y === (#1099 phase 2) + 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..52c9e3968 100644 --- a/src/widgets/chat/user-list/UserListWidget.ts +++ b/src/widgets/chat/user-list/UserListWidget.ts @@ -177,12 +177,24 @@ export class UserListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
${this.renderFooter()}
`; } + // === A11Y === (#1099 phase 2) + 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..1888c7633 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,91 @@ 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). The container has + // role="listbox" (set in subclass render overrides); each item + // is role="option". tabindex=0 makes items keyboard-focusable — + // proper roving tabindex (only the active item gets tabindex=0) + // is a phase-3 follow-up. + div.setAttribute('role', 'option'); + div.tabIndex = 0; + const label = this.getItemLabel(item); + if (label) div.setAttribute('aria-label', label); + div.setAttribute('aria-selected', String(this.isSelected(item))); 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. + * Handler is scoped to the container so it doesn't interfere with + * keyboard handling on sibling widgets (e.g., the chat composer). + */ + 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(); + items[nextIdx].focus(); + } + }; + + protected override firstUpdated(): void { + super.firstUpdated(); + const container = this.shadowRoot?.querySelector(`.${this.containerClass}`); + container?.addEventListener('keydown', this.onListKeydown as EventListener); + } + protected getLoadFunction(): LoadFn { return async (cursor?: string, limit?: number) => { const result = await DataList.execute({