From 42f60cb286fbad2c3368108dd09570b233907b8a Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Tue, 5 May 2026 01:15:38 -0700 Subject: [PATCH] feat(accessibility): add focus trap helpers --- packages/accessibility/src/index.ts | 86 +++++++++++++++++++++++++++- packages/accessibility/tsconfig.json | 2 +- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/accessibility/src/index.ts b/packages/accessibility/src/index.ts index 1742feac..1d760ab9 100644 --- a/packages/accessibility/src/index.ts +++ b/packages/accessibility/src/index.ts @@ -12,6 +12,86 @@ export function alertLabel(level: string, description: string): AlertA11yProps { }; } +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + element => + !element.hasAttribute('disabled') && + element.getAttribute('aria-hidden') !== 'true' && + element.offsetParent !== null + ); +} + +export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void { + if (event.key !== 'Tab') return; + + const focusable = getFocusableElements(container); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void { + const preferred = container.querySelector(selector); + const fallback = getFocusableElements(container)[0]; + (preferred ?? fallback)?.focus(); +} + +export type ScreenReaderPoliteness = 'assertive' | 'polite'; + +export function announceToScreenReader( + message: string, + politeness: ScreenReaderPoliteness = 'polite' +): void { + if (typeof document === 'undefined') return; + + const id = `bytelyst-sr-announcer-${politeness}`; + let announcer = document.getElementById(id); + if (!announcer) { + announcer = document.createElement('div'); + announcer.id = id; + announcer.setAttribute('aria-live', politeness); + announcer.setAttribute('aria-atomic', 'true'); + announcer.style.position = 'absolute'; + announcer.style.width = '1px'; + announcer.style.height = '1px'; + announcer.style.margin = '-1px'; + announcer.style.padding = '0'; + announcer.style.overflow = 'hidden'; + announcer.style.clip = 'rect(0 0 0 0)'; + announcer.style.whiteSpace = 'nowrap'; + announcer.style.border = '0'; + document.body.appendChild(announcer); + } + + announcer.textContent = ''; + window.setTimeout(() => { + if (announcer) { + announcer.textContent = message; + } + }, 10); +} + export type ProgressbarA11yProps = { role: 'progressbar'; 'aria-label': string; @@ -24,7 +104,7 @@ export type ProgressbarA11yProps = { export function progressLabel( label: string, valuePct: number, - description: string, + description: string ): ProgressbarA11yProps { return { role: 'progressbar', @@ -67,7 +147,7 @@ export function timerLabel( hours: number, minutes: number, seconds: number, - status: string, + status: string ): TimerA11yProps { return { 'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`, @@ -87,7 +167,7 @@ export function sliderLabel( label: string, value: number, min: number, - max: number, + max: number ): SliderA11yProps { return { role: 'slider', diff --git a/packages/accessibility/tsconfig.json b/packages/accessibility/tsconfig.json index 60eaca37..5626e4b9 100644 --- a/packages/accessibility/tsconfig.json +++ b/packages/accessibility/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "strict": true, "skipLibCheck": true, "esModuleInterop": true,