import { type ReactNode, useEffect, useRef } from "react"; import "./dialog.scss"; /** * App-wide modal primitive — a native `` (`showModal()`), not a * `role="dialog"` div: the browser handles the modal semantics, the * focus trap, Escape-to-close and the backdrop for free instead of this * component reimplementing all four. First modal in the app — every other * "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button, * the settings pages' danger zones) is an inline two-step reveal, not an * overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't * fit inline, hence this. * * Mounted only while open (see `PlanningPage`'s conditional rendering of * `RecipePickerDialog`, same convention as its own `CalendarPopover`) — * `showModal()` fires once on mount rather than toggling on an `isOpen` * prop, since closing this component means the caller stops rendering it * rather than flipping a prop on a permanently-mounted instance. */ export function Dialog({ onClose, title, children, className, }: { onClose: () => void; title?: string; children: ReactNode; className?: string; }) { const dialogRef = useRef(null); useEffect(() => { dialogRef.current?.showModal(); }, []); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; // `close` covers every way a native dialog can close — Escape (which // fires `cancel` first, then `close`) as much as a future // `
` — so this is the one listener needed to // keep the caller's own "is this open" state (e.g. `PlanningPage`'s // `openSlot`) in sync with it. dialog.addEventListener("close", onClose); return () => dialog.removeEventListener("close", onClose); }, [onClose]); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; // Attached imperatively (not a JSX `onClick`) since the click-to-close // it implements is already reachable from the keyboard via Escape // (native `cancel`/`close`, wired above) — a JSX `onClick` here would // trip the "needs a matching keyboard handler" a11y lint for a // non-interactive element even though one already exists, just not in // a form that lint rule can see. function handleClick(e: MouseEvent) { // Re-read from the ref (not the outer `dialog` const) — TS can't // carry that early-return narrowing into a nested function, since it // can't prove the function won't run at some later point where it no // longer holds (even though here, as an event listener on this same // element, it trivially still does). const current = dialogRef.current; if (!current) return; // A click lands on the `` element itself both for the // backdrop *and* for its own unfilled padding/margin — comparing // against its content box (not just `e.target`) is what actually // distinguishes "outside the panel" from "on it". const rect = current.getBoundingClientRect(); const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; if (!inside) current.close(); } dialog.addEventListener("click", handleClick); return () => dialog.removeEventListener("click", handleClick); }, []); return ( {title && (

{title}

)}
{children}
); }