import { useEffect, useId, useRef, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { OVERLAY_Z_MODAL } from '../../lib/overlayLayers'; import { __ } from '../../lib/i18n'; type Props = { open: boolean; title: string; description?: string; onClose: () => void; children: ReactNode; footer?: ReactNode; /** Narrow dialog vs comfortable form width (`comfortable` = between md and lg). */ size?: 'sm' | 'md' | 'comfortable' | 'lg' | 'xl'; }; /** Monotonic id so nested modals only consume Escape on the topmost layer. */ let modalStackSeq = 0; type StackEntry = { id: number; close: () => void }; const modalEscapeStack: StackEntry[] = []; /** * Accessible modal shell (backdrop, Escape on top dialog only, focus first field). Reuse across admin flows. * Uses a high z-index so nested pickers and footers stay above `#wpadminbar` (WordPress uses ~99999). */ export function Modal({ open, title, description, onClose, children, footer, size = 'md' }: Props) { const titleId = useId(); const descId = useId(); const panelRef = useRef(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { if (!open) { return; } const id = ++modalStackSeq; const entry: StackEntry = { id, close: () => { onCloseRef.current(); }, }; modalEscapeStack.push(entry); const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; } const top = modalEscapeStack[modalEscapeStack.length - 1]; if (!top || top.id !== id) { return; } e.preventDefault(); top.close(); }; document.addEventListener('keydown', onKey); const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); const idx = modalEscapeStack.findIndex((x) => x.id === id); if (idx !== -1) { modalEscapeStack.splice(idx, 1); } document.body.style.overflow = prev; }; }, [open]); useEffect(() => { if (!open || !panelRef.current) { return; } const focusable = panelRef.current.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusable?.focus(); }, [open]); if (!open || typeof document === 'undefined') { return null; } const maxW = size === 'sm' ? 'max-w-md' : size === 'comfortable' ? 'max-w-2xl' : size === 'lg' ? 'max-w-4xl' : size === 'xl' ? 'max-w-6xl' : 'max-w-lg'; return createPortal(
{children}
{footer ? (
{footer}
) : null} , document.body ); }