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
21 changes: 20 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,21 @@ export class RoomListWidget extends ReactiveListWidget<RoomEntity> {
`;
}

// === 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');
Expand Down
14 changes: 13 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,24 @@ 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)
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
82 changes: 81 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,91 @@ 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). 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 "<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.
* 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<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();
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<T> {
return async (cursor?: string, limit?: number) => {
const result = await DataList.execute<T>({
Expand Down
Loading