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.

@

Lowercase letters, numbers and dashes.

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.

Your account

  • At least 10 characters, not met
  • One number, not met
Workspace

You can invite your team once it is set up.

Shown in the sidebar and in invites.

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.

Company

Shown on every invoice.

Address

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.

Stay

Only for changes to your stay.

Payment

You are charged when you check in.

Card
Signature

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

PropTypeDefaultWhat it does
actionstring | ((data: FormData) => FormResult | Promise<FormResult>)noneA 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>noneRuns 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 | voidnoneChecks after the native constraints. Return error text by field name; each one is set with setCustomValidity on that control.
childrenReactNode | ((state: FormState) => ReactNode)noneA 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.
successMessagestring"Sent."Announced and shown under the form when a submit succeeds without its own message.
resetOnSuccessbooleanfalseResets the fields after a successful submit.
...restComponentProps<"form">noneEvery other native form attribute, including method, ref and onInput.
FormSection titleReactNodenoneThe section's legend.
FormSection descriptionReactNodenoneA line under the title, linked to the fieldset with aria-describedby.
FormSection ...restComponentProps<"fieldset">noneEvery native fieldset attribute, including disabled.
FormActions align"start" | "end" | "between""end"Where the buttons sit; between pushes the first one to the start.
FormActions stickybooleanfalsePins the row to the bottom of the scrolling area.
FormActions ...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-formThe form. data-pending while an action runs, data-submitted after the first try.
summary.eq-form-summaryError summary; takes focus after a failed submit.
summary title.eq-form-summary-title
summary text.eq-form-summary-textThe message of an action that threw.
summary list.eq-form-summary-list
summary link.eq-form-summary-linkMoves focus to the field.
summary field.eq-form-summary-fieldThe field's label inside a link.
status.eq-form-statusPolite live region with the result, always mounted.
section.eq-form-sectionFormSection, a fieldset.
section title.eq-form-section-titleThe legend.
section description.eq-form-section-description
section body.eq-form-section-body
actions.eq-form-actionsFormActions. data-align and data-sticky.

Keyboard

EnterIn a text field, submits the form, as natively.
TabFrom the focused summary, moves to the first error link.
EnterOn 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index