import { useEffect, useId, useRef, useState, 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 { pushLayer, removeLayer, isTopmost } from "../lib/layerStack"; import { useFullscreenElement } from "../lib/fullscreen"; // Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically // focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a // collapsed disclosure) so the trap never lands focus on something the user can't see. const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const focusablesIn = (root: HTMLElement): HTMLElement[] => [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null); // 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; everything else about the shell — * the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer * is a placement, not a second dialog implementation: duplicating that behaviour is exactly how * two shells drift apart. */ placement?: "center" | "drawer"; }) { const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. useBackToClose(onClose); const fsRoot = useFullscreenElement(); const panelRef = useRef(null); const titleId = useId(); // Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the // dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading // it later (in the effect) would capture a now-inside element that unmounts on close, stranding // focus on . useState initializer runs exactly once, on first render. const [restoreTo] = useState(() => document.activeElement as HTMLElement | null); // 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 (!isTopmost(id)) return; if (e.key === "Escape") { e.stopPropagation(); onCloseRef.current(); return; } // Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert // page behind the backdrop. Only the topmost modal traps (a nested one defers to its child). if (e.key === "Tab") { const panel = panelRef.current; if (!panel) return; const items = focusablesIn(panel); const first = items[0]; const last = items[items.length - 1]; if (!first || !last) { e.preventDefault(); return; } const active = document.activeElement; if (!panel.contains(active) || active === panel) { // Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by // clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be // checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back. e.preventDefault(); (e.shiftKey ? last : first).focus(); } else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } } }; window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; // Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move // focus to the first control so the dialog opens focused, not stranded on the background. const panel = panelRef.current; if (panel && !panel.contains(document.activeElement)) { (focusablesIn(panel)[0] ?? panel).focus(); } return () => { window.removeEventListener("keydown", onKey); removeLayer(id); document.body.style.overflow = prev; // Restore focus to the trigger, if it's still in the document and focusable. if (restoreTo && restoreTo.isConnected) restoreTo.focus(); }; }, [restoreTo]); const drawer = placement === "drawer"; 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}
, fsRoot ?? document.body, ); }