import { useEffect, useId, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; import "@shared/components/Modal.css"; export type ModalWidth = "sm" | "md" | "lg" | "xl"; export interface ModalProps { open: boolean; onClose: () => void; /** Optional heading slot rendered above the body. */ title?: ReactNode; /** Optional sub-heading rendered under the title. */ subtitle?: ReactNode; /** Optional footer slot rendered below the body. */ footer?: ReactNode; /** Width preset. sm=24rem, md=32rem, lg=48rem, xl=64rem. Defaults to md. */ width?: ModalWidth; /** Disable click-on-backdrop dismissal. Defaults to false. */ disableBackdropClose?: boolean; /** Disable Escape-key dismissal. Defaults to false. */ disableEscapeClose?: boolean; /** Accessible name when no visible `title` is provided. */ ariaLabel?: string; className?: string; children?: ReactNode; } const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** * Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and * click-on-backdrop close by default. Each instance is self-contained — the * caller owns the open state and the close handler. * * Focus is trapped inside the dialog while open and returned to the * previously-focused element on close. */ export function Modal({ open, onClose, title, subtitle, footer, width = "md", disableBackdropClose = false, disableEscapeClose = false, ariaLabel, className, children, }: ModalProps) { const dialogRef = useRef(null); const titleId = useId(); useEffect(() => { if (!open || disableEscapeClose) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, disableEscapeClose, onClose]); useEffect(() => { if (!open) return; const previous = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = previous; }; }, [open]); useEffect(() => { if (!open) return; const previouslyFocused = document.activeElement as HTMLElement | null; const dialog = dialogRef.current; const first = dialog?.querySelector(FOCUSABLE_SELECTOR); first?.focus(); function onKey(e: KeyboardEvent) { if (e.key !== "Tab" || !dialog) return; const focusables = dialog.querySelectorAll(FOCUSABLE_SELECTOR); if (focusables.length === 0) { e.preventDefault(); return; } const firstEl = focusables[0]; const lastEl = focusables[focusables.length - 1]; const active = document.activeElement; if (e.shiftKey && active === firstEl) { e.preventDefault(); lastEl.focus(); } else if (!e.shiftKey && active === lastEl) { e.preventDefault(); firstEl.focus(); } } document.addEventListener("keydown", onKey); return () => { document.removeEventListener("keydown", onKey); previouslyFocused?.focus?.(); }; }, [open]); if (!open) return null; function onBackdropClick() { if (!disableBackdropClose) onClose(); } const hasTitle = title !== undefined && title !== null; return createPortal(
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby={hasTitle ? titleId : undefined} aria-label={!hasTitle ? ariaLabel : undefined} > {(title || subtitle) && (
{title && (
{title}
)} {subtitle &&
{subtitle}
}
)}
{children}
{footer && }
, document.body, ); }