feat(accessibility): harden chat and keyboard interactions
Stream capability-authorized chat reads with cancellation, virtualize viewer rendering, centralize modal focus management, and add keyboard-accessible queue and VOD menus. Expose command palette combobox state and keep the document language synchronized with the selected locale.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
interface RendererDialogOptions {
|
||||
initialFocus?: HTMLElement | string | null;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
interface RendererOpenDialogState {
|
||||
returnFocus: HTMLElement | null;
|
||||
onEscape?: () => 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 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();
|
||||
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,
|
||||
isDialogOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
closeTopmostDialog,
|
||||
installMenuKeyboardNavigation,
|
||||
focusFirstMenuItem,
|
||||
};
|
||||
})();
|
||||
|
||||
Object.assign(globalThis, { RendererAccessibility });
|
||||
Reference in New Issue
Block a user