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.
249 lines
12 KiB
TypeScript
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,
|
|
);
|
|
}
|