Files
Twitch-VOD-Manager/src/renderer-accessibility.ts
T
Sucukdeluxe 348c37cbdb fix(accessibility): stabilize virtual viewer interactions
Use cancellable, generation-aware reads and removable virtual scrolling for chat and event viewers. Constrain virtual rows to fixed single-line heights, clean busy state, stop Escape propagation, and cover dialog, menu, palette, and virtual list interactions.
2026-08-12 01:55:50 +02:00

232 lines
8.8 KiB
TypeScript

interface RendererDialogOptions {
initialFocus?: HTMLElement | string | null;
onEscape?: () => void;
}
interface RendererOpenDialogState {
returnFocus: HTMLElement | null;
onEscape?: () => void;
}
interface RendererFixedHeightVirtualListOptions {
itemCount: () => number;
rowHeight: number;
overscan: number;
render: (range: { start: number; end: number }) => void;
}
const RendererAccessibility = (() => {
const dialogStack: string[] = [];
const dialogStates = new Map<string, RendererOpenDialogState>();
const focusSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
class RenderGeneration {
private value = 0;
next(): number {
this.value += 1;
return this.value;
}
isCurrent(generation: number): boolean {
return generation === this.value;
}
cancel(): void {
this.next();
}
}
function getVirtualRange(scrollTop: number, viewportHeight: number, itemCount: number, rowHeight: number, overscan: number): { start: number; end: number } {
if (itemCount <= 0 || rowHeight <= 0) return { start: 0, end: 0 };
const firstVisible = Math.max(0, Math.floor(scrollTop / rowHeight));
const visibleCount = Math.max(1, Math.ceil(viewportHeight / rowHeight));
return {
start: Math.max(0, firstVisible - Math.max(0, overscan)),
end: Math.min(itemCount, firstVisible + visibleCount + Math.max(0, overscan))
};
}
function getNextFocusIndex(activeIndex: number, count: number, shiftKey: boolean): number {
if (count < 1) return -1;
if (shiftKey) return activeIndex <= 0 ? count - 1 : activeIndex - 1;
return activeIndex >= count - 1 ? 0 : activeIndex + 1;
}
function getNextMenuIndex(activeIndex: number, count: number, key: string): number | null {
if (count < 1 || key === 'Escape') return null;
if (key === 'Home') return 0;
if (key === 'End') return count - 1;
if (key === 'ArrowDown') return activeIndex >= count - 1 ? 0 : activeIndex + 1;
if (key === 'ArrowUp') return activeIndex <= 0 ? count - 1 : activeIndex - 1;
return activeIndex;
}
function setDocumentLanguage(language: string): string {
const normalized = language === 'en' ? 'en' : 'de';
document.documentElement.lang = normalized;
return normalized;
}
function setBusy(element: HTMLElement, busy: boolean): void {
if (busy) element.setAttribute('aria-busy', 'true');
else element.removeAttribute('aria-busy');
}
function createFixedHeightVirtualList(list: HTMLElement, options: RendererFixedHeightVirtualListOptions): { dispose(): void; render(): void } {
let disposed = false;
let scheduled = false;
const render = (): void => {
if (disposed) return;
options.render(getVirtualRange(list.scrollTop, list.clientHeight, options.itemCount(), options.rowHeight, options.overscan));
};
const onScroll = (): void => {
if (scheduled || disposed) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
render();
});
};
list.addEventListener('scroll', onScroll, { passive: true });
render();
return {
dispose: () => {
if (disposed) return;
disposed = true;
list.removeEventListener('scroll', onScroll);
},
render,
};
}
function getFocusable(dialog: HTMLElement): HTMLElement[] {
return Array.from(dialog.querySelectorAll<HTMLElement>(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true');
}
function getInitialFocus(dialog: HTMLElement, initialFocus: RendererDialogOptions['initialFocus']): HTMLElement | null {
if (typeof initialFocus === 'string') return dialog.querySelector<HTMLElement>(initialFocus);
if (initialFocus && dialog.contains(initialFocus)) return initialFocus;
return getFocusable(dialog)[0] ?? null;
}
function syncBackgroundInertness(): void {
const shell = document.querySelector<HTMLElement>('.workspace-shell');
if (shell) shell.inert = dialogStack.length > 0;
}
function isDialogOpen(id: string): boolean {
return dialogStack.includes(id);
}
function openDialog(id: string, options: RendererDialogOptions = {}): void {
const dialog = document.getElementById(id);
if (!(dialog instanceof HTMLElement)) return;
const existing = dialogStack.indexOf(id);
if (existing >= 0) {
dialogStack.splice(existing, 1);
} else {
const active = document.activeElement;
dialogStates.set(id, {
returnFocus: active instanceof HTMLElement && active !== document.body ? active : null,
onEscape: options.onEscape
});
}
dialogStack.push(id);
dialog.classList.add('show');
dialog.setAttribute('aria-hidden', 'false');
syncBackgroundInertness();
requestAnimationFrame(() => getInitialFocus(dialog, options.initialFocus)?.focus());
}
function closeDialog(id: string): void {
const dialog = document.getElementById(id);
if (!(dialog instanceof HTMLElement)) return;
const index = dialogStack.indexOf(id);
if (index < 0) return;
const wasTopmost = index === dialogStack.length - 1;
const state = dialogStates.get(id);
dialogStack.splice(index, 1);
dialogStates.delete(id);
dialog.classList.remove('show');
dialog.setAttribute('aria-hidden', 'true');
syncBackgroundInertness();
if (wasTopmost && state?.returnFocus?.isConnected) requestAnimationFrame(() => state.returnFocus?.focus());
}
function closeTopmostDialog(): boolean {
const id = dialogStack.at(-1);
if (!id) return false;
const state = dialogStates.get(id);
if (state?.onEscape) state.onEscape();
else closeDialog(id);
return true;
}
function installMenuKeyboardNavigation(menu: HTMLElement, close: () => void): void {
menu.addEventListener('keydown', (event) => {
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).filter((item) => item.getAttribute('aria-disabled') !== 'true' && !(item instanceof HTMLButtonElement && item.disabled));
const activeIndex = items.indexOf(document.activeElement as HTMLElement);
const nextIndex = getNextMenuIndex(activeIndex < 0 ? 0 : activeIndex, items.length, event.key);
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (nextIndex === activeIndex || nextIndex === null) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
event.preventDefault();
items[nextIndex]?.focus();
}
});
}
function focusFirstMenuItem(menu: HTMLElement): void {
menu.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"]):not([disabled])')?.focus();
}
document.addEventListener('keydown', (event) => {
const id = dialogStack.at(-1);
if (!id) return;
const dialog = document.getElementById(id);
if (!(dialog instanceof HTMLElement)) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
closeTopmostDialog();
return;
}
if (event.key !== 'Tab') return;
const focusable = getFocusable(dialog);
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const activeIndex = focusable.indexOf(document.activeElement as HTMLElement);
const nextIndex = getNextFocusIndex(activeIndex, focusable.length, event.shiftKey);
if (activeIndex < 0 || nextIndex !== activeIndex + (event.shiftKey ? -1 : 1)) {
event.preventDefault();
focusable[nextIndex]?.focus();
}
}, true);
return {
RenderGeneration,
getVirtualRange,
getNextFocusIndex,
getNextMenuIndex,
setDocumentLanguage,
setBusy,
createFixedHeightVirtualList,
isDialogOpen,
openDialog,
closeDialog,
closeTopmostDialog,
installMenuKeyboardNavigation,
focusFirstMenuItem,
};
})();
Object.assign(globalThis, { RendererAccessibility });