Flows
Form
A form that validates on submit, lists every problem in a linked summary and tracks pending and the result.
- Built on
<form>Constraint validationsetCustomValidity<fieldset>useTransitionposition: sticky- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 6.8 kB
- Dependencies
- React
Code
"use client";
import { Button } from "@/components/equip/button";
import { Form } from "@/components/equip/form";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Demo() {
return (
<Form
style={{ width: 224, gap: 12 }}
successMessage="You are on the list."
resetOnSuccess
action={async () => {
await wait(900);
}}
>
{({ pending }) => (
<div style={{ display: "grid", gap: 4 }}>
<label htmlFor="form-demo-email">Work email</label>
<div style={{ display: "flex", gap: 8 }}>
<input id="form-demo-email" name="email" type="email" required placeholder="ana@larder.example" autoComplete="email" />
<Button type="submit" variant="accent" loading={pending}>
Join
</Button>
</div>
</div>
)}
</Form>
);
}Examples
Server errors and pending
An async onSubmit drives the pending state. Save ana to get a field error back, which stays until the handle changes; save offline to see a thrown error reported in the form.
Code
"use client";
import { Button } from "@/components/equip/button";
import { Field } from "@/components/equip/field";
import { Form, FormActions } from "@/components/equip/form";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const taken = ["ana", "admin", "larder"];
export default function Example() {
return (
<Form
style={{ width: "100%", maxWidth: 320 }}
successMessage="Handle saved."
onSubmit={async (_event, data) => {
await wait(1200);
const handle = String(data.get("handle")).trim().toLowerCase();
if (handle === "offline") throw new Error("Could not reach the server. Check your connection and try again.");
if (taken.includes(handle)) return { errors: { handle: `${handle} is already taken.` } };
}}
>
{({ errors, pending }) => (
<>
<Field
id="form-server-handle"
name="handle"
label="Public handle"
hint="Lowercase letters, numbers and dashes."
lead="@"
required
pattern="[a-z0-9\-]{3,20}"
defaultValue="ana"
error={errors.handle}
/>
<FormActions>
<Button type="submit" variant="accent" loading={pending}>
Save handle
</Button>
</FormActions>
</>
)}
</Form>
);
}Sign-up form
Submit it empty: the summary takes focus and links to each field. Then try ana@example.com, which the pretend server says is taken.
Code
"use client";
import { Button } from "@/components/equip/button";
import { Checkbox } from "@/components/equip/checkbox";
import { Field } from "@/components/equip/field";
import { MultiSelect } from "@/components/equip/multi-select";
import { PasswordField } from "@/components/equip/password-field";
import { Select } from "@/components/equip/select";
import { Form, FormActions, FormSection } from "@/components/equip/form";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const rules = [
{ label: "At least 10 characters", test: (v: string) => v.length >= 10 },
{ label: "One number", test: (v: string) => /\d/.test(v) },
];
export default function Example() {
return (
<Form
style={{ width: "100%", maxWidth: 380 }}
successMessage="Account created. Check your inbox to confirm your email."
validate={(values) => {
const errors: Record<string, string> = {};
const local = String(values.email ?? "").split("@")[0].toLowerCase();
const password = String(values.password ?? "");
if (password && !rules.every((rule) => rule.test(password))) errors.password = "Use at least 10 characters, with a number.";
else if (local.length > 2 && password.toLowerCase().includes(local)) errors.password = "Do not use your email name in the password.";
if (String(values.workspace ?? "").trim().toLowerCase() === "admin") errors.workspace = "That name is reserved.";
return errors;
}}
action={async (data) => {
await wait(1400);
if (String(data.get("email")).toLowerCase().endsWith("@example.com")) {
return { errors: { email: "This email already has an account. Sign in instead." } };
}
}}
>
{({ errors, pending }) => (
<>
<FormSection title="Your account">
<Field id="signup-name" name="name" label="Full name" autoComplete="name" required error={errors.name} />
<Field id="signup-email" name="email" type="email" label="Work email" autoComplete="email" required error={errors.email} />
<PasswordField
id="signup-password"
name="password"
label="Password"
autoComplete="new-password"
required
requirements={rules}
error={errors.password}
/>
</FormSection>
<FormSection title="Workspace" description="You can invite your team once it is set up.">
<Field
id="signup-workspace"
name="workspace"
label="Workspace name"
hint="Shown in the sidebar and in invites."
required
minLength={3}
error={errors.workspace}
/>
<Select id="signup-size" name="size" label="Team size" defaultValue="" required error={errors.size}>
<option value="" disabled>
Choose a size
</option>
<option value="1">Just me</option>
<option value="2-10">2 to 10</option>
<option value="11-50">11 to 50</option>
<option value="51+">More than 50</option>
</Select>
<MultiSelect
label="What will you use it for"
name="uses"
required
display="count"
placeholder="Choose at least one"
options={["Project planning", "Bug tracking", "Customer support", "Roadmaps", "Docs and wikis"]}
error={errors.uses}
/>
</FormSection>
<Checkbox name="terms" required label="I agree to the terms of service" />
<FormActions>
<Button type="submit" variant="accent" loading={pending}>
Create account
</Button>
</FormActions>
</>
)}
</Form>
);
}Sections and sticky actions
Plain labels and inputs inside a Form get the shared look, and the sticky row stays in view while the panel scrolls.
Code
"use client";
import { Button } from "@/components/equip/button";
import { Form, FormActions, FormSection } from "@/components/equip/form";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Example() {
return (
<div
style={{
width: "100%",
maxWidth: 360,
height: 300,
overflow: "auto",
/* Room for the sticky row, so a field scrolled into view is not left under it. */
scrollPaddingBlock: "var(--eq-space-2) calc(var(--eq-h-m) + 2 * var(--eq-space-3) + var(--eq-space-2))",
padding: "16px 16px 0",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
boxSizing: "border-box",
}}
>
<Form
successMessage="Billing details saved."
action={async () => {
await wait(800);
}}
>
{({ pending }) => (
<>
<FormSection title="Company" description="Shown on every invoice.">
<label>
Legal name
<input name="company" required autoComplete="organization" defaultValue="Harbour Trading Ltd" />
</label>
<label>
VAT number
<input name="vat" pattern="[A-Z]{2}[0-9A-Z]{8,12}" placeholder="GB123456789" />
</label>
</FormSection>
<FormSection title="Address">
<label>
Street
<input name="street" required autoComplete="address-line1" />
</label>
<label>
City
<input name="city" required autoComplete="address-level2" />
</label>
<label>
Postcode
<input name="postcode" required autoComplete="postal-code" />
</label>
</FormSection>
<FormActions sticky align="between">
<Button type="reset" variant="ghost">
Reset
</Button>
<Button type="submit" variant="accent" loading={pending}>
Save billing
</Button>
</FormActions>
</>
)}
</Form>
</div>
);
}Booking checkout
Date picker, Phone field, Card field and Signature pad report through native validity, so one summary covers them all and each link lands on the right input.
Code
"use client";
import { Button } from "@/components/equip/button";
import { CardField } from "@/components/equip/card-field";
import { DatePicker } from "@/components/equip/date-picker";
import { Field } from "@/components/equip/field";
import { PhoneField } from "@/components/equip/phone-field";
import { SignaturePad } from "@/components/equip/signature-pad";
import { Form, FormActions, FormSection } from "@/components/equip/form";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Example() {
return (
<Form
style={{ width: "100%", maxWidth: 380 }}
successMessage="Booking confirmed. A receipt is on its way."
validate={(values) => {
if (values.check_in && values.check_out && values.check_out <= values.check_in) {
return { check_out: "Check-out must be after check-in." };
}
}}
action={async () => {
await wait(1200);
}}
>
{({ errors, pending }) => (
<>
<FormSection title="Stay">
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 12 }}>
<DatePicker label="Check-in" name="check_in" required min="2026-11-13" today="2026-11-13" error={errors.check_in} />
<DatePicker label="Check-out" name="check_out" required min="2026-11-14" today="2026-11-13" error={errors.check_out} />
</div>
<Field id="booking-guest" name="guest" label="Lead guest" autoComplete="name" required error={errors.guest} />
<PhoneField label="Phone" name="phone" required defaultCountry="GB" hint="Only for changes to your stay." error={errors.phone} />
</FormSection>
<FormSection title="Payment" description="You are charged when you check in.">
<CardField label="Card" required />
<SignaturePad label="Signature" name="signature" required />
</FormSection>
<FormActions>
<Button type="submit" variant="accent" loading={pending}>
Confirm booking
</Button>
</FormActions>
</>
)}
</Form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
action | string | ((data: FormData) => FormResult | Promise<FormResult>) | none | A URL submits natively once the form is valid. A function is called in a transition instead, with pending state and double submit blocked. |
onSubmit | (event, data: FormData) => FormResult | Promise<FormResult> | none | Runs only when the form is valid, before a function action, and the form then does not submit natively. Return or resolve { errors } or { message }. |
validate | (values: FormValues, data: FormData) => FormErrors | void | none | Checks after the native constraints. Return error text by field name; each one is set with setCustomValidity on that control. |
children | ReactNode | ((state: FormState) => ReactNode) | none | A function receives { errors, pending, submitted }, to pass error= to each field and loading= to the submit button. |
summaryTitle | (count: number) => string | "Fix 2 fields to continue" | Heading of the error summary. |
successMessage | string | "Sent." | Announced and shown under the form when a submit succeeds without its own message. |
resetOnSuccess | boolean | false | Resets the fields after a successful submit. |
...rest | ComponentProps<"form"> | none | Every other native form attribute, including method, ref and onInput. |
FormSection title | ReactNode | none | The section's legend. |
FormSection description | ReactNode | none | A line under the title, linked to the fieldset with aria-describedby. |
FormSection ...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute, including disabled. |
FormActions align | "start" | "end" | "between" | "end" | Where the buttons sit; between pushes the first one to the start. |
FormActions sticky | boolean | false | Pins the row to the bottom of the scrolling area. |
FormActions ...rest | ComponentProps<"div"> | none | Every native div attribute. |
Anatomy
| root | .eq-form | The form. data-pending while an action runs, data-submitted after the first try. |
|---|---|---|
| summary | .eq-form-summary | Error summary; takes focus after a failed submit. |
| summary title | .eq-form-summary-title | |
| summary text | .eq-form-summary-text | The message of an action that threw. |
| summary list | .eq-form-summary-list | |
| summary link | .eq-form-summary-link | Moves focus to the field. |
| summary field | .eq-form-summary-field | The field's label inside a link. |
| status | .eq-form-status | Polite live region with the result, always mounted. |
| section | .eq-form-section | FormSection, a fieldset. |
| section title | .eq-form-section-title | The legend. |
| section description | .eq-form-section-description | |
| section body | .eq-form-section-body | |
| actions | .eq-form-actions | FormActions. data-align and data-sticky. |
Keyboard
| Enter | In a text field, submits the form, as natively. |
|---|---|
| Tab | From the focused summary, moves to the first error link. |
| Enter | On an error link, moves focus to that field and scrolls it into view. |
Notes
- The form sets noValidate and runs the browser's own checks itself, so required, type, pattern and min still apply and their messages are the browser's, in its language.
- On a failed submit the summary is focused and read out; each link names the field by its label and moves focus to it.
- Radios and checkboxes sharing a name get one entry, named by their fieldset legend.
- A part that shows an unnamed input and submits through a hidden one beside it, such as Date picker or Phone field, has its errors filed under the hidden input's name, and its link goes to the visible input.
- After the first failed submit the summary follows every edit, so it shrinks as problems are fixed.
- Errors returned by the action stay until that field's value changes; a thrown error shows its message in the summary instead of reaching an error boundary.
- While a function action or async onSubmit runs, the form is aria-busy and further submits are ignored; pass pending to the submit button's loading prop.
- The result is announced through a polite status region that is always in the page.
- Plain labels, inputs, selects and textareas inside the form get the shared text-entry look.
- A reset clears the summary, the result and any errors returned by the action.
- A sticky FormActions needs a scrolling ancestor and sits on the surface colour, so give the panel that background too.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/form.tsx413 lines
"use client";
import { useEffect, useId, useRef, useState, useTransition } from "react";
import type { ComponentProps, MouseEvent, ReactNode, SubmitEvent } from "react";
import "./form.css";
/** Every named value in the form. A name that appears more than once, such as a checkbox group, is an array. */
export type FormValues = Record<string, string | string[]>;
/** Error text by field name. */
export type FormErrors = Record<string, string>;
/** What a submit handler may return: field errors from the server, or a message to announce. */
export type FormResult = void | undefined | { errors?: FormErrors; message?: string };
export type FormState = {
/** Error text by field name, from native constraints, validate and the last result. */
errors: FormErrors;
/** True while an async action or onSubmit is running. */
pending: boolean;
/** True once a submit has been tried. */
submitted: boolean;
};
export type FormProps = Omit<ComponentProps<"form">, "action" | "onSubmit" | "children" | "noValidate"> & {
/** A URL submits natively once the form is valid. A function runs in a transition, with pending state, instead. */
action?: string | ((data: FormData) => FormResult | Promise<FormResult>);
/** Runs only when the form is valid. Return or resolve { errors } to show server errors. */
onSubmit?: (event: SubmitEvent<HTMLFormElement>, data: FormData) => FormResult | Promise<FormResult>;
/** Extra checks after the native constraints. Return error text by field name. */
validate?: (values: FormValues, data: FormData) => FormErrors | void;
/** Heading of the error summary. */
summaryTitle?: (count: number) => string;
/** Announced, and shown under the form, after a successful submit. */
successMessage?: string;
/** Clears the fields after a successful submit. */
resetOnSuccess?: boolean;
/** Plain nodes, or a function that receives the errors and pending state to pass on to fields and buttons. */
children?: ReactNode | ((state: FormState) => ReactNode);
};
type Entry = { key: string; label: string; message: string };
type Control = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
const FOCUSABLE =
'input:not([type="hidden"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])';
function isControl(el: Element): el is Control {
return el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement;
}
function toValues(data: FormData): FormValues {
const values: FormValues = {};
for (const [key, entry] of data) {
const text = typeof entry === "string" ? entry : entry.name;
const had = values[key];
values[key] = had === undefined ? text : Array.isArray(had) ? [...had, text] : [had, text];
}
return values;
}
/** Readable text of a label, without the text of controls or hidden decoration nested inside it. */
function textOf(el: Element | null | undefined) {
if (!el) return "";
const copy = el.cloneNode(true) as Element;
for (const inner of copy.querySelectorAll('button, input, select, textarea, [aria-hidden="true"]')) inner.remove();
return copy.textContent?.replace(/\s+/g, " ").trim() || "";
}
/** The name a person would recognise: the group legend for radios, else the control's own label. */
function labelOf(el: Control, key: string): string {
const root = el.ownerDocument;
if (el instanceof HTMLInputElement && (el.type === "radio" || el.type === "checkbox")) {
const legend = textOf(el.closest("fieldset")?.querySelector(":scope > legend"));
if (legend && el.form?.querySelectorAll(`[name="${CSS.escape(el.name)}"]`).length !== 1) return legend;
}
const labelledBy = el.getAttribute("aria-labelledby");
if (labelledBy) {
const text = labelledBy.split(/\s+/).map((id) => textOf(root.getElementById(id))).join(" ").trim();
if (text) return text;
}
const aria = el.getAttribute("aria-label");
if (aria && el.getAttribute("aria-hidden") !== "true") return aria;
const own = textOf(el.labels?.[0]);
if (own) return own;
// A hidden input carries the value of a composite part; its label sits beside it in the part root.
const near = textOf(el.parentElement?.querySelector("label, legend"));
return near || key;
}
/** Where a summary link sends focus: the control itself, the checked radio, or the nearest focusable control in its part. */
function targetFor(form: HTMLFormElement, key: string): HTMLElement | null {
const item = form.elements.namedItem(key);
let el: Element | null = null;
if (item instanceof RadioNodeList) {
const all = Array.from(item);
el = all.find((node) => node instanceof HTMLInputElement && node.checked) ?? all[0] ?? null;
} else el = item;
if (!(el instanceof HTMLElement)) return null;
const hidden = el instanceof HTMLInputElement && el.type === "hidden";
if (!hidden && !el.matches(":disabled")) return el;
// A hidden value: go to the part's invalid control if it has one, else its first control.
const root = el.parentElement;
return (
root?.querySelector<HTMLElement>(':is(input:not([type="hidden"]), select, textarea):invalid') ??
root?.querySelector<HTMLElement>(FOCUSABLE) ??
null
);
}
/**
* The name a control's error is filed under. A composite part may show a visible input without a
* name and submit through a hidden one beside it; the error then belongs to the hidden one's name.
*/
function keyOf(el: Control): string {
if (el.name) return el.name;
let parent = el.parentElement;
for (let depth = 0; depth < 3 && parent && parent !== el.form; depth++, parent = parent.parentElement) {
const hidden = parent.querySelector<HTMLInputElement>(':scope > input[type="hidden"][name]');
if (hidden) return hidden.name;
}
return el.id;
}
const same = (a: Entry[], b: Entry[]) =>
a.length === b.length && a.every((e, i) => e.key === b[i].key && e.message === b[i].message && e.label === b[i].label);
export function Form({
action,
onSubmit,
validate,
summaryTitle = (count) => (count === 1 ? "Fix 1 field to continue" : `Fix ${count} fields to continue`),
successMessage = "Sent.",
resetOnSuccess = false,
className,
children,
ref,
onInput,
onChange,
onClick,
onKeyUp,
onReset,
...rest
}: FormProps) {
const id = useId();
const formRef = useRef<HTMLFormElement | null>(null);
const summaryRef = useRef<HTMLDivElement>(null);
const locked = useRef(false);
const custom = useRef<Control[]>([]);
/** Errors from a submit result, kept until the field's value changes. */
const server = useRef<Record<string, { message: string; value: string }>>({});
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [entries, setEntries] = useState<Entry[]>([]);
const [failure, setFailure] = useState("");
const [message, setMessage] = useState("");
const [submitted, setSubmitted] = useState(false);
const [attempt, setAttempt] = useState(0);
const [pending, startTransition] = useTransition();
// After a failed submit the summary takes focus, so it is read out and the links are one Tab away.
useEffect(() => {
if (attempt > 0) summaryRef.current?.focus();
}, [attempt]);
useEffect(() => () => clearTimeout(timer.current), []);
/** Runs native constraints and validate, marks custom errors on their controls, and returns the list in DOM order. */
function check(form: HTMLFormElement): Entry[] {
for (const el of custom.current) el.setCustomValidity("");
custom.current = [];
const data = new FormData(form);
const values = toValues(data);
const extra: FormErrors = { ...(validate?.(values, data) ?? {}) };
for (const [key, saved] of Object.entries(server.current)) {
const now = values[key];
const text = Array.isArray(now) ? now.join("\n") : (now ?? "");
if (text === saved.value) extra[key] ??= saved.message;
else delete server.current[key];
}
for (const [key, text] of Object.entries(extra)) {
if (!text) continue;
const item = form.elements.namedItem(key);
const el = item instanceof RadioNodeList ? item[0] : item;
// A control whose own part already set a message keeps it; only messages set here are cleared later.
if (el && isControl(el) && el.willValidate && !el.validity.customError) {
el.setCustomValidity(text);
custom.current.push(el);
}
}
const list: Entry[] = [];
const seen = new Set<string>();
for (const el of Array.from(form.elements)) {
if (!isControl(el)) continue;
const key = keyOf(el);
if (!key || seen.has(key)) continue;
const text = extra[key] || (el.willValidate && !el.validity.valid ? el.validationMessage : "");
if (!text) continue;
seen.add(key);
list.push({ key, label: labelOf(el, key), message: text });
}
// Errors for names no control carries still show, under their own name.
for (const [key, text] of Object.entries(extra)) {
if (text && !seen.has(key)) list.push({ key, label: key, message: text });
}
return list;
}
function show(list: Entry[]) {
setEntries((prev) => (same(prev, list) ? prev : list));
}
function remember(form: HTMLFormElement, errors: FormErrors) {
const values = toValues(new FormData(form));
for (const [key, text] of Object.entries(errors)) {
const now = values[key];
server.current[key] = { message: text, value: Array.isArray(now) ? now.join("\n") : (now ?? "") };
}
}
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
const form = event.currentTarget;
// The ref answers at once; pending state lags a fast double click by a render.
if (locked.current) {
event.preventDefault();
return;
}
setMessage("");
setFailure("");
setSubmitted(true);
const list = check(form);
show(list);
if (list.length > 0) {
event.preventDefault();
setAttempt((n) => n + 1);
return;
}
if (typeof action !== "function" && !onSubmit) return; // A URL, or the page itself: let the browser submit.
event.preventDefault();
locked.current = true;
const data = new FormData(form, event.nativeEvent.submitter);
startTransition(async () => {
try {
const results = [onSubmit ? await onSubmit(event, data) : undefined];
if (typeof action === "function") results.push(await action(data));
const errors: FormErrors = Object.assign({}, ...results.map((r) => r?.errors ?? {}));
if (Object.keys(errors).length > 0) {
remember(form, errors);
show(check(form));
setAttempt((n) => n + 1);
return;
}
if (resetOnSuccess) {
form.reset();
setSubmitted(false);
}
setMessage(results.map((r) => r?.message).find(Boolean) ?? successMessage);
} catch (error) {
// Caught here so a failed request reports in the form rather than reaching an error boundary.
setFailure(error instanceof Error && error.message ? error.message : "Something went wrong. Try again.");
setAttempt((n) => n + 1);
} finally {
locked.current = false;
}
});
}
/** Once a submit has failed, the summary follows every edit, a tick later so composite parts have settled. */
function recheck() {
if (!submitted || locked.current) return;
clearTimeout(timer.current);
timer.current = setTimeout(() => {
if (formRef.current) show(check(formRef.current));
}, 0);
}
function focusField(event: MouseEvent<HTMLAnchorElement>, key: string) {
event.preventDefault();
const form = formRef.current;
const target = form && targetFor(form, key);
if (!target) return;
target.focus({ preventScroll: true });
target.scrollIntoView({ block: "center", behavior: "smooth" });
}
const errors: FormErrors = Object.fromEntries(entries.map((e) => [e.key, e.message]));
const titleId = `${id}-summary`;
const showSummary = entries.length > 0 || failure !== "";
return (
<form
{...rest}
ref={(node) => {
formRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
noValidate
action={typeof action === "string" ? action : undefined}
className={["eq-form", className].filter(Boolean).join(" ")}
aria-busy={pending || undefined}
data-pending={pending || undefined}
data-submitted={submitted || undefined}
onSubmit={handleSubmit}
onInput={(event) => {
onInput?.(event);
recheck();
}}
onChange={(event) => {
onChange?.(event);
recheck();
}}
onClick={(event) => {
onClick?.(event);
recheck();
}}
onKeyUp={(event) => {
onKeyUp?.(event);
recheck();
}}
onReset={(event) => {
onReset?.(event);
// A reset starts over: no summary, no result, no stored server errors.
clearTimeout(timer.current);
for (const el of custom.current) el.setCustomValidity("");
custom.current = [];
server.current = {};
setEntries([]);
setFailure("");
setMessage("");
setSubmitted(false);
}}
>
{showSummary && (
<div className="eq-form-summary" ref={summaryRef} tabIndex={-1} aria-labelledby={titleId}>
<p className="eq-form-summary-title" id={titleId}>
{failure && entries.length === 0 ? "The form was not sent" : summaryTitle(entries.length)}
</p>
{failure && <p className="eq-form-summary-text">{failure}</p>}
{entries.length > 0 && (
<ul className="eq-form-summary-list">
{entries.map((entry) => (
<li key={entry.key}>
<a className="eq-form-summary-link" href={`#${entry.key}`} onClick={(event) => focusField(event, entry.key)}>
<span className="eq-form-summary-field">{entry.label}:</span> {entry.message}
</a>
</li>
))}
</ul>
)}
</div>
)}
{typeof children === "function" ? children({ errors, pending, submitted }) : children}
{/* Always mounted, so the message is announced when it arrives. */}
<p className="eq-form-status" role="status">
{message && (
<>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4 8.5 2.5 2.5L12 5.5" />
</svg>
{message}
</>
)}
</p>
</form>
);
}
export type FormSectionProps = Omit<ComponentProps<"fieldset">, "title"> & {
/** The section's legend. */
title: ReactNode;
/** A line under the title, read as the group's description. */
description?: ReactNode;
};
export function FormSection({ title, description, className, children, "aria-describedby": describedBy, ...rest }: FormSectionProps) {
const id = useId();
const descriptionId = description ? `${id}-description` : undefined;
return (
<fieldset
{...rest}
className={["eq-form-section", className].filter(Boolean).join(" ")}
aria-describedby={[describedBy, descriptionId].filter(Boolean).join(" ") || undefined}
>
<legend className="eq-form-section-title">{title}</legend>
{description && (
<p className="eq-form-section-description" id={descriptionId}>
{description}
</p>
)}
<div className="eq-form-section-body">{children}</div>
</fieldset>
);
}
export type FormActionsProps = ComponentProps<"div"> & {
/** Where the buttons sit. "between" pushes the first button to the start. */
align?: "start" | "end" | "between";
/** Keeps the row at the bottom of the scrolling area while the form scrolls under it. */
sticky?: boolean;
};
export function FormActions({ align = "end", sticky = false, className, ...rest }: FormActionsProps) {
return (
<div
{...rest}
className={["eq-form-actions", className].filter(Boolean).join(" ")}
data-align={align}
data-sticky={sticky || undefined}
/>
);
}components/equip/form.css227 lines
@layer equip {
.eq-form {
display: grid;
gap: var(--eq-space-4);
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
/* While an action runs the fields stay readable but stop taking clicks. */
.eq-form[data-pending] {
cursor: progress;
}
/* ---- error summary ---- */
.eq-form-summary {
display: grid;
gap: var(--eq-space-1);
padding: var(--eq-space-3) var(--eq-space-4);
border: 1px solid var(--eq-danger);
border-radius: var(--eq-radius-m);
background: color-mix(in srgb, var(--eq-danger) 7%, transparent);
opacity: 1;
translate: 0 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-form-summary {
opacity: 0;
translate: 0 -4px;
}
}
.eq-form-summary:focus-visible,
.eq-form-summary:focus {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-form-summary-title {
margin: 0;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-m) / 1.3 var(--eq-font);
}
.eq-form-summary-text {
margin: 0;
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-form-summary-list {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-form-summary-link {
display: inline-block;
min-height: 24px;
padding-block: 3px;
border-radius: var(--eq-radius-s);
color: var(--eq-danger);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
}
.eq-form-summary-field {
font-weight: var(--eq-weight-strong);
}
@media (hover: hover) {
.eq-form-summary-link:hover {
text-decoration-thickness: 2px;
}
}
.eq-form-summary-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* ---- result ---- */
.eq-form-status {
display: flex;
align-items: center;
gap: var(--eq-space-1);
margin: 0;
color: var(--eq-ok);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-form-status > svg {
flex: none;
width: 16px;
height: 16px;
}
/* Empty, it stays in the accessibility tree as a live region but takes no room. */
.eq-form-status:empty {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
}
/* ---- section ---- */
/* Block layout: a rendered legend is not a grid item, so spacing comes from margins. */
.eq-form-section {
display: block;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
/* Sections are set apart by a line, so long forms read as chapters. It sits on the bottom edge:
a top border would run through the legend. */
.eq-form-section:has(+ .eq-form-section) {
padding-bottom: var(--eq-space-4);
border-bottom: 1px solid var(--eq-line);
}
.eq-form-section-title {
margin: 0 0 var(--eq-space-3);
padding: 0;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-m) / 1.3 var(--eq-font);
}
.eq-form-section-description {
margin: calc(var(--eq-space-2) * -1) 0 var(--eq-space-3);
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-form-section-body {
display: grid;
gap: var(--eq-space-3);
min-width: 0;
}
/* ---- actions ---- */
.eq-form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--eq-space-2);
}
.eq-form-actions[data-align="start"] {
justify-content: flex-start;
}
.eq-form-actions[data-align="between"] > :first-child {
margin-inline-end: auto;
}
.eq-form-actions[data-sticky] {
position: sticky;
bottom: 0;
z-index: 1;
margin-inline: calc(var(--eq-space-4) * -1);
padding: var(--eq-space-3) var(--eq-space-4);
border-top: 1px solid var(--eq-line);
background: var(--eq-surface);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
}
/* ---- plain controls ---- */
/* Unclassed labels and controls get the shared look, so a quick form needs no other part. */
:where(.eq-form label:not([class])) {
display: grid;
gap: var(--eq-space-1);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
:where(.eq-form :is(input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="file"], [type="color"], [type="submit"], [type="button"], [type="reset"]), select, textarea):not([class])) {
box-sizing: border-box;
width: 100%;
height: var(--eq-h-m);
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
transition: border-color var(--eq-dur) var(--eq-ease);
}
:where(.eq-form textarea:not([class])) {
height: auto;
padding-block: var(--eq-space-2);
}
:where(.eq-form :is(input, select, textarea):not([class]))::placeholder {
color: var(--eq-ink-3);
}
:where(.eq-form :is(input, select, textarea):not([class]):focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
/* After a failed submit every invalid plain control is marked, not only the ones that were touched. */
:where(.eq-form :is(input, select, textarea):not([class]):user-invalid),
:where(.eq-form[data-submitted] :is(input, select, textarea):not([class]):invalid) {
border-color: var(--eq-danger);
}
}