"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; } const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; /** * 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, which 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 coords. * * UX / a11y details * ----------------- * - Backdrop click triggers `onClose` (only when the click target IS * the backdrop, not the panel inside). * - Escape triggers `onClose`. * - `body` overflow is locked while open so background content doesn't * scroll behind the modal. * - Focus is moved into the panel on open, trapped within it while open * (Tab / Shift+Tab cycle), and restored to the previously focused * element on close — so keyboard and screen-reader users can't tab * out to the inert page behind the dialog. */ export function Modal({ open, onClose, children, ariaLabel }: Props) { const closeRef = useRef(onClose); closeRef.current = onClose; const panelRef = useRef(null); useEffect(() => { if (!open) return; // Remember what had focus so we can restore it on close. const previouslyFocused = document.activeElement as HTMLElement | null; // Lock background scroll. const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; // Move focus into the dialog — first focusable element, else the // panel itself (it carries tabIndex={-1}). const panel = panelRef.current; const focusables = panel ? Array.from(panel.querySelectorAll(FOCUSABLE)) : []; (focusables[0] ?? panel)?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { closeRef.current(); return; } if (e.key !== "Tab" || !panel) return; // Re-query each time — modal content can change between tabs. const items = Array.from( panel.querySelectorAll(FOCUSABLE) ).filter((el) => el.offsetParent !== null || el === document.activeElement); if (items.length === 0) { e.preventDefault(); panel.focus(); return; } const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement; if (e.shiftKey) { if (active === first || active === panel) { e.preventDefault(); last.focus(); } } else if (active === last) { e.preventDefault(); first.focus(); } }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", onKey); // Restore focus to the trigger (if it's still in the document). if (previouslyFocused && document.contains(previouslyFocused)) { previouslyFocused.focus(); } }; }, [open]); if (!open) return null; if (typeof document === "undefined") return null; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} >
{children}
, document.body ); }