"use client"; import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; interface Props { open: boolean; /** Called when user clicks the backdrop or presses Escape. */ onClose: () => void; children: React.ReactNode; /** * ARIA label fallback when no labelled element exists inside. * Optional; if you have a heading inside the modal with id, set * `aria-labelledby` on a wrapper instead. */ ariaLabel?: string; } /** * Portal-based modal. * * Why a portal * ------------ * `position: fixed` becomes positioned relative to a transformed * ancestor's containing block, not the viewport, when ANY ancestor * has a `transform`, `perspective`, or `filter` applied. Our * `animate-in` utility sets `transform: translateY(0)` on a lot of * dashboard/tenant-detail containers (because of the fade-up * animation, which uses `animation-fill-mode: both` to keep the * transform on after the animation finishes). That broke modals * rendered as in-place children — they centred to the panel they * lived in, not to the page. * * Rendering at `document.body` via `createPortal` escapes every * containing-block ancestor and gives us true viewport coordinates. * * UX details * ---------- * - Backdrop click triggers `onClose`. (Bubbling check: only fires * when the click target IS the backdrop, not the panel inside.) * - Escape key triggers `onClose`. Standard modal expectation. * - `body` overflow is locked while open so background content * doesn't scroll behind the modal. * - Renders nothing on first paint server-side, then mounts on * client. `useEffect` gating ensures `document.body` is available; * without it Next.js SSR would throw on `document` reference. */ export function Modal({ open, onClose, children, ariaLabel }: Props) { const closeRef = useRef(onClose); closeRef.current = onClose; useEffect(() => { if (!open) return; // Lock background scroll. Restore on unmount/close. const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeRef.current(); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", onKey); }; }, [open]); if (!open) return null; if (typeof document === "undefined") return null; return createPortal(