feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)

Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
This commit is contained in:
2026-09-27 01:33:37 +02:00
parent 4c63d6660d
commit 3ef2c38eb4
34 changed files with 505 additions and 113 deletions
+86 -8
View File
@@ -1,16 +1,53 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import {
createContext,
useContext,
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 { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack";
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import { trapTab, useDialogChrome } from "./dialogFocus";
import { 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
* (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). Required, so every
* dialog states its purpose instead of inheriting "the first focusable", which is always the ✕. */
export type ModalFocus = "field" | "close" | "action";
// 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
@@ -23,8 +60,10 @@ export default function Modal({
children,
maxWidth = "max-w-lg",
placement = "center",
initialFocus,
}: {
title: ReactNode;
initialFocus: ModalFocus;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
@@ -49,7 +88,31 @@ export default function Modal({
// 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 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 === "field" && bodyRef.current ? focusablesIn(bodyRef.current)[0] : 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 first control arrives the focus moves
// there — but only while it still rests on the ✕: a user who has moved it keeps it where it is.
useEffect(() => {
const body = bodyRef.current;
if (initialFocus !== "field" || !body || focusablesIn(body).length > 0) return;
const watch = new MutationObserver(() => {
const first = focusablesIn(body)[0];
if (!first) return;
watch.disconnect();
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
@@ -115,19 +178,34 @@ export default function Modal({
tabIndex={-1}
className={
drawer
? `${SURFACE.drawer} relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `${SURFACE.panel} relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full rounded-none sm:rounded-l-2xl overlay-drawer`
: `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full rounded-2xl`
}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
{/* 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>
<IconButton label={t("common.close")} size="sm" variant="ghost" onClick={onClose}>
<IconButton
ref={closeRef}
label={t("common.close")}
size="sm"
variant="ghost"
onClick={onClose}
>
<X className="w-4 h-4" />
</IconButton>
</div>
<div className="px-5 pb-5">{children}</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