import { forwardRef, useCallback, useEffect, useId, useRef, type ReactNode, } from "react"; function cx(...parts: Array): string { return parts.filter(Boolean).join(" "); } export interface ModalProps { open: boolean; onClose: () => void; title: string; children: ReactNode; /** Footer actions; place the primary action last (renders right-most). */ footer?: ReactNode; /** Non-destructive dialogs close on backdrop click; destructive ones must not. */ dismissOnBackdrop?: boolean; } const FOCUSABLE = 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; export const Modal = forwardRef(function Modal( { open, onClose, title, children, footer, dismissOnBackdrop = true }, _ref ) { const panelRef = useRef(null); const titleId = useId(); const bodyId = useId(); // Remember what was focused so we can restore it on close. const triggerRef = useRef(null); useEffect(() => { if (!open) return; triggerRef.current = document.activeElement as HTMLElement | null; // Move focus into the dialog. const panel = panelRef.current; const first = panel?.querySelector(FOCUSABLE); (first ?? panel)?.focus(); // Lock background scroll. const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prevOverflow; // Return focus to the trigger on close. triggerRef.current?.focus?.(); }; }, [open]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); return; } if (e.key !== "Tab") return; // Focus trap. const panel = panelRef.current; if (!panel) return; const nodes = Array.from(panel.querySelectorAll(FOCUSABLE)).filter( (n) => n.offsetParent !== null ); if (nodes.length === 0) { e.preventDefault(); return; } const firstNode = nodes[0]; const lastNode = nodes[nodes.length - 1]; const active = document.activeElement; if (e.shiftKey && active === firstNode) { e.preventDefault(); lastNode.focus(); } else if (!e.shiftKey && active === lastNode) { e.preventDefault(); firstNode.focus(); } }, [onClose] ); if (!open) return null; return (
{/* Backdrop — fades in, closes non-destructive dialogs on click. */} ); }); /* Usage: const [open, setOpen] = useState(false); setOpen(false)} title="Delete project?" dismissOnBackdrop={false} // destructive: don't discard on stray click footer={<> // primary, right-most } > This permanently removes the project and all its data. */