import { useEffect, useId, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { useBackToClose } from "../../lib/history"; import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../lib/layerStack"; import { useFullscreenPortalHost } from "../../lib/fullscreen"; import { trapTab, useDialogChrome } from "./dialogFocus"; import { Button } from "./Button"; import Toaster from "./Toaster"; import { useOverlayOrder } from "./overlayOrder"; // Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the // shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor // dismisses just the error and returns to the editor underneath, and a player below defers to it // (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under is painted // beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the // download dialog) would otherwise open invisibly. export default function Modal({ title, onClose, children, maxWidth = "max-w-lg", placement = "center", }: { title: ReactNode; onClose: () => void; children: ReactNode; maxWidth?: string; /** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top * of the viewport (where a search-first dialog like the command palette belongs, so the list grows * downwards instead of the whole thing jumping as results change). Everything else about the shell * — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A * placement is not a second dialog implementation: duplicating that behaviour is exactly how two * shells drift apart. */ placement?: "center" | "drawer" | "top"; }) { const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. useBackToClose(onClose); // ONE container element for the dialog's whole life, re-parented between and the fullscreen // element (deep-review round 4) — and attached before the children commit, so an `autoFocus` child // still gets the focus (round 5). Both rules live in the hook; `ui/AnchoredPopover` shares it. const host = useFullscreenPortalHost(); const panelRef = useRef(null); const rootRef = useRef(null); const titleId = useId(); // Focus lands in the panel; OWNERSHIP is the whole dialog — the same element the Tab trap below // is scoped to, toast host included. Tracking ownership over the panel alone meant tabbing to an // in-dialog toast counted as leaving, and the close-time restore was then skipped (round 6). useDialogChrome(panelRef, rootRef); const overlayOrder = useOverlayOrder(); // Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a // drag that begins inside the dialog (e.g. selecting text in an input) and is released out on // the backdrop wrongly dismisses — the click event bubbles to the common ancestor (the backdrop). const pressedOnBackdrop = useRef(false); // Keep a stable handler across renders so the stack id is assigned once per mount. const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { const id = pushLayer(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && isTopmost(id)) { e.stopPropagation(); onCloseRef.current(); return; } // Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert // page behind the backdrop. A nested modal or a click-opened menu above traps for itself; a hover // card open under the pointer (a passive layer) does not, so it must not switch the trap off. if (e.key === "Tab" && !anyBlockingLayerAbove(id)) { const panel = panelRef.current; if (!panel) return; // The whole dialog, not just the panel: its toast host sits beside the panel (see ui/Toaster), // and a toast's Undo or dismiss must be reachable from the keyboard like everything else. trapTab(e, rootRef.current ?? panel, panel); } }; window.addEventListener("keydown", onKey); return () => { window.removeEventListener("keydown", onKey); removeLayer(id); }; }, []); const drawer = placement === "drawer"; const top = placement === "top"; // Only on a server render, where the hook has no `document` to build a host from. if (!host) return null; return createPortal(
{ pressedOnBackdrop.current = e.target === e.currentTarget; }} onClick={(e) => { if (pressedOnBackdrop.current && e.target === e.currentTarget) onClose(); }} role="dialog" aria-modal="true" aria-labelledby={titleId} >

{title}

{children}
{/* Toasts raised while a dialog is open show inside it: the app's own host is under this scrim, where a failed share or rename was unreadable and a click on it closed the dialog (see ui/Toaster). */}
, host, ); }