diff --git a/src/utils/accessibility.js b/src/utils/accessibility.js new file mode 100644 index 0000000..32d3451 --- /dev/null +++ b/src/utils/accessibility.js @@ -0,0 +1,228 @@ +/** + * Accessibility Utilities and Hooks + * Provides utilities for improved keyboard navigation, focus management, and screen reader support + */ + +import { useEffect, useRef } from 'react'; + +/** + * Hook to trap focus within a modal or dialog + * @param {boolean} isActive - Whether focus trap is active + */ +export const useFocusTrap = (isActive) => { + const containerRef = useRef(null); + + useEffect(() => { + if (!isActive || !containerRef.current) return; + + const container = containerRef.current; + const focusableElements = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + const handleTabKey = (e) => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + lastElement?.focus(); + e.preventDefault(); + } + } else { + if (document.activeElement === lastElement) { + firstElement?.focus(); + e.preventDefault(); + } + } + }; + + // Focus first element when trap activates + firstElement?.focus(); + + container.addEventListener('keydown', handleTabKey); + return () => container.removeEventListener('keydown', handleTabKey); + }, [isActive]); + + return containerRef; +}; + +/** + * Hook to handle ESC key for closing modals/menus + * @param {Function} onEscape - Callback when ESC is pressed + * @param {boolean} isActive - Whether the listener is active + */ +export const useEscapeKey = (onEscape, isActive = true) => { + useEffect(() => { + if (!isActive) return; + + const handleEscape = (e) => { + if (e.key === 'Escape') { + onEscape(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [onEscape, isActive]); +}; + +/** + * Hook to announce messages to screen readers + * @returns {Function} announce - Function to announce a message + */ +export const useScreenReaderAnnounce = () => { + const announcer = useRef(null); + + useEffect(() => { + // Create ARIA live region if it doesn't exist + if (!announcer.current) { + const element = document.createElement('div'); + element.setAttribute('role', 'status'); + element.setAttribute('aria-live', 'polite'); + element.setAttribute('aria-atomic', 'true'); + element.className = 'sr-only'; + document.body.appendChild(element); + announcer.current = element; + } + + return () => { + if (announcer.current) { + document.body.removeChild(announcer.current); + } + }; + }, []); + + const announce = (message, priority = 'polite') => { + if (announcer.current) { + announcer.current.setAttribute('aria-live', priority); + announcer.current.textContent = message; + + // Clear after announcement + setTimeout(() => { + if (announcer.current) { + announcer.current.textContent = ''; + } + }, 1000); + } + }; + + return announce; +}; + +/** + * Hook to manage focus on component mount + * @param {boolean} shouldFocus - Whether to focus on mount + */ +export const useAutoFocus = (shouldFocus = true) => { + const elementRef = useRef(null); + + useEffect(() => { + if (shouldFocus && elementRef.current) { + elementRef.current.focus(); + } + }, [shouldFocus]); + + return elementRef; +}; + +/** + * Component: VisuallyHidden + * Hides content visually but keeps it accessible to screen readers + */ +export const VisuallyHidden = ({ children, as: Component = 'span', ...props }) => { + return ( + + {children} + + ); +}; + +/** + * Component: SkipLink + * Allows keyboard users to skip navigation and go directly to main content + */ +export const SkipLink = ({ targetId = 'main-content', children = 'Skip to main content' }) => { + return ( + + {children} + + ); +}; + +/** + * Utility: Generate unique ID for ARIA relationships + */ +let idCounter = 0; +export const useId = (prefix = 'id') => { + const idRef = useRef(null); + + if (idRef.current === null) { + idRef.current = `${prefix}-${++idCounter}`; + } + + return idRef.current; +}; + +/** + * Utility: Check if reduced motion is preferred + */ +export const useReducedMotion = () => { + const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReducedMotion(mediaQuery.matches); + + const handleChange = (e) => setPrefersReducedMotion(e.matches); + mediaQuery.addEventListener('change', handleChange); + + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return prefersReducedMotion; +}; + +/** + * Higher-Order Component: Add keyboard navigation to clickable divs + */ +export const makeKeyboardAccessible = (Component) => { + return ({ onClick, ...props }) => { + const handleKeyPress = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(e); + } + }; + + return ( + + ); + }; +}; + +export default { + useFocusTrap, + useEscapeKey, + useScreenReaderAnnounce, + useAutoFocus, + useId, + useReducedMotion, + VisuallyHidden, + SkipLink, + makeKeyboardAccessible, +};