import { createContext, useContext, useEffect, useEffectEvent, 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 { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack"; import { useFullscreenPortalHost } from "../../lib/fullscreen"; import { firstFieldIn, focusablesIn, trapTab, useDialogChrome } from "./dialogFocus"; import { IconButton } from "./IconButton"; import Toaster from "./Toaster"; import { useOverlayOrder } from "./overlayOrder"; import { SURFACE } from "./surfaces"; /** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control, * or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or * browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button; * the ✕ if none does), `player` = the panel itself (a player: its keys reach it, and Space presses no * button). Required, so every dialog states its purpose instead of inheriting "the first * focusable", which is always the ✕. */ export type ModalFocus = "field" | "close" | "action" | "player"; // The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that // owns its own state (the issue composer) pins its buttons without lifting that state into the caller. const FooterSlot = createContext(null); /** The dialog's commit row (Cancel / Save…). It renders OUTSIDE the scrolling body, pinned under it * (C4): the primary action stays on screen however long the body is, or however much of the screen * the on-screen keyboard takes. A section's own buttons (a sub-form inside a manager) stay in the body. */ export function ModalFooter({ children, className = "flex flex-wrap items-center justify-end gap-2", }: { children: ReactNode; className?: string; }) { const slot = useContext(FooterSlot); return slot ? createPortal(
{children}
, slot) : null; } // A column: header, the scrolling body, the footer. `overflow-hidden` clips the body to the rounded // panel; the panel itself never scrolls any more. The scope names the body's scroll timeline for the // header's and the footer's fades, which sit outside that scroller. const PANEL = "relative flex flex-col overflow-hidden outline-none panel-scroll-scope"; // 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. // 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", initialFocus, headerActions, }: { title: ReactNode; initialFocus: ModalFocus; onClose: () => void; children: ReactNode; maxWidth?: string; /** `drawer` slides in from the right edge and runs full height (`drawer-start`: from the left, where * a side panel lives); `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" | "drawer-start" | "top"; /** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */ headerActions?: ReactNode; }) { 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 — and attached before the children commit, so an `autoFocus` child // still gets the focus. 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. const bodyRef = useRef(null); const closeRef = useRef(null); const [footerSlot, setFooterSlot] = useState(null); useDialogChrome(panelRef, rootRef, { // Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's // first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over. initial: () => (initialFocus === "player" ? panelRef.current : null) ?? (initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ?? (closeRef.current?.offsetParent != null ? closeRef.current : null), }); // A form that loads after the dialog opens (the issue composer shows a spinner first) has no field // to take the focus at open, so it lands on the ✕. When the form arrives the focus moves to its // first field — but only while it still rests on the ✕: a user who has moved it keeps it there. // The form takes it ONCE: arrived while the focus was elsewhere, it disarms all the same, or a later // mutation would pull the focus off a ✕ the user went back to. The watch // re-arms only when the focus is taken away, not moved: the body has no control again, or the // focused one went (a failed load's Retry giving way to the spinner, or straight to the form) and // the focus fell to . It then goes to the ✕, and on to the first field as soon as the body // has one — at once, when other controls stayed beside the one that went. Only // : focus in a dialog stacked on top is not this one's to take. useEffect(() => { const body = bodyRef.current; if (initialFocus !== "field" || !body || focusablesIn(body).length > 0) return; let armed = true; const watch = new MutationObserver(() => { if (document.activeElement === document.body && closeRef.current) { closeRef.current.focus(); armed = true; } const first = firstFieldIn(body); if (!first) { armed = true; return; } if (!armed) return; armed = false; if (document.activeElement === closeRef.current) first.focus(); }); watch.observe(body, { childList: true, subtree: true }); return () => watch.disconnect(); }, [initialFocus]); 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); // An effect event, so a new handler each render never re-registers: the stack id is assigned once per mount. const close = useEffectEvent(onClose); useEffect(() => { const id = pushLayer(); const onKey = (e: KeyboardEvent) => { if (takeEscape(e, id)) { e.stopPropagation(); close(); 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 drawerStart = placement === "drawer-start"; 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} >
{/* C4: the header and the footer stay put and only the body scrolls, with the B9 fade at each edge — shown only while content is actually under it (the body's scroll timeline, see index.css), so a dialog that fits draws no fade over its first or last line. */}

{title}

{headerActions && (
{headerActions}
)}
{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, ); }