import { createContext, useCallback, useContext, useEffect, useId, useRef, useState, type ElementType, type FormEvent, type KeyboardEvent, type ReactNode, } from "react"; import { ArrowLeftIcon, LoaderCircleIcon, LockIcon, XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { Button } from "./button"; import { TechnicalDetails } from "./error-message"; import { Notice } from "./notice"; import { SectionFrameReset } from "./section-variant"; import { Skeleton } from "./skeleton"; import type { AnalyticsAction } from "./analytics-actions"; import { apiErrorTechnicalFacts, userErrorTextWithoutReference } from "./api-error"; import { cn } from "../lib/cn"; import { containedPortal } from "./portal-container"; const DialogContainedPortal = containedPortal(DialogPrimitive.Portal); /* ---------------------------------------------------------------------------- FormFrame, FormDialog, FormPage and FormInline (brief 7.15). One anatomy for every create and edit form: a title, one vertical column of fields, server errors inside the form, and a footer with Cancel (ghost) and one primary. Enter submits; Cmd/Ctrl+Enter submits from a textarea. The frame renders in four presentations: - "dialog": centred 480/560/640px panel; a bottom sheet on phones. - "sheet": full-height right sheet for longer forms (see form-sheet.tsx). - "page": a full page with a back link, for long creates. - "inline": a panel in the page flow. `onSubmit` may be async. Returning `false` keeps the form open (for example after client validation); throwing shows the error's message inside the form. Anything else counts as success and calls `onSubmitted`. -------------------------------------------------------------------------- */ export type FormVariant = "dialog" | "sheet" | "page" | "inline"; /** A form page's frame: the DetailPage column (960px, 32px sides, 16px on phones). */ const PAGE_FRAME = "mx-auto w-full max-w-[960px] px-8 max-sm:px-4 @max-[26rem]/form:px-4"; export type FormDialogSize = "sm" | "md" | "lg"; type SubmitResult = void | boolean; interface FormChrome { Title: ElementType; Description: ElementType; /** Radix Close, rendered asChild around the close button. */ Close: ElementType | null; } const FormChromeContext = createContext(null); const DIALOG_CHROME: FormChrome = { Title: DialogPrimitive.Title, Description: DialogPrimitive.Description, Close: DialogPrimitive.Close, }; /** Provides Radix title, description and close parts to a frame inside a Radix dialog. */ export function FormDialogChromeProvider({ children }: { children: ReactNode }) { return {children}; } export interface FormFrameProps { /** Presentation. Default "dialog". */ variant?: FormVariant | undefined; /** Names the object and the outcome: "New variable set", "Replace value". */ title: ReactNode; /** One sentence under the title. */ description?: ReactNode | undefined; /** A 40px tile or icon before the title (dialog and sheet only). */ leading?: ReactNode | undefined; /** Right of the title, for example a status badge. */ headerAside?: ReactNode | undefined; /** The fields. Wrap them in `FieldStack` for the 24px rhythm. */ children?: ReactNode | undefined; /** Verb + object: "Create variable set". */ submitLabel: ReactNode; /** Shown with a spinner while submitting: "Creating…". */ pendingLabel?: ReactNode | undefined; /** Closed product-analytics label for the primary button (never derived from text). */ submitAnalyticsAction?: AnalyticsAction | null | undefined; /** Pass null to hide Cancel (for example on a one-time secret step). */ cancelLabel?: ReactNode | null | undefined; /** "destructive" fills the primary in danger, for confirmations. */ tone?: "default" | "destructive" | undefined; onSubmit?: | ((event: FormEvent) => SubmitResult | Promise) | undefined; /** Called after a successful submit. */ onSubmitted?: (() => void) | undefined; onCancel?: (() => void) | undefined; /** Controlled submitting state. Defaults to tracking `onSubmit`'s promise. */ pending?: boolean | undefined; onPendingChange?: ((pending: boolean) => void) | undefined; /** A server error, rendered inside the form above the footer. */ error?: ReactNode | undefined; submitDisabled?: boolean | undefined; /** Why the primary is disabled, shown in the footer: who can fix it. */ disabledReason?: ReactNode | undefined; /** Quiet content at the start of the footer, for example "Runs with your connected accounts." */ footerStart?: ReactNode | undefined; /** Replaces the Cancel and primary buttons entirely; false hides the footer (a step that waits). */ footer?: ReactNode | false | undefined; /** Close button in the header (dialog and sheet). Default true. */ showClose?: boolean | undefined; /** Back link above the title (page only). */ back?: | { label: ReactNode; href?: string | undefined; onClick?: (() => void) | undefined } | undefined; /** What gets focus when an overlay opens: the first field (default) or Cancel. */ initialFocus?: "field" | "cancel" | undefined; /** The current values are loading (an edit form): fields show as placeholders. */ loading?: boolean | undefined; /** How many field placeholders to show while loading. Default 3. */ loadingFields?: number | undefined; className?: string | undefined; bodyClassName?: string | undefined; } /** What went wrong and what to do; an API error's status and reference go behind Technical details. */ function errorMessage(error: unknown): ReactNode { const facts = apiErrorTechnicalFacts(error); if (facts.length === 0) return userErrorTextWithoutReference(error); return ( <> {userErrorTextWithoutReference(error)}
); } const SUBMITTABLE = "input:not([type=hidden]):not(:disabled):not([readonly]), textarea:not(:disabled):not([readonly])"; /** First focusable field in a form body, for initial focus and after a failed submit. */ export function firstFormField(root: ParentNode | null, invalidOnly = false): HTMLElement | null { if (!root) return null; const explicit = root.querySelector("[data-autofocus]"); if (!invalidOnly && explicit) return explicit; const selector = invalidOnly ? '[aria-invalid="true"]' : `${SUBMITTABLE}, select:not(:disabled), [role="radiogroup"] [tabindex="0"]`; return root.querySelector(selector); } export function FormFrame({ variant = "dialog", title, description, leading, headerAside, children, submitLabel, pendingLabel, submitAnalyticsAction, cancelLabel = "Cancel", tone = "default", onSubmit, onSubmitted, onCancel, pending: pendingProp, onPendingChange, error, submitDisabled = false, disabledReason, footerStart, footer, showClose = true, back, initialFocus = "field", loading = false, loadingFields = 3, className, bodyClassName, }: FormFrameProps) { const chrome = useContext(FormChromeContext); const [internalPending, setInternalPending] = useState(false); const [thrown, setThrown] = useState(null); const bodyRef = useRef(null); // A submit that finishes after the person left the form (a page they // navigated away from) must not act on it: closing it would navigate back. const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const reasonId = useId(); const pending = pendingProp ?? internalPending; const shownError = error ?? thrown; const setPending = useCallback( (next: boolean) => { setInternalPending(next); onPendingChange?.(next); }, [onPendingChange], ); const focusInvalid = () => { requestAnimationFrame(() => firstFormField(bodyRef.current, true)?.focus()); }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (pending || submitDisabled) return; setThrown(null); if (!onSubmit) { onSubmitted?.(); return; } let succeeded = false; setPending(true); try { const result = await onSubmit(event); succeeded = result !== false; } catch (caught) { if (mountedRef.current) setThrown(errorMessage(caught)); } finally { // The owner's bookkeeping outlives the form: a dialog closed by its // owner during the save refuses Escape until it hears the save ended. if (mountedRef.current) setPending(false); else onPendingChange?.(false); } if (!mountedRef.current) return; if (succeeded) onSubmitted?.(); else focusInvalid(); }; const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === "Enter" && (event.metaKey || event.ctrlKey) && event.target instanceof HTMLTextAreaElement ) { event.preventDefault(); event.currentTarget.requestSubmit(); } }; const Title = chrome?.Title ?? (variant === "page" ? "h1" : "h2"); const Description = chrome?.Description ?? "p"; const Close = chrome?.Close ?? null; const overlay = variant === "dialog" || variant === "sheet"; const closeButton = overlay && showClose ? ( Close ? ( ) : ( ) ) : null; const reason = submitDisabled && disabledReason && !pending ? disabledReason : null; const actions = footer || footer === false ? ( footer ) : (
{cancelLabel !== null ? ( ) : null}
); const start = reason ? (

) : footerStart ? (
{footerStart}
) : null; const errorBlock = shownError ? (
{shownError}
) : null; return (
void handleSubmit(event)} onKeyDown={handleKeyDown} className="flex min-h-0 min-w-0 flex-1 flex-col" > {variant === "page" ? ( // The 640px column starts where a DetailPage's content starts (the // same 960px frame and padding), so moving between an object's page // and its edit page doesn't shift the back link or the title.
*]:max-w-[640px]")}> {back ? ( back.href ? ( ) : ( ) ) : null}
{/* Focusable from script only: a form page opened in place moves focus here. */} {title} {description ? ( {description} ) : null}
{headerAside ?
{headerAside}
: null}
) : (
{leading && variant !== "inline" ?
{leading}
: null}
{title} {headerAside}
{description ? ( {description} ) : null}
{closeButton ?
{closeButton}
: null}
)}
*]:max-w-[640px]"), variant === "inline" && "px-5 pb-5", bodyClassName, )} >
{loading ? : children} {errorBlock ?
{errorBlock}
: null}
{footer === false ? null : (
{variant === "page" ? (
{start ? (
{start}
) : null} {actions}
) : variant === "inline" ? ( // The buttons end where the field column ends, not at the panel edge.
{start ?
{start}
: null} {actions}
) : ( <> {start ? (
{start}
) : null} {actions} )}
)}
); } /** Label and control placeholders, the shape of the fields that are loading. */ function FieldPlaceholders({ count }: { count: number }) { return (
{Array.from({ length: count }, (_, index) => ( // oxlint-disable-next-line react/no-array-index-key -- fixed placeholders, never reordered ))}
); } /* ---------------------------------------------------------------------------- FormDialog: the frame in a modal. 4 fields or fewer; use FormSheet for more. -------------------------------------------------------------------------- */ const DIALOG_WIDTH: Record = { sm: "sm:max-w-[480px]", md: "sm:max-w-[560px]", lg: "sm:max-w-[640px]", }; export interface FormOverlayProps extends Omit { open: boolean; onOpenChange: (open: boolean) => void; /** Optional trigger element, rendered with Radix `asChild`. */ trigger?: ReactNode | undefined; /** * When false, only Cancel, Close or Escape close it. Use it on a step that * shows something once (a new API key). Outside clicks never discard typed * input either way. */ dismissible?: boolean | undefined; onCancel?: (() => void) | undefined; /** * Where focus goes after closing. Defaults to the trigger, or to whatever * had focus when it opened (a button, or the ⋯ that owned a menu item). */ onCloseAutoFocus?: ((event: Event) => void) | undefined; } /** * Where focus goes back to after an overlay opened from state closes: what had * focus when it opened, or, for a menu item (which closes with its menu), the * button that opened the menu. */ function focusReturnTarget( active: Element | null, content: HTMLElement | null, ): HTMLElement | null { if (!(active instanceof HTMLElement) || active === document.body || content?.contains(active)) { return null; } const menu = active.closest('[role="menu"]'); const menuButtonId = menu?.getAttribute("aria-labelledby"); const menuButton = menuButtonId ? document.getElementById(menuButtonId) : null; return menuButton ?? active; } /** Shared open/close rules for FormDialog and FormSheet. */ export function useFormOverlay({ open, onOpenChange, dismissible = true, pending, onSubmitted, onCancel, onPendingChange, onCloseAutoFocus, hasTrigger = false, }: Pick< FormOverlayProps, | "open" | "onOpenChange" | "dismissible" | "pending" | "onSubmitted" | "onCancel" | "onPendingChange" | "onCloseAutoFocus" > & { hasTrigger?: boolean }) { const pendingRef = useRef(false); if (pending !== undefined) pendingRef.current = pending; const dirtyRef = useRef(false); const contentRef = useRef(null); const returnFocusRef = useRef(null); useEffect(() => { if (open) dirtyRef.current = false; }, [open]); const handleOpenChange = (next: boolean) => { if (!next && pendingRef.current) return; onOpenChange(next); }; return { contentRef, rootProps: { open, onOpenChange: handleOpenChange }, contentProps: { ref: contentRef, onInput: () => { dirtyRef.current = true; }, onOpenAutoFocus: (event: Event) => { const content = contentRef.current; // Radix only returns focus to a Trigger. Opened from state (a button, // a row menu), remember what had focus so closing goes back there. returnFocusRef.current = focusReturnTarget(document.activeElement, content); const target = content?.querySelector("[data-autofocus]") ?? firstFormField(content?.querySelector('[data-slot="form-body"]') ?? null); if (!target) return; event.preventDefault(); target.focus(); }, onPointerDownOutside: (event: Event) => { if (!dismissible || pendingRef.current || dirtyRef.current) event.preventDefault(); }, onEscapeKeyDown: (event: Event) => { if (pendingRef.current) event.preventDefault(); }, onCloseAutoFocus: (event: Event) => { onCloseAutoFocus?.(event); if (event.defaultPrevented || hasTrigger) return; const target = returnFocusRef.current; returnFocusRef.current = null; if (!target?.isConnected) return; event.preventDefault(); target.focus({ preventScroll: true }); }, }, frameProps: { onPendingChange: (next: boolean) => { pendingRef.current = next; onPendingChange?.(next); }, onSubmitted: onSubmitted ?? (() => onOpenChange(false)), onCancel: onCancel ?? (() => onOpenChange(false)), }, }; } export function FormDialog({ open, onOpenChange, trigger, size = "md", dismissible, onCloseAutoFocus, onSubmitted, onCancel, onPendingChange, className, ...frame }: FormOverlayProps & { size?: FormDialogSize }) { const overlay = useFormOverlay({ open, onOpenChange, dismissible, pending: frame.pending, onSubmitted, onCancel, onPendingChange, onCloseAutoFocus, hasTrigger: Boolean(trigger), }); return ( {trigger ? {trigger} : null} ); } /* ---------------------------------------------------------------------------- FormPage and FormInline: the same frame without an overlay. -------------------------------------------------------------------------- */ /** A full-page create form, for example /schedules/new. The footer sticks to the bottom. */ export function FormPage(props: Omit) { return ; } /** A form panel in the page flow. Keep it for one or two fields next to what they change. */ export function FormInline(props: Omit) { return ; }