'use client'; import { useEffect, useRef, type ReactNode, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; interface ModalProps { title: string; description?: string; children?: ReactNode; primaryActionLabel?: string; secondaryActionLabel?: string; onPrimaryAction?: () => void | Promise; onSecondaryAction?: () => void; onOpenChange?: (open: boolean) => void; isOpen: boolean; } /** * Generic, accessible modal component with backdrop, focus management, and semantic structure. * Controlled via the `isOpen` prop; callers handle URL state and routing. */ export default function Modal({ title, description, children, primaryActionLabel, secondaryActionLabel, onPrimaryAction, onSecondaryAction, onOpenChange, isOpen, }: ModalProps) { const dialogRef = useRef(null); const previouslyFocusedElementRef = useRef(null); // When the modal opens, remember previous focus and move focus into the dialog useEffect(() => { if (isOpen) { previouslyFocusedElementRef.current = document.activeElement; const focusable = getFirstFocusable(dialogRef.current); if (focusable) { focusable.focus(); } else if (dialogRef.current) { dialogRef.current.focus(); } return; } // When closing, restore focus if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) { previouslyFocusedElementRef.current.focus(); } }, [isOpen]); // Basic focus trap with keyboard handling (Tab / Shift+Tab, Escape) const handleKeyDown = (event: ReactKeyboardEvent) => { if (event.key === 'Escape') { if (onOpenChange) { onOpenChange(false); } return; } if (event.key === 'Tab') { const focusable = getFocusableElements(dialogRef.current); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1] ?? first; if (!first || !last) { return; } if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } } }; const handleBackdropClick = (event: React.MouseEvent) => { if (event.target === event.currentTarget && onOpenChange) { onOpenChange(false); } }; if (!isOpen) { return null; } return (
{description && ( )}
{children}
{(primaryActionLabel || secondaryActionLabel) && (
{secondaryActionLabel && ( )} {primaryActionLabel && ( )}
)}
); } function getFocusableElements(root: HTMLElement | null): HTMLElement[] { if (!root) return []; const selectors = [ 'a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', ]; const nodes = Array.from( root.querySelectorAll(selectors.join(',')), ); return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')); } function getFirstFocusable(root: HTMLElement | null): HTMLElement | null { const elements = getFocusableElements(root); return elements[0] ?? null; }