Files
siftlode/frontend/src/components/ui/Modal.tsx
T
peter d1704a5519 revert: the Modal Escape guard for a fullscreen own-content video
Chrome does not hand the page the Escape it spends on leaving fullscreen (checked with a real
keypress: the first Esc leaves fullscreen, the second closes the player). The guard defended a case
that does not happen, under a comment saying it does. This reverts 121ebeeb.
2026-10-07 23:59:32 +02:00

249 lines
12 KiB
TypeScript

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<HTMLElement | null>(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(<div className={className}>{children}</div>, 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 <body> 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 <body> 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<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(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 <body>. 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
// <body>: 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(
<div
ref={rootRef}
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer
? "justify-end"
: drawerStart
? "justify-start"
: top
? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]"
: "items-center justify-center p-4 sm:p-6"
}`}
onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (pressedOnBackdrop.current && e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div
ref={panelRef}
tabIndex={-1}
className={
drawer
? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full overlay-drawer`
: drawerStart
? `${SURFACE.drawerStart} ${PANEL} w-full ${maxWidth} h-full overlay-drawer-start`
: `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full`
}
>
{/* 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. */}
<div className="relative z-base flex shrink-0 items-start justify-between gap-3 p-5 pb-3 fade-below-panel">
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
{headerActions && (
<div className="-my-1 ml-auto flex shrink-0 items-center gap-1">{headerActions}</div>
)}
<IconButton
ref={closeRef}
label={t("common.close")}
size="sm"
variant="ghost"
onClick={onClose}
>
<X className="w-4 h-4" />
</IconButton>
</div>
<div ref={bodyRef} className="min-h-0 flex-auto overflow-y-auto px-5 pb-5 panel-scroller">
<FooterSlot value={footerSlot}>{children}</FooterSlot>
</div>
<div
ref={setFooterSlot}
className="relative z-base shrink-0 px-5 pb-5 fade-above-panel empty:hidden"
/>
</div>
{/* 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). */}
<Toaster overlay order={overlayOrder} />
</div>,
host,
);
}