feat(accessibility): add focus trap helpers

This commit is contained in:
OpenAI Codex 2026-05-05 01:15:38 -07:00
parent c49fb7977d
commit 42f60cb286
2 changed files with 84 additions and 4 deletions

View File

@ -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<HTMLElement>(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<HTMLElement>(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',

View File

@ -3,7 +3,7 @@
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,