From c3faeddf172ac581702f7bba2d53968b4e4fe614 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:18:26 +0200 Subject: [PATCH] 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. --- ...nderer-production-path.integration.test.ts | 62 +++++- src/renderer.ts | 198 +++++++++++++++++- src/styles.css | 69 +++++- 3 files changed, 312 insertions(+), 17 deletions(-) diff --git a/src/renderer-production-path.integration.test.ts b/src/renderer-production-path.integration.test.ts index 79c0aa2..a5e8646 100644 --- a/src/renderer-production-path.integration.test.ts +++ b/src/renderer-production-path.integration.test.ts @@ -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); diff --git a/src/renderer.ts b/src/renderer.ts index a207a43..5d0438e 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -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('#viewerDetailTitle'); + const text = existing.querySelector('#viewerDetailText'); + const closeButton = existing.querySelector('#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 | 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 { @@ -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 | 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 { @@ -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); } }); diff --git a/src/styles.css b/src/styles.css index 513b670..6c36e4e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;