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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user