/** * Accessibility Utility Functions * * Helper functions for implementing accessibility features across the app. */ /** * Announce a message to screen readers * * Creates a visually hidden element with aria-live attribute to announce * messages to screen reader users without visual interruption. * * @param message - The message to announce * @param priority - 'polite' (wait for pause) or 'assertive' (interrupt immediately) */ export function announceToScreenReader( message: string, priority: 'polite' | 'assertive' = 'polite', ): void { const announcement = document.createElement('div'); announcement.setAttribute('role', 'status'); announcement.setAttribute('aria-live', priority); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only'; announcement.textContent = message; announcement.style.position = 'absolute'; announcement.style.left = '-10000px'; announcement.style.width = '1px'; announcement.style.height = '1px'; announcement.style.overflow = 'hidden'; document.body.appendChild(announcement); // Remove after screen reader has had time to announce setTimeout(() => { if (document.body.contains(announcement)) { document.body.removeChild(announcement); } }, 1000); } /** * Check if user prefers reduced motion * * Returns true if the user has enabled "reduce motion" in their system preferences. * Use this to disable or minimize animations for users with vestibular disorders. */ export function prefersReducedMotion(): boolean { if (typeof window === 'undefined') return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } /** * Trap focus within an element * * Useful for modals and dialogs to ensure keyboard users can't * tab out of the modal and into background content. * * @param element - The container element to trap focus within * @returns Cleanup function to remove the focus trap */ export function trapFocus(element: HTMLElement): () => void { const focusableElements = element.querySelectorAll( 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; if (e.shiftKey) { // Shift + Tab: moving backwards if (document.activeElement === firstElement) { e.preventDefault(); lastElement?.focus(); } } else { // Tab: moving forwards if (document.activeElement === lastElement) { e.preventDefault(); firstElement?.focus(); } } }; element.addEventListener('keydown', handleKeyDown); // Focus the first element firstElement?.focus(); // Return cleanup function return () => { element.removeEventListener('keydown', handleKeyDown); }; } /** * Get all focusable elements within a container * * @param container - The container to search within * @returns Array of focusable HTML elements */ export function getFocusableElements( container: HTMLElement, ): HTMLElement[] { const selector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; return Array.from(container.querySelectorAll(selector)); } /** * Calculate relative luminance of a color * * Used for checking color contrast ratios per WCAG guidelines. * * @param rgb - RGB color values [r, g, b] (0-255) * @returns Relative luminance value (0-1) */ function getRelativeLuminance(rgb: [number, number, number]): number { const [r, g, b] = rgb.map((value) => { const sRGB = value / 255; return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** * Parse hex color to RGB * * @param hex - Hex color string (#RRGGBB or #RGB) * @returns RGB values [r, g, b] */ function hexToRgb(hex: string): [number, number, number] | null { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) { // Try 3-digit hex const shortResult = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(hex); if (!shortResult) return null; return [ parseInt(shortResult[1] + shortResult[1], 16), parseInt(shortResult[2] + shortResult[2], 16), parseInt(shortResult[3] + shortResult[3], 16), ]; } return [ parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16), ]; } /** * Get contrast ratio between two colors * * WCAG requirements: * - Normal text: 4.5:1 minimum (AA), 7:1 recommended (AAA) * - Large text (18pt+ or 14pt+ bold): 3:1 minimum (AA), 4.5:1 recommended (AAA) * * @param color1 - First color (hex format) * @param color2 - Second color (hex format) * @returns Contrast ratio (1-21) */ export function getContrastRatio(color1: string, color2: string): number | null { const rgb1 = hexToRgb(color1); const rgb2 = hexToRgb(color2); if (!rgb1 || !rgb2) return null; const l1 = getRelativeLuminance(rgb1); const l2 = getRelativeLuminance(rgb2); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } /** * Check if color contrast meets WCAG AA standards * * @param foreground - Foreground color (hex) * @param background - Background color (hex) * @param isLargeText - Whether the text is large (18pt+ or 14pt+ bold) * @returns Object with pass/fail status and actual ratio */ export function meetsContrastRequirements( foreground: string, background: string, isLargeText: boolean = false, ): { passes: boolean; ratio: number | null; required: number } { const ratio = getContrastRatio(foreground, background); const required = isLargeText ? 3 : 4.5; return { passes: ratio !== null && ratio >= required, ratio, required, }; } /** * Generate a unique ID for accessibility attributes * * Useful for linking labels to inputs, or descriptions to elements. * * @param prefix - Optional prefix for the ID * @returns Unique ID string */ export function generateA11yId(prefix: string = 'a11y'): string { return `${prefix}-${Math.random().toString(36).substr(2, 9)}`; } /** * Check if an element is visible and focusable * * @param element - The element to check * @returns true if element is visible and can receive focus */ export function isElementFocusable(element: HTMLElement): boolean { if (!element) return false; // Check if element is hidden if (element.offsetParent === null) return false; if (window.getComputedStyle(element).visibility === 'hidden') return false; if (window.getComputedStyle(element).display === 'none') return false; // Check if element can receive focus const tabindex = element.getAttribute('tabindex'); if (tabindex && parseInt(tabindex) < 0) return false; return true; } /** * Focus an element with optional scroll behavior * * @param element - Element to focus * @param scrollIntoView - Whether to scroll element into view */ export function focusElement( element: HTMLElement | null, scrollIntoView: boolean = true, ): void { if (!element) return; element.focus({ preventScroll: !scrollIntoView }); if (scrollIntoView) { element.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'nearest', }); } }