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}
>
{/* 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,
);
}