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(); 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(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true'); } function getInitialFocus(dialog: HTMLElement, initialFocus: RendererDialogOptions['initialFocus']): HTMLElement | null { if (typeof initialFocus === 'string') return dialog.querySelector(initialFocus); if (initialFocus && dialog.contains(initialFocus)) return initialFocus; return getFocusable(dialog)[0] ?? null; } function syncBackgroundInertness(): void { const shell = document.querySelector('.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('[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('[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 });