import { useEffect, useRef, type ReactNode } from 'react'; import { Button } from '@/components/ui/Button'; import { SectionHeader } from '@/components/ui/SectionHeader'; import { type Accent } from '@/lib/cn'; type ModalSize = 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'; const SIZE_CLASS: Record = { md: 'max-w-md', lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl', '3xl': 'max-w-3xl', '4xl': 'max-w-4xl', '5xl': 'max-w-5xl', '6xl': 'max-w-6xl', '7xl': 'max-w-7xl', }; interface ModalProps { open: boolean; title: string; accent?: Accent; onClose: () => void; children: ReactNode; /** Optional name to give the dialog role for screen readers / Playwright. */ testid?: string; /** Max-width preset. Defaults to `2xl` to keep historical behavior. */ size?: ModalSize; } /** * Centered modal with a backdrop. Closes on Escape and on backdrop click. * The accessible name comes from the SectionHeader's `highlight`, so the dialog * can be located via `getByRole('dialog', { name: ... })`. * * The dialog caps its height at the viewport and scrolls its body internally, * so tall content (MITRE matrix, long forms) never escapes the viewport. */ export function Modal({ open, title, accent = 'cyan', onClose, children, testid, size = '2xl', }: ModalProps) { const ref = useRef(null); useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }} role="presentation" >
{children}
); }