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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user