diff --git a/src/components/accessibility/RouteChangeAnnouncer.tsx b/src/components/accessibility/RouteChangeAnnouncer.tsx new file mode 100644 index 00000000..4073c5ec --- /dev/null +++ b/src/components/accessibility/RouteChangeAnnouncer.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useFocusOnRouteChange } from '@/hooks/useAccessibility'; + +/** + * Manages focus on route changes by moving focus to the main content landmark. + * Render once inside RootProviders (client boundary). + */ +export function RouteChangeAnnouncer() { + const pathname = usePathname(); + useFocusOnRouteChange(pathname); + return null; +} diff --git a/src/components/index.ts b/src/components/index.ts index 1430e5b1..a7608f0f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ * Import from '@/components' rather than '@/app/components' for shared pieces. */ +export * from './ui/Modal'; export * from './ui/Toast'; export * from './shared/EnvGuard'; export * from './errors/ErrorBoundarySystem'; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 00000000..dd7903f8 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useEffect, useId } from 'react'; +import { X } from 'lucide-react'; +import { useFocusTrap, useScreenReaderAnnouncement } from '@/hooks/useAccessibility'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + /** Additional class names for the inner panel */ + className?: string; +} + +/** + * Accessible modal dialog with focus trap, Escape-to-close, and screen reader announcements. + * Uses the existing `useFocusTrap` hook from `useAccessibility`. + */ +export function Modal({ isOpen, onClose, title, children, className = '' }: ModalProps) { + const titleId = useId(); + const containerRef = useFocusTrap(isOpen); + const announce = useScreenReaderAnnouncement(); + + // Announce open/close and lock body scroll + useEffect(() => { + if (isOpen) { + announce(`${title} dialog opened`, 'polite'); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen, title, announce]); + + // Escape key closes the modal + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +