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('[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.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 === '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[];
|
if (selector.startsWith('.')) return descendants.filter((element) => element.matches(selector)) as T[];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -222,7 +223,7 @@ class FakeDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getElementById(id: string): FakeElement | null {
|
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 {
|
querySelector(selector: string): FakeElement | null {
|
||||||
@@ -240,6 +241,13 @@ class FakeDocument {
|
|||||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener));
|
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 {
|
register(element: FakeElement, id: string): FakeElement {
|
||||||
element.id = id;
|
element.id = id;
|
||||||
this.elements.set(id, element);
|
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',
|
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}'
|
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: [] },
|
config: { downloaded_vod_ids: [] },
|
||||||
currentLanguage: 'en',
|
currentLanguage: 'en',
|
||||||
@@ -327,6 +336,15 @@ function findMenu(document: FakeDocument): FakeElement {
|
|||||||
return menu;
|
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', () => {
|
describe('renderer production interaction paths', () => {
|
||||||
it('opens queue ContextMenu and Shift+F10 menus through the delegated renderer path and restores focus', () => {
|
it('opens queue ContextMenu and Shift+F10 menus through the delegated renderer path and restores focus', () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
@@ -403,15 +421,37 @@ describe('renderer production interaction paths', () => {
|
|||||||
return pendingResult;
|
return pendingResult;
|
||||||
}
|
}
|
||||||
if (path === 'events') return Promise.resolve({ success: true, messages: [{ type: 'recording_start', title: 'Long event title', game: 'Game' }] });
|
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');
|
const api = evaluate(runtime, sourceFragment('renderer.ts', 'interface EventLogEntry', 'function closeTopmostOpenModal'), 'openChatViewer, closeChatViewer, onChatViewerFilterChange, openEventsViewer, closeEventsViewer');
|
||||||
|
|
||||||
await api.openChatViewer('chat', 'Chat');
|
await api.openChatViewer('chat', 'Chat');
|
||||||
const chatRow = chatList.firstChild?.firstChild?.firstChild;
|
const chatRow = chatList.firstChild?.firstChild?.firstChild;
|
||||||
expect(chatRow?.getAttribute('tabindex')).toBe('0');
|
expect(chatList.getAttribute('role')).toBe('listbox');
|
||||||
expect(chatRow?.getAttribute('aria-label')).toContain('Long chat message');
|
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;
|
let clock = 0;
|
||||||
runtime.context.performance = { now: () => (clock++ === 0 ? 0 : 9) };
|
runtime.context.performance = { now: () => (clock++ === 0 ? 0 : 9) };
|
||||||
@@ -426,8 +466,16 @@ describe('renderer production interaction paths', () => {
|
|||||||
|
|
||||||
await api.openEventsViewer('events', 'Events');
|
await api.openEventsViewer('events', 'Events');
|
||||||
const eventsRow = eventsList.firstChild?.firstChild?.firstChild;
|
const eventsRow = eventsList.firstChild?.firstChild?.firstChild;
|
||||||
expect(eventsRow?.getAttribute('tabindex')).toBe('0');
|
expect(eventsList.getAttribute('role')).toBe('listbox');
|
||||||
expect(eventsRow?.getAttribute('aria-label')).toContain('Long event title');
|
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');
|
const pending = api.openEventsViewer('events-pending', 'Events');
|
||||||
api.closeEventsViewer();
|
api.closeEventsViewer();
|
||||||
expect(reads.at(-1)?.signal.aborted).toBe(true);
|
expect(reads.at(-1)?.signal.aborted).toBe(true);
|
||||||
|
|||||||
+190
-8
@@ -308,10 +308,153 @@ interface EventLogEntry {
|
|||||||
part?: number;
|
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 eventsViewerMessages: EventLogEntry[] = [];
|
||||||
let eventsViewerSessionGeneration = 0;
|
let eventsViewerSessionGeneration = 0;
|
||||||
let eventsViewerReadAbort: AbortController | null = null;
|
let eventsViewerReadAbort: AbortController | null = null;
|
||||||
let eventsViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
|
let eventsViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
|
||||||
|
let eventsViewerListbox: ViewerListboxController | null = null;
|
||||||
const eventsViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
|
const eventsViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
|
||||||
const EVENT_VIEWER_ROW_HEIGHT = 36;
|
const EVENT_VIEWER_ROW_HEIGHT = 36;
|
||||||
const EVENT_VIEWER_OVERSCAN = 12;
|
const EVENT_VIEWER_OVERSCAN = 12;
|
||||||
@@ -319,6 +462,8 @@ const EVENT_VIEWER_OVERSCAN = 12;
|
|||||||
function disposeEventsViewerVirtualList(): void {
|
function disposeEventsViewerVirtualList(): void {
|
||||||
eventsViewerVirtualList?.dispose();
|
eventsViewerVirtualList?.dispose();
|
||||||
eventsViewerVirtualList = null;
|
eventsViewerVirtualList = null;
|
||||||
|
eventsViewerListbox?.dispose();
|
||||||
|
eventsViewerListbox = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||||
@@ -383,9 +528,16 @@ function getEventViewerDetail(event: EventLogEntry): string {
|
|||||||
return JSON.stringify(event);
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'event-viewer-row';
|
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');
|
const time = document.createElement('span');
|
||||||
time.className = 'event-viewer-time';
|
time.className = 'event-viewer-time';
|
||||||
time.textContent = formatEventTime(event.t);
|
time.textContent = formatEventTime(event.t);
|
||||||
@@ -396,9 +548,8 @@ function createEventViewerRow(event: EventLogEntry): HTMLElement {
|
|||||||
const detail = document.createElement('div');
|
const detail = document.createElement('div');
|
||||||
detail.className = 'event-viewer-detail';
|
detail.className = 'event-viewer-detail';
|
||||||
detail.textContent = getEventViewerDetail(event);
|
detail.textContent = getEventViewerDetail(event);
|
||||||
const accessibleLabel = `${time.textContent} ${tag.textContent} ${detail.textContent}`.trim();
|
const accessibleLabel = getEventViewerAccessibleLabel(event);
|
||||||
row.title = accessibleLabel;
|
row.title = accessibleLabel;
|
||||||
row.setAttribute('tabindex', '0');
|
|
||||||
row.setAttribute('aria-label', accessibleLabel);
|
row.setAttribute('aria-label', accessibleLabel);
|
||||||
row.append(time, tag, detail);
|
row.append(time, tag, detail);
|
||||||
return row;
|
return row;
|
||||||
@@ -423,15 +574,26 @@ function renderEventsList(events: EventLogEntry[], generation: number): void {
|
|||||||
rows.className = 'event-viewer-virtual-rows';
|
rows.className = 'event-viewer-virtual-rows';
|
||||||
canvas.appendChild(rows);
|
canvas.appendChild(rows);
|
||||||
list.appendChild(canvas);
|
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, {
|
eventsViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, {
|
||||||
itemCount: () => events.length,
|
itemCount: () => events.length,
|
||||||
rowHeight: EVENT_VIEWER_ROW_HEIGHT,
|
rowHeight: EVENT_VIEWER_ROW_HEIGHT,
|
||||||
overscan: EVENT_VIEWER_OVERSCAN,
|
overscan: EVENT_VIEWER_OVERSCAN,
|
||||||
render: (range) => {
|
render: (range) => {
|
||||||
if (!eventsViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('eventsViewerModal')) return;
|
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)`;
|
rows.style.transform = `translateY(${range.start * EVENT_VIEWER_ROW_HEIGHT}px)`;
|
||||||
const fragment = document.createDocumentFragment();
|
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);
|
rows.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -458,6 +620,7 @@ let chatViewerFormat: 'replay' | 'live' = 'replay';
|
|||||||
let chatViewerSessionGeneration = 0;
|
let chatViewerSessionGeneration = 0;
|
||||||
let chatViewerReadAbort: AbortController | null = null;
|
let chatViewerReadAbort: AbortController | null = null;
|
||||||
let chatViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
|
let chatViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
|
||||||
|
let chatViewerListbox: ViewerListboxController | null = null;
|
||||||
const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
|
const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
|
||||||
const CHAT_VIEWER_ROW_HEIGHT = 29;
|
const CHAT_VIEWER_ROW_HEIGHT = 29;
|
||||||
const CHAT_VIEWER_OVERSCAN = 12;
|
const CHAT_VIEWER_OVERSCAN = 12;
|
||||||
@@ -465,6 +628,8 @@ const CHAT_VIEWER_OVERSCAN = 12;
|
|||||||
function disposeChatViewerVirtualList(): void {
|
function disposeChatViewerVirtualList(): void {
|
||||||
chatViewerVirtualList?.dispose();
|
chatViewerVirtualList?.dispose();
|
||||||
chatViewerVirtualList = null;
|
chatViewerVirtualList = null;
|
||||||
|
chatViewerListbox?.dispose();
|
||||||
|
chatViewerListbox = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||||
@@ -546,10 +711,17 @@ function onChatViewerFilterChange(): void {
|
|||||||
filterChunk();
|
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 row = document.createElement('div');
|
||||||
const isMessageType = m.type === 'msg' || !m.type;
|
const isMessageType = m.type === 'msg' || !m.type;
|
||||||
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
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) {
|
if (!isMessageType) {
|
||||||
const tag = document.createElement('span');
|
const tag = document.createElement('span');
|
||||||
@@ -578,9 +750,8 @@ function createChatViewerRow(m: ChatViewerMessage): HTMLElement {
|
|||||||
const message = document.createElement('span');
|
const message = document.createElement('span');
|
||||||
message.textContent = ' ' + (m.msg || m.text || '');
|
message.textContent = ' ' + (m.msg || m.text || '');
|
||||||
row.appendChild(message);
|
row.appendChild(message);
|
||||||
const accessibleLabel = [time, user, m.msg || m.text || ''].filter(Boolean).join(' ');
|
const accessibleLabel = getChatViewerAccessibleLabel(m);
|
||||||
row.title = accessibleLabel;
|
row.title = accessibleLabel;
|
||||||
row.setAttribute('tabindex', '0');
|
|
||||||
row.setAttribute('aria-label', accessibleLabel);
|
row.setAttribute('aria-label', accessibleLabel);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -598,15 +769,26 @@ function renderChatViewerList(messages: ChatViewerMessage[], generation: number)
|
|||||||
rows.className = 'chat-viewer-virtual-rows';
|
rows.className = 'chat-viewer-virtual-rows';
|
||||||
canvas.appendChild(rows);
|
canvas.appendChild(rows);
|
||||||
list.appendChild(canvas);
|
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, {
|
chatViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, {
|
||||||
itemCount: () => messages.length,
|
itemCount: () => messages.length,
|
||||||
rowHeight: CHAT_VIEWER_ROW_HEIGHT,
|
rowHeight: CHAT_VIEWER_ROW_HEIGHT,
|
||||||
overscan: CHAT_VIEWER_OVERSCAN,
|
overscan: CHAT_VIEWER_OVERSCAN,
|
||||||
render: (range) => {
|
render: (range) => {
|
||||||
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
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)`;
|
rows.style.transform = `translateY(${range.start * CHAT_VIEWER_ROW_HEIGHT}px)`;
|
||||||
const fragment = document.createDocumentFragment();
|
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);
|
rows.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+67
-2
@@ -4942,12 +4942,77 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-viewer-row:focus-visible,
|
.chat-viewer-row[aria-selected="true"],
|
||||||
.event-viewer-row:focus-visible {
|
.event-viewer-row[aria-selected="true"] {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
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 {
|
.chat-viewer-row .chat-viewer-time {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user