import { useEffect, useState, useRef, useCallback } from 'react'; /** * Accessibility utilities and hooks for WCAG 2.1 AA compliance */ // ==================== HOOKS ==================== /** * Detects if user prefers reduced motion */ export function usePrefersReducedMotion(): boolean { const [prefersReduced, setPrefersReduced] = useState(() => { if (typeof window === 'undefined') return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }); useEffect(() => { const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); mediaQuery.addEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler); }, []); return prefersReduced; } /** * Detects if user prefers high contrast */ export function usePrefersHighContrast(): boolean { const [prefersHighContrast, setPrefersHighContrast] = useState(() => { if (typeof window === 'undefined') return false; return window.matchMedia('(prefers-contrast: more)').matches; }); useEffect(() => { const mediaQuery = window.matchMedia('(prefers-contrast: more)'); const handler = (e: MediaQueryListEvent) => setPrefersHighContrast(e.matches); mediaQuery.addEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler); }, []); return prefersHighContrast; } /** * Manage focus trap for modals/dialogs */ export function useFocusTrap(isActive: boolean) { const containerRef = useRef(null); const previousFocusRef = useRef(null); useEffect(() => { if (!isActive) return; // Store current focus previousFocusRef.current = document.activeElement as HTMLElement; const container = containerRef.current; if (!container) return; // Get focusable elements const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const focusableElements = container.querySelectorAll(focusableSelector); const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; // Focus first element firstFocusable?.focus(); const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable?.focus(); } } else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable?.focus(); } } }; container.addEventListener('keydown', handleKeyDown); return () => { container.removeEventListener('keydown', handleKeyDown); // Restore focus on unmount previousFocusRef.current?.focus(); }; }, [isActive]); return containerRef; } /** * Announce message to screen readers */ export function useAnnounce() { const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => { const el = document.createElement('div'); el.setAttribute('role', 'status'); el.setAttribute('aria-live', priority); el.setAttribute('aria-atomic', 'true'); el.className = 'sr-only'; el.textContent = message; document.body.appendChild(el); // Remove after announcement setTimeout(() => document.body.removeChild(el), 1000); }, []); return announce; } /** * Handle roving tabindex for arrow key navigation */ export function useRovingTabIndex(itemCount: number) { const [activeIndex, setActiveIndex] = useState(0); const itemsRef = useRef<(T | null)[]>([]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { let newIndex = activeIndex; switch (e.key) { case 'ArrowDown': case 'ArrowRight': e.preventDefault(); newIndex = (activeIndex + 1) % itemCount; break; case 'ArrowUp': case 'ArrowLeft': e.preventDefault(); newIndex = (activeIndex - 1 + itemCount) % itemCount; break; case 'Home': e.preventDefault(); newIndex = 0; break; case 'End': e.preventDefault(); newIndex = itemCount - 1; break; default: return; } setActiveIndex(newIndex); itemsRef.current[newIndex]?.focus(); }, [activeIndex, itemCount]); const getItemProps = (index: number) => ({ ref: (el: T | null) => { itemsRef.current[index] = el; }, tabIndex: index === activeIndex ? 0 : -1, onKeyDown: handleKeyDown, }); return { activeIndex, setActiveIndex, getItemProps }; } // ==================== UTILITIES ==================== /** * Generate unique ID for ARIA relationships */ let idCounter = 0; export function generateAriaId(prefix: string = 'aria'): string { return `${prefix}-${++idCounter}`; } /** * Check if color contrast meets WCAG standards * Returns ratio (4.5:1 for AA normal text, 3:1 for large text) */ export function getContrastRatio(foreground: string, background: string): number { const getLuminance = (hex: string): number => { const rgb = parseInt(hex.slice(1), 16); const r = ((rgb >> 16) & 0xff) / 255; const g = ((rgb >> 8) & 0xff) / 255; const b = (rgb & 0xff) / 255; const toLinear = (c: number) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); }; const l1 = getLuminance(foreground); const l2 = getLuminance(background); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } /** * Format number for screen readers */ export function formatNumberForSR(num: number, type: 'currency' | 'percent' | 'decimal' = 'decimal'): string { switch (type) { case 'currency': return `${num.toFixed(2)} dollars`; case 'percent': return `${num} percent`; default: return num.toLocaleString(); } } // ==================== COMPONENTS ==================== /** * Screen reader only text */ export function VisuallyHidden({ children }: { children: React.ReactNode }) { return ( {children} ); } /** * Skip link for keyboard navigation */ export function SkipLink({ href, children }: { href: string; children: React.ReactNode }) { return ( {children} ); } /** * Live region for dynamic announcements */ export function LiveRegion({ message, priority = 'polite' }: { message: string; priority?: 'polite' | 'assertive' }) { return (
{message}
); }