import { useCallback, useEffect, useId, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { usePaneVisible } from "./PaneVisibility"; const FOCUSABLE_SELECTOR = [ "a[href]", "area[href]", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "button:not([disabled])", "iframe", "object", "embed", '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', ].join(","); function focusableWithin(root: HTMLElement): HTMLElement[] { // Deliberately no `offsetParent` check: everything a dialog renders is // visible, and `offsetParent` is unreliable inside fixed-position overlays. return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => !el.closest("[hidden]") && el.getAttribute("aria-hidden") !== "true", ); } export interface ModalProps { /** Accessible name for the dialog. Rendered as the header unless `hideTitle`. */ title: string; onClose: () => void; children: ReactNode; /** Optional sticky footer row (buttons live here). */ footer?: ReactNode; /** Optional sub-header description, wired to `aria-describedby`. */ description?: ReactNode; /** Tailwind width class for the dialog panel. */ widthClassName?: string; /** When false, Escape / overlay click / the ✕ button do not close. */ dismissible?: boolean; /** Hide the ✕ in the header (the footer usually carries a Close button). */ hideCloseButton?: boolean; /** Focused on mount; falls back to the first focusable child. */ initialFocusRef?: React.RefObject; /** Applied to the scrollable body wrapper. */ bodyClassName?: string; } /** * The one modal primitive. Every dialog in the app renders through this so * `role="dialog"`, `aria-modal`, a focus trap, focus restore, Escape and * click-outside are implemented once instead of twelve times. */ export default function Modal({ title, onClose, children, footer, description, widthClassName = "w-[32rem]", dismissible = true, hideCloseButton = false, initialFocusRef, bodyClassName = "", }: ModalProps) { const overlayRef = useRef(null); const panelRef = useRef(null); const restoreFocusRef = useRef(null); const titleId = useId(); const descId = useId(); // A dialog portals to `document.body`, so the `hidden` class its pane uses to // step aside for another tab cannot reach it. `PaneVisibility` is how it // finds out, and while it is false this dialog paints nothing, traps // nothing, and — via `[hidden]` — blocks no native file drop. const paneVisible = usePaneVisible(); const paneVisibleRef = useRef(paneVisible); paneVisibleRef.current = paneVisible; // Remember what had focus, and restore it on unmount — but not if the pane // is hidden by then: a dialog closed while the user is on another tab would // otherwise yank focus back to a control they cannot see. useEffect(() => { restoreFocusRef.current = document.activeElement as HTMLElement | null; return () => { if (paneVisibleRef.current) restoreFocusRef.current?.focus?.(); }; }, []); // Move focus inside — on mount, and again whenever the pane comes back. useEffect(() => { if (!paneVisible) return; const panel = panelRef.current; if (!panel) return; const target = initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel; // Defer so the panel is laid out (offsetParent) before we query it. const frame = requestAnimationFrame(() => target.focus?.()); return () => cancelAnimationFrame(frame); // `initialFocusRef` is a ref object; re-running on its identity would steal // focus mid-interaction. // eslint-disable-next-line react-hooks/exhaustive-deps }, [paneVisible]); // Escape closes; Tab is trapped inside the panel. Neither applies while the // pane is hidden — those keystrokes belong to whatever the user is looking // at instead. useEffect(() => { if (!paneVisible) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && dismissible) { e.stopPropagation(); onClose(); return; } if (e.key !== "Tab") return; const panel = panelRef.current; if (!panel) return; const items = focusableWithin(panel); if (items.length === 0) { e.preventDefault(); panel.focus(); return; } const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement as HTMLElement | null; if (!active || !panel.contains(active)) { e.preventDefault(); first.focus(); return; } if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } }; document.addEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true); }, [dismissible, onClose, paneVisible]); const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (dismissible && e.target === overlayRef.current) onClose(); }, [dismissible, onClose], ); return createPortal( , document.body, ); }