fix(accessibility): preserve virtual viewer focus

Keep chat and event viewer focus on a listbox with an active descendant instead of creating row tab stops. Provide a full-text detail dialog for keyboard users with Escape focus restoration and production-path coverage for scrolling and selection.
This commit is contained in:
Sucukdeluxe
2026-08-12 02:18:26 +02:00
parent 42cb707313
commit c3faeddf17
3 changed files with 312 additions and 17 deletions
@@ -182,6 +182,7 @@ class FakeElement {
if (selector.includes('[role="menuitem"]')) return descendants.filter((element) => element.getAttribute('role') === 'menuitem') as T[];
if (selector.includes('a[href]') || selector.includes('[tabindex]')) return descendants.filter((element) => element.tagName === 'button' || element.tagName === 'input' || element.tabIndex >= 0) as T[];
if (selector === 'button') return descendants.filter((element) => element.tagName === 'button') as T[];
if (selector.startsWith('#')) return descendants.filter((element) => element.id === selector.slice(1)) as T[];
if (selector.startsWith('.')) return descendants.filter((element) => element.matches(selector)) as T[];
return [];
}
@@ -222,7 +223,7 @@ class FakeDocument {
}
getElementById(id: string): FakeElement | null {
return this.elements.get(id) ?? null;
return this.elements.get(id) ?? findDescendantById(this.body, id);
}
querySelector(selector: string): FakeElement | null {
@@ -240,6 +241,13 @@ class FakeDocument {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener));
}
dispatch(event: FakeEvent): void {
for (const listener of this.listeners.get(event.type) ?? []) {
if (event.immediatePropagationStopped) break;
listener(event);
}
}
register(element: FakeElement, id: string): FakeElement {
element.id = id;
this.elements.set(id, element);
@@ -299,7 +307,8 @@ function createRuntime(): Runtime {
viewEvents: 'Events', viewEventsCount: '{count} events', viewEventsEmpty: 'No events', eventStartedAs: 'Started as', eventEndedAfter: 'Ended after',
eventRecordingResume: 'Resume started — part {part}', eventTitleFromTo: 'Title {from} {to}', eventGameFromTo: 'Game {from} {to}'
},
vods: { ctxOpenOnTwitch: 'Open', ctxCopyUrl: 'Copy', trimButton: 'Trim', addQueue: 'Queue', ctxMarkDownloaded: 'Mark', ctxUnmarkDownloaded: 'Unmark' }
vods: { ctxOpenOnTwitch: 'Open', ctxCopyUrl: 'Copy', trimButton: 'Trim', addQueue: 'Queue', ctxMarkDownloaded: 'Mark', ctxUnmarkDownloaded: 'Unmark' },
streamers: { modalCloseAria: 'Close' }
},
config: { downloaded_vod_ids: [] },
currentLanguage: 'en',
@@ -327,6 +336,15 @@ function findMenu(document: FakeDocument): FakeElement {
return menu;
}
function findDescendantById(element: FakeElement, id: string): FakeElement | null {
if (element.id === id) return element;
for (const child of element.children) {
const found = findDescendantById(child, id);
if (found) return found;
}
return null;
}
describe('renderer production interaction paths', () => {
it('opens queue ContextMenu and Shift+F10 menus through the delegated renderer path and restores focus', () => {
const runtime = createRuntime();
@@ -403,15 +421,37 @@ describe('renderer production interaction paths', () => {
return pendingResult;
}
if (path === 'events') return Promise.resolve({ success: true, messages: [{ type: 'recording_start', title: 'Long event title', game: 'Game' }] });
return Promise.resolve({ success: true, format: 'live', messages: [{ type: 'msg', u: 'viewer', msg: 'Long chat message' }] });
return Promise.resolve({ success: true, format: 'live', messages: Array.from({ length: 80 }, (_, index) => ({ type: 'msg', u: `viewer-${index}`, msg: `Long chat message ${index}` })) });
}
};
const api = evaluate(runtime, sourceFragment('renderer.ts', 'interface EventLogEntry', 'function closeTopmostOpenModal'), 'openChatViewer, closeChatViewer, onChatViewerFilterChange, openEventsViewer, closeEventsViewer');
await api.openChatViewer('chat', 'Chat');
const chatRow = chatList.firstChild?.firstChild?.firstChild;
expect(chatRow?.getAttribute('tabindex')).toBe('0');
expect(chatRow?.getAttribute('aria-label')).toContain('Long chat message');
expect(chatList.getAttribute('role')).toBe('listbox');
expect(chatRow?.getAttribute('role')).toBe('option');
expect(chatRow?.getAttribute('tabindex')).toBeNull();
chatList.focus();
chatList.scrollTop = 29 * 40;
chatList.dispatch(new FakeEvent('scroll'));
expect(runtime.document.activeElement).toBe(chatList);
const chatActiveId = chatList.getAttribute('aria-activedescendant');
expect(chatActiveId).toBeTruthy();
expect(findDescendantById(chatList, chatActiveId || '')).not.toBeNull();
chatList.dispatch(new FakeEvent('keydown', { key: 'ArrowDown' }));
expect(runtime.document.activeElement).toBe(chatList);
const chatKeyboardId = chatList.getAttribute('aria-activedescendant');
expect(chatKeyboardId).not.toBe(chatActiveId);
expect(findDescendantById(chatList, chatKeyboardId || '')).not.toBeNull();
chatList.dispatch(new FakeEvent('keydown', { key: 'Enter' }));
const detailDialog = findDescendantById(runtime.document.body, 'viewerDetailModal');
expect(detailDialog?.classList.contains('show')).toBe(true);
expect(findDescendantById(detailDialog as FakeElement, 'viewerDetailText')?.textContent).toContain('Long chat message 41');
const detailEscape = new FakeEvent('keydown', { key: 'Escape' });
runtime.document.dispatch(detailEscape);
expect(detailEscape.immediatePropagationStopped).toBe(true);
expect(detailDialog?.classList.contains('show')).toBe(false);
expect(runtime.document.activeElement).toBe(chatList);
let clock = 0;
runtime.context.performance = { now: () => (clock++ === 0 ? 0 : 9) };
@@ -426,8 +466,16 @@ describe('renderer production interaction paths', () => {
await api.openEventsViewer('events', 'Events');
const eventsRow = eventsList.firstChild?.firstChild?.firstChild;
expect(eventsRow?.getAttribute('tabindex')).toBe('0');
expect(eventsRow?.getAttribute('aria-label')).toContain('Long event title');
expect(eventsList.getAttribute('role')).toBe('listbox');
expect(eventsRow?.getAttribute('role')).toBe('option');
expect(eventsRow?.getAttribute('tabindex')).toBeNull();
eventsList.focus();
eventsList.dispatch(new FakeEvent('keydown', { key: 'Enter' }));
const eventsDetail = findDescendantById(runtime.document.body, 'viewerDetailModal');
expect(eventsDetail?.classList.contains('show')).toBe(true);
expect(findDescendantById(eventsDetail as FakeElement, 'viewerDetailText')?.textContent).toContain('Long event title');
runtime.document.dispatch(new FakeEvent('keydown', { key: 'Escape' }));
expect(runtime.document.activeElement).toBe(eventsList);
const pending = api.openEventsViewer('events-pending', 'Events');
api.closeEventsViewer();
expect(reads.at(-1)?.signal.aborted).toBe(true);
+190 -8
View File
@@ -308,10 +308,153 @@ interface EventLogEntry {
part?: number;
}
interface ViewerListboxController {
activeIndex(): number;
synchronizeRange(range: { start: number; end: number }): number;
dispose(): void;
}
interface ViewerListboxOptions {
list: HTMLElement;
itemCount: () => number;
rowHeight: number;
optionId: (index: number) => string;
label: string;
detailTitle: string;
detailText: (index: number) => string;
requestRender: () => void;
}
function ensureViewerDetailDialog(): { dialog: HTMLElement; title: HTMLElement; text: HTMLElement; closeButton: HTMLButtonElement } {
const existing = document.getElementById('viewerDetailModal');
if (existing instanceof HTMLElement) {
const title = existing.querySelector<HTMLElement>('#viewerDetailTitle');
const text = existing.querySelector<HTMLElement>('#viewerDetailText');
const closeButton = existing.querySelector<HTMLButtonElement>('#viewerDetailClose');
if (title && text && closeButton) return { dialog: existing, title, text, closeButton };
existing.remove();
}
const dialog = document.createElement('div');
dialog.id = 'viewerDetailModal';
dialog.className = 'viewer-detail-modal';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'viewerDetailTitle');
const panel = document.createElement('div');
panel.className = 'viewer-detail-dialog';
const header = document.createElement('div');
header.className = 'viewer-detail-header';
const title = document.createElement('h2');
title.id = 'viewerDetailTitle';
const closeButton = document.createElement('button');
closeButton.id = 'viewerDetailClose';
closeButton.type = 'button';
closeButton.className = 'viewer-detail-close';
closeButton.textContent = '×';
closeButton.setAttribute('aria-label', UI_TEXT.streamers.modalCloseAria);
closeButton.addEventListener('click', () => RendererAccessibility.closeDialog('viewerDetailModal'));
const text = document.createElement('div');
text.id = 'viewerDetailText';
text.className = 'viewer-detail-text';
header.append(title, closeButton);
panel.append(header, text);
dialog.appendChild(panel);
document.body.appendChild(dialog);
return { dialog, title, text, closeButton };
}
function openViewerDetail(title: string, text: string, returnFocus: HTMLElement): void {
const detail = ensureViewerDetailDialog();
detail.title.textContent = title;
detail.text.textContent = text;
if (document.activeElement !== returnFocus) returnFocus.focus();
RendererAccessibility.openDialog('viewerDetailModal', { initialFocus: detail.closeButton });
}
function createViewerListbox(options: ViewerListboxOptions): ViewerListboxController {
const { list } = options;
let active = options.itemCount() > 0 ? 0 : -1;
const updateActiveDescendant = (): void => {
if (active < 0) list.removeAttribute('aria-activedescendant');
else list.setAttribute('aria-activedescendant', options.optionId(active));
};
const synchronizeRange = (range: { start: number; end: number }): number => {
const count = options.itemCount();
if (count === 0 || range.end <= range.start) {
active = -1;
updateActiveDescendant();
return active;
}
if (active < range.start || active >= range.end) {
const firstVisible = Math.max(range.start, Math.floor(list.scrollTop / options.rowHeight));
active = Math.min(range.end - 1, Math.max(range.start, firstVisible));
}
updateActiveDescendant();
return active;
};
const setActive = (next: number): void => {
const count = options.itemCount();
if (count === 0) return;
active = Math.max(0, Math.min(count - 1, next));
const firstVisible = Math.floor(list.scrollTop / options.rowHeight);
const visibleCount = Math.max(1, Math.ceil(list.clientHeight / options.rowHeight));
if (active < firstVisible || active >= firstVisible + visibleCount) {
list.scrollTop = Math.max(0, active * options.rowHeight - Math.floor(Math.max(0, list.clientHeight - options.rowHeight) / 2));
}
updateActiveDescendant();
options.requestRender();
};
const onKeyDown = (event: KeyboardEvent): void => {
const count = options.itemCount();
if (count === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive(active + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setActive(active - 1);
return;
}
if (event.key === 'Home') {
event.preventDefault();
setActive(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
setActive(count - 1);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openViewerDetail(options.detailTitle, options.detailText(active), list);
}
};
list.setAttribute('role', 'listbox');
list.setAttribute('tabindex', '0');
list.setAttribute('aria-label', options.label);
updateActiveDescendant();
list.addEventListener('keydown', onKeyDown);
return {
activeIndex: () => active,
synchronizeRange,
dispose: () => {
list.removeEventListener('keydown', onKeyDown);
list.removeAttribute('aria-activedescendant');
list.removeAttribute('aria-label');
list.removeAttribute('role');
list.removeAttribute('tabindex');
}
};
}
let eventsViewerMessages: EventLogEntry[] = [];
let eventsViewerSessionGeneration = 0;
let eventsViewerReadAbort: AbortController | null = null;
let eventsViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
let eventsViewerListbox: ViewerListboxController | null = null;
const eventsViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
const EVENT_VIEWER_ROW_HEIGHT = 36;
const EVENT_VIEWER_OVERSCAN = 12;
@@ -319,6 +462,8 @@ const EVENT_VIEWER_OVERSCAN = 12;
function disposeEventsViewerVirtualList(): void {
eventsViewerVirtualList?.dispose();
eventsViewerVirtualList = null;
eventsViewerListbox?.dispose();
eventsViewerListbox = null;
}
async function openEventsViewer(filePath: string, title: string): Promise<void> {
@@ -383,9 +528,16 @@ function getEventViewerDetail(event: EventLogEntry): string {
return JSON.stringify(event);
}
function createEventViewerRow(event: EventLogEntry): HTMLElement {
function getEventViewerAccessibleLabel(event: EventLogEntry): string {
return `${formatEventTime(event.t)} ${event.type || 'event'} ${getEventViewerDetail(event)}`.trim();
}
function createEventViewerRow(event: EventLogEntry, index: number, activeIndex: number): HTMLElement {
const row = document.createElement('div');
row.className = 'event-viewer-row';
row.id = `eventsViewerOption-${index}`;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', String(index === activeIndex));
const time = document.createElement('span');
time.className = 'event-viewer-time';
time.textContent = formatEventTime(event.t);
@@ -396,9 +548,8 @@ function createEventViewerRow(event: EventLogEntry): HTMLElement {
const detail = document.createElement('div');
detail.className = 'event-viewer-detail';
detail.textContent = getEventViewerDetail(event);
const accessibleLabel = `${time.textContent} ${tag.textContent} ${detail.textContent}`.trim();
const accessibleLabel = getEventViewerAccessibleLabel(event);
row.title = accessibleLabel;
row.setAttribute('tabindex', '0');
row.setAttribute('aria-label', accessibleLabel);
row.append(time, tag, detail);
return row;
@@ -423,15 +574,26 @@ function renderEventsList(events: EventLogEntry[], generation: number): void {
rows.className = 'event-viewer-virtual-rows';
canvas.appendChild(rows);
list.appendChild(canvas);
eventsViewerListbox = createViewerListbox({
list,
itemCount: () => events.length,
rowHeight: EVENT_VIEWER_ROW_HEIGHT,
optionId: (index) => `eventsViewerOption-${index}`,
label: UI_TEXT.queue.viewEvents,
detailTitle: UI_TEXT.queue.viewEvents,
detailText: (index) => getEventViewerAccessibleLabel(events[index]),
requestRender: () => eventsViewerVirtualList?.render(),
});
eventsViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, {
itemCount: () => events.length,
rowHeight: EVENT_VIEWER_ROW_HEIGHT,
overscan: EVENT_VIEWER_OVERSCAN,
render: (range) => {
if (!eventsViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('eventsViewerModal')) return;
const activeIndex = eventsViewerListbox?.synchronizeRange(range) ?? -1;
rows.style.transform = `translateY(${range.start * EVENT_VIEWER_ROW_HEIGHT}px)`;
const fragment = document.createDocumentFragment();
for (let index = range.start; index < range.end; index++) fragment.appendChild(createEventViewerRow(events[index]));
for (let index = range.start; index < range.end; index++) fragment.appendChild(createEventViewerRow(events[index], index, activeIndex));
rows.replaceChildren(fragment);
}
});
@@ -458,6 +620,7 @@ let chatViewerFormat: 'replay' | 'live' = 'replay';
let chatViewerSessionGeneration = 0;
let chatViewerReadAbort: AbortController | null = null;
let chatViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
let chatViewerListbox: ViewerListboxController | null = null;
const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
const CHAT_VIEWER_ROW_HEIGHT = 29;
const CHAT_VIEWER_OVERSCAN = 12;
@@ -465,6 +628,8 @@ const CHAT_VIEWER_OVERSCAN = 12;
function disposeChatViewerVirtualList(): void {
chatViewerVirtualList?.dispose();
chatViewerVirtualList = null;
chatViewerListbox?.dispose();
chatViewerListbox = null;
}
async function openChatViewer(filePath: string, title: string): Promise<void> {
@@ -546,10 +711,17 @@ function onChatViewerFilterChange(): void {
filterChunk();
}
function createChatViewerRow(m: ChatViewerMessage): HTMLElement {
function getChatViewerAccessibleLabel(m: ChatViewerMessage): string {
return [formatChatTimeMarker(m), m.u || m.user || m.login || '', m.msg || m.text || ''].filter(Boolean).join(' ');
}
function createChatViewerRow(m: ChatViewerMessage, index: number, activeIndex: number): HTMLElement {
const row = document.createElement('div');
const isMessageType = m.type === 'msg' || !m.type;
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
row.id = `chatViewerOption-${index}`;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', String(index === activeIndex));
if (!isMessageType) {
const tag = document.createElement('span');
@@ -578,9 +750,8 @@ function createChatViewerRow(m: ChatViewerMessage): HTMLElement {
const message = document.createElement('span');
message.textContent = ' ' + (m.msg || m.text || '');
row.appendChild(message);
const accessibleLabel = [time, user, m.msg || m.text || ''].filter(Boolean).join(' ');
const accessibleLabel = getChatViewerAccessibleLabel(m);
row.title = accessibleLabel;
row.setAttribute('tabindex', '0');
row.setAttribute('aria-label', accessibleLabel);
return row;
}
@@ -598,15 +769,26 @@ function renderChatViewerList(messages: ChatViewerMessage[], generation: number)
rows.className = 'chat-viewer-virtual-rows';
canvas.appendChild(rows);
list.appendChild(canvas);
chatViewerListbox = createViewerListbox({
list,
itemCount: () => messages.length,
rowHeight: CHAT_VIEWER_ROW_HEIGHT,
optionId: (index) => `chatViewerOption-${index}`,
label: UI_TEXT.queue.viewChat,
detailTitle: UI_TEXT.queue.viewChat,
detailText: (index) => getChatViewerAccessibleLabel(messages[index]),
requestRender: () => chatViewerVirtualList?.render(),
});
chatViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, {
itemCount: () => messages.length,
rowHeight: CHAT_VIEWER_ROW_HEIGHT,
overscan: CHAT_VIEWER_OVERSCAN,
render: (range) => {
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
const activeIndex = chatViewerListbox?.synchronizeRange(range) ?? -1;
rows.style.transform = `translateY(${range.start * CHAT_VIEWER_ROW_HEIGHT}px)`;
const fragment = document.createDocumentFragment();
for (let index = range.start; index < range.end; index++) fragment.appendChild(createChatViewerRow(messages[index]));
for (let index = range.start; index < range.end; index++) fragment.appendChild(createChatViewerRow(messages[index], index, activeIndex));
rows.replaceChildren(fragment);
}
});
+67 -2
View File
@@ -4942,12 +4942,77 @@ input[type="number"]::-webkit-outer-spin-button {
background: rgba(255, 255, 255, 0.04);
}
.chat-viewer-row:focus-visible,
.event-viewer-row:focus-visible {
.chat-viewer-row[aria-selected="true"],
.event-viewer-row[aria-selected="true"] {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.viewer-detail-modal {
display: none;
position: fixed;
inset: 0;
z-index: 2200;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.66);
}
.viewer-detail-modal.show {
display: flex;
}
.viewer-detail-dialog {
width: min(760px, 100%);
max-height: min(680px, 100%);
display: flex;
flex-direction: column;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--bg-card);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
}
.viewer-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--border-soft);
}
.viewer-detail-header h2 {
margin: 0;
font-size: 16px;
}
.viewer-detail-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 6px;
color: var(--text);
background: transparent;
font-size: 24px;
line-height: 1;
}
.viewer-detail-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.viewer-detail-text {
overflow: auto;
padding: 18px;
color: var(--text);
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.chat-viewer-row .chat-viewer-time {
color: var(--text-secondary);
margin-right: 8px;