Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/widgets/chat/room-list/RoomListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ export class RoomListWidget extends ReactiveListWidget<RoomEntity> {
`;
}

// === A11Y === (#1099 phase 2)
// === 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);
Expand Down
11 changes: 8 additions & 3 deletions src/widgets/chat/user-list/UserListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,11 @@ export class UserListWidget extends ReactiveListWidget<UserEntity> {
`;
}

// === A11Y === (#1099 phase 2)
// === 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';
Expand Down Expand Up @@ -322,9 +326,10 @@ export class UserListWidget extends ReactiveListWidget<UserEntity> {
div.className = 'list-item';
div.dataset.id = user.id;
div.setAttribute('role', 'option');
div.tabIndex = 0;
const isSelected = this.isItemIdSelected(user.id);
div.tabIndex = isSelected ? 0 : -1;
div.setAttribute('aria-label', this.getItemLabel(user));
div.setAttribute('aria-selected', String(this._selectedUserId === user.id));
div.setAttribute('aria-selected', String(isSelected));
render(this.renderItem(user), div);
div.addEventListener('click', (e) => {
e.stopPropagation();
Expand Down
72 changes: 63 additions & 9 deletions src/widgets/shared/ReactiveListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,18 @@ 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.
// 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');
div.tabIndex = 0;
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(this.isSelected(item)));
div.setAttribute('aria-selected', String(isSel));
render(this.renderItem(item), div);
div.addEventListener('click', (e) => {
e.stopPropagation();
Expand Down Expand Up @@ -191,8 +193,9 @@ export abstract class ReactiveListWidget<T extends BaseEntity> extends ReactiveE
* 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).
* 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(
Expand Down Expand Up @@ -222,6 +225,10 @@ export abstract class ReactiveListWidget<T extends BaseEntity> extends ReactiveE
}
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();
}
};
Expand All @@ -232,6 +239,53 @@ export abstract class ReactiveListWidget<T extends BaseEntity> extends ReactiveE
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