export type AlertA11yProps = { role: 'alert'; 'aria-live': 'assertive' | 'polite'; 'aria-label': string; }; export function alertLabel(level: string, description: string): AlertA11yProps { return { role: 'alert', 'aria-live': level === 'danger' ? 'assertive' : 'polite', 'aria-label': description, }; } 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; 'aria-valuenow': number; 'aria-valuemin': number; 'aria-valuemax': number; 'aria-valuetext': string; }; export function progressLabel( label: string, valuePct: number, description: string ): ProgressbarA11yProps { return { role: 'progressbar', 'aria-label': label, 'aria-valuenow': valuePct, 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuetext': description, }; } export type AriaLabelOnly = { 'aria-label': string }; export function streakLabel(days: number): AriaLabelOnly { return { 'aria-label': `${days} day streak` }; } export type ButtonA11yProps = { 'aria-label': string; 'aria-roledescription'?: string; }; export function buttonLabel(text: string, hint?: string): ButtonA11yProps { return { 'aria-label': text, ...(hint ? { 'aria-roledescription': hint } : {}), }; } export function achievementLabel(name: string, description: string): AriaLabelOnly { return { 'aria-label': `Achievement: ${name} — ${description}` }; } export type TimerA11yProps = { 'aria-label': string; 'aria-live': 'polite'; }; export function timerLabel( hours: number, minutes: number, seconds: number, status: string ): TimerA11yProps { return { 'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`, 'aria-live': 'polite', }; } export type SliderA11yProps = { role: 'slider'; 'aria-label': string; 'aria-valuenow': number; 'aria-valuemin': number; 'aria-valuemax': number; }; export function sliderLabel( label: string, value: number, min: number, max: number ): SliderA11yProps { return { role: 'slider', 'aria-label': label, 'aria-valuenow': value, 'aria-valuemin': min, 'aria-valuemax': max, }; } function plural(n: number, singular: string, pluralForm: string): string { const word = n === 1 ? singular : pluralForm; return `${n} ${word}`; } /** * Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes". */ export function formatDurationForA11y(hours: number): string { const totalMinutes = Math.round(hours * 60); const h = Math.floor(totalMinutes / 60); const m = totalMinutes % 60; if (h === 0 && m === 0) { return '0 minutes'; } if (m === 0) { return plural(h, 'hour', 'hours'); } if (h === 0) { return plural(m, 'minute', 'minutes'); } return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`; }