/** * A modal dialog. * * @module */ import { type ReactNode, useCallback, useEffect, useId, useRef } from "react"; import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts"; /** The props of {@link Modal}. */ export interface ModalProps { /** The dialog's title. */ title: string; /** Called to close it. */ onClose: () => void; /** The dialog's body. */ children: ReactNode; /** Whether it takes the wide layout. */ wide?: boolean; /** * Runs before every dismissal the user did not aim at the content — Escape, * the backdrop, the ✕. Returning `false` cancels it, which lets a modal * holding an unsaved draft ask first instead of throwing the work away. */ onBeforeClose?: () => boolean; } /** * Renders a modal dialog over the page, closed by Escape, the backdrop or its * ✕, and locks the page's scroll while it is open. */ export function Modal( { title, onClose, children, wide = false, onBeforeClose }: ModalProps, ): React.ReactPortal { const backdropRef = useRef(null); const titleId = useId(); const requestClose = useCallback(() => { if (onBeforeClose?.() === false) return; onClose(); }, [onBeforeClose, onClose]); useScrollLock(); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) requestClose(); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [requestClose]); return createPortal(
{ if (e.target === backdropRef.current) requestClose(); }} >
{title}
{children}
, document.body, ); }