test(accessibility): cover real renderer interaction paths
Exercise delegated queue and VOD context menus plus chat and event viewer cancellation through compiled renderer code. Make truncated virtual rows keyboard-focusable with complete accessible labels and visible focus feedback.
This commit is contained in:
@@ -0,0 +1,438 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type Listener = (event: FakeEvent) => void;
|
||||||
|
|
||||||
|
class FakeEvent {
|
||||||
|
defaultPrevented = false;
|
||||||
|
propagationStopped = false;
|
||||||
|
immediatePropagationStopped = false;
|
||||||
|
target: FakeElement | null;
|
||||||
|
readonly key: string;
|
||||||
|
readonly shiftKey: boolean;
|
||||||
|
readonly clientX: number;
|
||||||
|
readonly clientY: number;
|
||||||
|
|
||||||
|
constructor(readonly type: string, options: { target?: FakeElement; key?: string; shiftKey?: boolean; clientX?: number; clientY?: number } = {}) {
|
||||||
|
this.target = options.target ?? null;
|
||||||
|
this.key = options.key ?? '';
|
||||||
|
this.shiftKey = options.shiftKey ?? false;
|
||||||
|
this.clientX = options.clientX ?? 24;
|
||||||
|
this.clientY = options.clientY ?? 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefault(): void {
|
||||||
|
this.defaultPrevented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPropagation(): void {
|
||||||
|
this.propagationStopped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopImmediatePropagation(): void {
|
||||||
|
this.immediatePropagationStopped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeMouseEvent extends FakeEvent { }
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
private readonly values = new Set<string>();
|
||||||
|
|
||||||
|
add(...tokens: string[]): void {
|
||||||
|
tokens.forEach((token) => this.values.add(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(...tokens: string[]): void {
|
||||||
|
tokens.forEach((token) => this.values.delete(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(token: string): boolean {
|
||||||
|
return this.values.has(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(token: string, force?: boolean): boolean {
|
||||||
|
const shouldAdd = force ?? !this.values.has(token);
|
||||||
|
if (shouldAdd) this.values.add(token);
|
||||||
|
else this.values.delete(token);
|
||||||
|
return shouldAdd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
id = '';
|
||||||
|
className = '';
|
||||||
|
textContent = '';
|
||||||
|
value = '';
|
||||||
|
tabIndex = -1;
|
||||||
|
inert = false;
|
||||||
|
disabled = false;
|
||||||
|
isConnected = true;
|
||||||
|
clientHeight = 58;
|
||||||
|
scrollTop = 0;
|
||||||
|
parentElement: FakeElement | null = null;
|
||||||
|
readonly children: FakeElement[] = [];
|
||||||
|
readonly classList = new FakeClassList();
|
||||||
|
readonly attributes = new Map<string, string>();
|
||||||
|
readonly dataset: Record<string, string> = {};
|
||||||
|
readonly style: Record<string, string> & { setProperty(name: string, value: string): void } = Object.assign(Object.create(null), {
|
||||||
|
setProperty(this: Record<string, string>, name: string, value: string): void { this[name] = value; }
|
||||||
|
});
|
||||||
|
private readonly listeners = new Map<string, Listener[]>();
|
||||||
|
|
||||||
|
constructor(readonly tagName: string, protected readonly document: FakeDocument) { }
|
||||||
|
|
||||||
|
get firstChild(): FakeElement | null {
|
||||||
|
return this.children[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child: FakeElement): FakeElement {
|
||||||
|
if (child.tagName === 'fragment') {
|
||||||
|
child.children.slice().forEach((entry) => this.appendChild(entry));
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
child.parentElement = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
append(...children: FakeElement[]): void {
|
||||||
|
children.forEach((child) => this.appendChild(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceChildren(...children: FakeElement[]): void {
|
||||||
|
this.children.splice(0).forEach((child) => { child.parentElement = null; });
|
||||||
|
children.forEach((child) => this.appendChild(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(): void {
|
||||||
|
if (this.parentElement) {
|
||||||
|
const index = this.parentElement.children.indexOf(this);
|
||||||
|
if (index >= 0) this.parentElement.children.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.parentElement = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(node: FakeElement): boolean {
|
||||||
|
return node === this || this.children.some((child) => child.contains(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
closest(selector: string): FakeElement | null {
|
||||||
|
if (this.matches(selector)) return this;
|
||||||
|
return this.parentElement?.closest(selector) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(selector: string): boolean {
|
||||||
|
if (selector.startsWith('.')) return this.className.split(/\s+/).includes(selector.slice(1)) || this.classList.contains(selector.slice(1));
|
||||||
|
if (selector.startsWith('#')) return this.id === selector.slice(1);
|
||||||
|
if (selector === 'button') return this.tagName === 'button';
|
||||||
|
if (selector === 'input') return this.tagName === 'input';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBoundingClientRect(): { left: number; top: number; width: number; height: number } {
|
||||||
|
return { left: 24, top: 24, width: 140, height: 36 };
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name: string, value: string): void {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
if (name === 'tabindex') this.tabIndex = Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name: string): string | null {
|
||||||
|
return this.attributes.get(name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttribute(name: string): void {
|
||||||
|
this.attributes.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: Listener): void {
|
||||||
|
const listeners = this.listeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: Listener): void {
|
||||||
|
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: FakeEvent): void {
|
||||||
|
if (!event.target) event.target = this;
|
||||||
|
for (const listener of this.listeners.get(event.type) ?? []) {
|
||||||
|
if (event.immediatePropagationStopped) break;
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.document.activeElement = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector<T extends FakeElement = FakeElement>(selector: string): T | null {
|
||||||
|
return this.querySelectorAll<T>(selector)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll<T extends FakeElement = FakeElement>(selector: string): T[] {
|
||||||
|
const descendants = this.descendants();
|
||||||
|
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.matches(selector)) as T[];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private descendants(): FakeElement[] {
|
||||||
|
return this.children.flatMap((child) => [child, ...child.descendants()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeButton extends FakeElement {
|
||||||
|
type = 'button';
|
||||||
|
|
||||||
|
constructor(document: FakeDocument) {
|
||||||
|
super('button', document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
readonly documentElement = { lang: '' };
|
||||||
|
readonly body: FakeElement;
|
||||||
|
readonly elements = new Map<string, FakeElement>();
|
||||||
|
activeElement: FakeElement;
|
||||||
|
hidden = false;
|
||||||
|
private readonly listeners = new Map<string, Listener[]>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.body = new FakeElement('body', this);
|
||||||
|
this.activeElement = this.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
createElement(tagName: string): FakeElement {
|
||||||
|
if (tagName === 'button') return new FakeButton(this);
|
||||||
|
return new FakeElement(tagName, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDocumentFragment(): FakeElement {
|
||||||
|
return new FakeElement('fragment', this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementById(id: string): FakeElement | null {
|
||||||
|
return this.elements.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector: string): FakeElement | null {
|
||||||
|
if (selector === '.workspace-shell') return this.elements.get('workspace-shell') ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: Listener): void {
|
||||||
|
const listeners = this.listeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: Listener): void {
|
||||||
|
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
register(element: FakeElement, id: string): FakeElement {
|
||||||
|
element.id = id;
|
||||||
|
this.elements.set(id, element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Runtime {
|
||||||
|
document: FakeDocument;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
scheduled: Array<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transpile(source: string): string {
|
||||||
|
return transpileModule(source, { compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } }).outputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFragment(fileName: string, start: string, end: string): string {
|
||||||
|
const source = readFileSync(join(__dirname, fileName), 'utf8');
|
||||||
|
const from = source.indexOf(start);
|
||||||
|
const to = source.indexOf(end, from);
|
||||||
|
if (from < 0 || to < 0) throw new Error(`Missing test fragment markers in ${fileName}`);
|
||||||
|
return source.slice(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntime(): Runtime {
|
||||||
|
const document = new FakeDocument();
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
document,
|
||||||
|
HTMLElement: FakeElement,
|
||||||
|
HTMLButtonElement: FakeButton,
|
||||||
|
HTMLDivElement: FakeElement,
|
||||||
|
HTMLInputElement: FakeElement,
|
||||||
|
Element: FakeElement,
|
||||||
|
Node: FakeElement,
|
||||||
|
MouseEvent: FakeMouseEvent,
|
||||||
|
PointerEvent: FakeEvent,
|
||||||
|
AbortController,
|
||||||
|
requestAnimationFrame: (callback: () => void) => callback(),
|
||||||
|
setTimeout: (callback: () => void) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length;
|
||||||
|
},
|
||||||
|
clearTimeout: () => undefined,
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
innerWidth: 1280,
|
||||||
|
innerHeight: 720,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
navigator: { clipboard: { writeText: () => Promise.resolve() } },
|
||||||
|
localStorage: { getItem: () => null, setItem: () => undefined },
|
||||||
|
UI_TEXT: {
|
||||||
|
queue: {
|
||||||
|
ctxCopyUrl: 'Copy URL', ctxOpenOnTwitch: 'Open', ctxRemove: 'Remove',
|
||||||
|
viewChatLoading: 'Loading', viewChat: 'Chat', viewChatFailed: 'Failed', viewChatCount: '{count} messages', viewChatTruncatedSuffix: ' truncated',
|
||||||
|
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' }
|
||||||
|
},
|
||||||
|
config: { downloaded_vod_ids: [] },
|
||||||
|
currentLanguage: 'en',
|
||||||
|
queue: [],
|
||||||
|
selectedQueueIds: [],
|
||||||
|
};
|
||||||
|
context.window = context;
|
||||||
|
context.byId = (id: string) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) throw new Error(`Missing element ${id}`);
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
runInNewContext(transpile(readFileSync(join(__dirname, 'renderer-accessibility.ts'), 'utf8')), context);
|
||||||
|
return { document, context, scheduled };
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(runtime: Runtime, source: string, expose: string): Record<string, (...args: unknown[]) => unknown> {
|
||||||
|
runInNewContext(transpile(`${source}\nObject.assign(globalThis, { __productionPath: { ${expose} } });`), runtime.context);
|
||||||
|
return (runtime.context as unknown as { __productionPath: Record<string, (...args: unknown[]) => unknown> }).__productionPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenu(document: FakeDocument): FakeElement {
|
||||||
|
const menu = document.body.children.find((element) => element.getAttribute('role') === 'menu');
|
||||||
|
if (!menu) throw new Error('Menu was not created');
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderer production interaction paths', () => {
|
||||||
|
it('opens queue ContextMenu and Shift+F10 menus through the delegated renderer path and restores focus', () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const list = runtime.document.register(new FakeElement('div', runtime.document), 'queueList');
|
||||||
|
const queueItem = new FakeElement('div', runtime.document);
|
||||||
|
queueItem.className = 'queue-item';
|
||||||
|
queueItem.dataset.id = 'queue-1';
|
||||||
|
list.appendChild(queueItem);
|
||||||
|
runtime.context.queue = [{ id: 'queue-1', status: 'completed', outputFiles: [], url: 'https://twitch.example/vod' }];
|
||||||
|
const api = evaluate(runtime, sourceFragment('renderer-queue.ts', 'let queueContextMenuInitialized', 'async function moveQueueItemTo'), 'initQueueContextMenu');
|
||||||
|
|
||||||
|
api.initQueueContextMenu();
|
||||||
|
queueItem.focus();
|
||||||
|
list.dispatch(new FakeEvent('keydown', { target: queueItem, key: 'ContextMenu' }));
|
||||||
|
let menu = findMenu(runtime.document);
|
||||||
|
expect(runtime.document.activeElement).toBe(menu.querySelector('[role="menuitem"]'));
|
||||||
|
menu.dispatch(new FakeEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(runtime.document.activeElement).toBe(queueItem);
|
||||||
|
|
||||||
|
list.dispatch(new FakeEvent('keydown', { target: queueItem, key: 'F10', shiftKey: true }));
|
||||||
|
menu = findMenu(runtime.document);
|
||||||
|
expect(runtime.document.activeElement).toBe(menu.querySelector('[role="menuitem"]'));
|
||||||
|
menu.dispatch(new FakeEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(runtime.document.activeElement).toBe(queueItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens VOD ContextMenu and Shift+F10 menus through renderer-streamers and restores focus', () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const grid = runtime.document.register(new FakeElement('div', runtime.document), 'vodGrid');
|
||||||
|
const card = new FakeElement('article', runtime.document);
|
||||||
|
card.className = 'vod-card';
|
||||||
|
Object.assign(card.dataset, { vodId: 'vod-1', vodUrl: 'https://twitch.example/vod', vodTitle: 'Title', vodDate: '2026-08-12', vodStreamer: 'streamer', vodDuration: '01:00:00' });
|
||||||
|
grid.appendChild(card);
|
||||||
|
const api = evaluate(runtime, sourceFragment('renderer-streamers.ts', 'let vodGridDelegationInitialized', 'async function toggleVodDownloadedMark'), 'initVodGridSelectionDelegation');
|
||||||
|
|
||||||
|
api.initVodGridSelectionDelegation();
|
||||||
|
card.focus();
|
||||||
|
grid.dispatch(new FakeEvent('contextmenu', { target: card }));
|
||||||
|
let menu = findMenu(runtime.document);
|
||||||
|
expect(runtime.document.activeElement).toBe(menu.querySelector('[role="menuitem"]'));
|
||||||
|
menu.dispatch(new FakeEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(runtime.document.activeElement).toBe(card);
|
||||||
|
|
||||||
|
grid.dispatch(new FakeEvent('keydown', { target: card, key: 'F10', shiftKey: true }));
|
||||||
|
menu = findMenu(runtime.document);
|
||||||
|
expect(runtime.document.activeElement).toBe(menu.querySelector('[role="menuitem"]'));
|
||||||
|
menu.dispatch(new FakeEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(runtime.document.activeElement).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels real chat and events viewer reads, rejects stale filters, and exposes clipped rows to keyboard users', async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.document.register(new FakeElement('main', runtime.document), 'workspace-shell');
|
||||||
|
const chatModal = runtime.document.register(new FakeElement('div', runtime.document), 'chatViewerModal');
|
||||||
|
const chatList = runtime.document.register(new FakeElement('div', runtime.document), 'chatViewerList');
|
||||||
|
const chatFilter = runtime.document.register(new FakeElement('input', runtime.document), 'chatViewerFilter');
|
||||||
|
const chatStatus = runtime.document.register(new FakeElement('div', runtime.document), 'chatViewerStatus');
|
||||||
|
runtime.document.register(new FakeElement('div', runtime.document), 'chatViewerTitle');
|
||||||
|
chatModal.append(chatFilter, chatList, chatStatus);
|
||||||
|
const eventsModal = runtime.document.register(new FakeElement('div', runtime.document), 'eventsViewerModal');
|
||||||
|
const eventsList = runtime.document.register(new FakeElement('div', runtime.document), 'eventsViewerList');
|
||||||
|
const eventsStatus = runtime.document.register(new FakeElement('div', runtime.document), 'eventsViewerStatus');
|
||||||
|
runtime.document.register(new FakeElement('div', runtime.document), 'eventsViewerTitle');
|
||||||
|
eventsModal.append(eventsList, eventsStatus);
|
||||||
|
const reads: Array<{ path: string; signal: AbortSignal }> = [];
|
||||||
|
const deferred = {} as { resolve(value: { success: boolean; cancelled?: boolean; messages?: Array<Record<string, unknown>> }): void };
|
||||||
|
const pendingResult = new Promise<{ success: boolean; cancelled?: boolean; messages?: Array<Record<string, unknown>> }>((resolve) => {
|
||||||
|
deferred.resolve = resolve;
|
||||||
|
});
|
||||||
|
runtime.context.api = {
|
||||||
|
readChatFile: (path: string, signal: AbortSignal) => {
|
||||||
|
reads.push({ path, signal });
|
||||||
|
if (path === 'events-pending') {
|
||||||
|
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' }] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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');
|
||||||
|
|
||||||
|
let clock = 0;
|
||||||
|
runtime.context.performance = { now: () => (clock++ === 0 ? 0 : 9) };
|
||||||
|
chatFilter.value = 'viewer';
|
||||||
|
api.onChatViewerFilterChange();
|
||||||
|
expect(chatList.getAttribute('aria-busy')).toBe('true');
|
||||||
|
api.closeChatViewer();
|
||||||
|
expect(reads[0].signal.aborted).toBe(true);
|
||||||
|
expect(chatList.getAttribute('aria-busy')).toBeNull();
|
||||||
|
runtime.scheduled.splice(0).forEach((callback) => callback());
|
||||||
|
expect(chatList.children).toHaveLength(0);
|
||||||
|
|
||||||
|
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');
|
||||||
|
const pending = api.openEventsViewer('events-pending', 'Events');
|
||||||
|
api.closeEventsViewer();
|
||||||
|
expect(reads.at(-1)?.signal.aborted).toBe(true);
|
||||||
|
expect(eventsList.getAttribute('aria-busy')).toBeNull();
|
||||||
|
deferred.resolve({ success: false, cancelled: true });
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-2
@@ -396,7 +396,10 @@ 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);
|
||||||
row.title = `${time.textContent} ${tag.textContent} ${detail.textContent}`.trim();
|
const accessibleLabel = `${time.textContent} ${tag.textContent} ${detail.textContent}`.trim();
|
||||||
|
row.title = accessibleLabel;
|
||||||
|
row.setAttribute('tabindex', '0');
|
||||||
|
row.setAttribute('aria-label', accessibleLabel);
|
||||||
row.append(time, tag, detail);
|
row.append(time, tag, detail);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -575,7 +578,10 @@ 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);
|
||||||
row.title = [time, user, m.msg || m.text || ''].filter(Boolean).join(' ');
|
const accessibleLabel = [time, user, m.msg || m.text || ''].filter(Boolean).join(' ');
|
||||||
|
row.title = accessibleLabel;
|
||||||
|
row.setAttribute('tabindex', '0');
|
||||||
|
row.setAttribute('aria-label', accessibleLabel);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4942,6 +4942,12 @@ 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,
|
||||||
|
.event-viewer-row:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.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