diff --git a/src/eslint-baseline.linux.txt b/src/eslint-baseline.linux.txt index 4e8a6e6d2..053a342ad 100644 --- a/src/eslint-baseline.linux.txt +++ b/src/eslint-baseline.linux.txt @@ -1 +1 @@ -5464 +5462 diff --git a/src/eslint-baseline.txt b/src/eslint-baseline.txt index 4e8a6e6d2..053a342ad 100644 --- a/src/eslint-baseline.txt +++ b/src/eslint-baseline.txt @@ -1 +1 @@ -5464 +5462 diff --git a/src/widgets/chat/chat-widget/ChatWidget.ts b/src/widgets/chat/chat-widget/ChatWidget.ts index 8b3aaaaa9..c6ffe400e 100644 --- a/src/widgets/chat/chat-widget/ChatWidget.ts +++ b/src/widgets/chat/chat-widget/ChatWidget.ts @@ -432,6 +432,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 a074c221b..56d3a251c 100644 --- a/src/widgets/chat/room-list/RoomListWidget.ts +++ b/src/widgets/chat/room-list/RoomListWidget.ts @@ -13,6 +13,7 @@ import { html, reactive, unsafeCSS, + nothing, type TemplateResult, type CSSResultGroup } from '../../shared/ReactiveListWidget'; @@ -201,7 +202,13 @@ export class RoomListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
+ ${this.isEmpty ? this.renderEmptyState() : nothing} ${showNewDM && hasDMs ? html`
+ Start a conversation
` : ''} @@ -210,6 +217,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'); @@ -436,7 +458,7 @@ export class RoomListWidget extends ReactiveListWidget { this.selectRoom(room); } - protected override onItemClick(_item: RoomEntity): void { - // Handled by @click in renderItem template + protected override onItemClick(item: RoomEntity): void { + this.selectRoom(item); } } diff --git a/src/widgets/chat/user-list/UserListWidget.ts b/src/widgets/chat/user-list/UserListWidget.ts index 75719a1ea..52aa47f5f 100644 --- a/src/widgets/chat/user-list/UserListWidget.ts +++ b/src/widgets/chat/user-list/UserListWidget.ts @@ -181,7 +181,12 @@ export class UserListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
${this.isEmpty ? this.renderEmptyState() : nothing} ${this.renderFooter()}
@@ -202,6 +207,14 @@ export class UserListWidget extends ReactiveListWidget { `; } + // === 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'; @@ -308,11 +321,22 @@ export class UserListWidget extends ReactiveListWidget { const div = globalThis.document.createElement('div'); div.className = 'list-item'; div.dataset.id = user.id; + div.setAttribute('role', 'option'); + div.tabIndex = 0; + div.setAttribute('aria-label', this.getItemLabel(user)); + div.setAttribute('aria-selected', String(this._selectedUserId === user.id)); render(this.renderItem(user), div); div.addEventListener('click', (e) => { e.stopPropagation(); this.onItemClick(user); }); + div.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.onItemClick(user); + } + }); return div; }; } @@ -320,6 +344,7 @@ export class UserListWidget extends ReactiveListWidget { // === EVENT HANDLERS === private handleUserClick(e: Event, user: UserEntity): void { if ((e.target as HTMLElement).tagName === 'BUTTON') return; + e.stopPropagation(); this._selectedUserId = user.id; this.openUserProfile(user); } @@ -406,7 +431,8 @@ export class UserListWidget extends ReactiveListWidget { } // === SELECTION HOOK (override base) === - protected override onItemClick(_item: UserEntity): void { - // Handled by @click in renderItem template + protected override onItemClick(item: UserEntity): void { + this._selectedUserId = item.id; + this.openUserProfile(item); } } diff --git a/src/widgets/shared/ReactiveListWidget.ts b/src/widgets/shared/ReactiveListWidget.ts index efa38cc7a..44126fc7d 100644 --- a/src/widgets/shared/ReactiveListWidget.ts +++ b/src/widgets/shared/ReactiveListWidget.ts @@ -125,7 +125,12 @@ export abstract class ReactiveListWidget extends ReactiveE return html`
${this.renderHeader()} -
+
${this.isEmpty ? this.renderEmptyState() : nothing} @@ -142,15 +147,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({