Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/widgets/chat/chat-widget/ChatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,17 @@ export class ChatWidget extends EntityScrollerWidget<ChatMessageEntity> {
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');
Expand Down
25 changes: 24 additions & 1 deletion src/widgets/chat/room-list/RoomListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,11 @@ export class RoomListWidget extends ReactiveListWidget<RoomEntity> {
return html`
<div class="entity-list-container">
${this.renderHeader()}
<div class="${this.containerClass}"></div>
<div
class="${this.containerClass}"
role="listbox"
aria-label="Rooms and direct messages"
></div>
${showNewDM && hasDMs ? html`
<div class="new-dm-btn" @click=${this.startNewDM}>+ Start a conversation</div>
` : ''}
Expand All @@ -191,6 +195,25 @@ export class RoomListWidget extends ReactiveListWidget<RoomEntity> {
`;
}

// === 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');
Expand Down
18 changes: 17 additions & 1 deletion src/widgets/chat/user-list/UserListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,28 @@ export class UserListWidget extends ReactiveListWidget<UserEntity> {
return html`
<div class="entity-list-container">
${this.renderHeader()}
<div class="${this.containerClass}"></div>
<div
class="${this.containerClass}"
role="listbox"
aria-label="Users and personas"
></div>
${this.renderFooter()}
</div>
`;
}

// === 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';
Expand Down
136 changes: 135 additions & 1 deletion src/widgets/shared/ReactiveListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ export abstract class ReactiveListWidget<T extends BaseEntity> extends ReactiveE
return html`
<div class="list-widget">
${this.renderHeader()}
<div class="${this.containerClass}">
<div
class="${this.containerClass}"
role="listbox"
aria-label=${this.listTitle}
>
<!-- EntityScroller populates items here -->
</div>
${this.renderFooter()}
Expand All @@ -130,15 +134,145 @@ export abstract class ReactiveListWidget<T extends BaseEntity> 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 "<room name>, <member count> 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<HTMLElement>(`.${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<string, unknown>): void {
super.updated(changed);
this.syncListSelection();
}

private syncListSelection(): void {
const items = this.shadowRoot?.querySelectorAll<HTMLElement>(
`.${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<T> {
return async (cursor?: string, limit?: number) => {
const result = await DataList.execute<T>({
Expand Down
Loading