f-ui
Design

Form Validation

Who validates (FE, BE, hybrid), where errors appear, and copy-paste recipes for f-ui Formily forms.

Use this page when wiring fill-form validation — required rules, reveal timing, where errors live (summary vs table rollup), async checks, and server issue mapping. Component APIs live on Form (Formily). Table cell vs summary ownership also lives on Object Messaging And Table Chrome.

When To Use

  • You need to choose frontend preview vs backend authority vs hybrid.
  • You need to know when a field error appears (submit / touch / always) — walk When Errors Show with the numbered Try steps.
  • You are composing header fields + Editable Table and need to know whether Form Error Summary stays on — walk Where Errors Appear.
  • You are about to set revealErrors, focusOnInvalid, or applyFormIssues.
  • You want a named recipe instead of hunting through Formily API tables.
  • You are mixing pretty / readOnly / editable on one Edit form, or wiring an approver inbox (Display until Unlock) — walk Mixed / Approver Lock. Lock mark placement and field vs surface scope: Field Lock Affordance.

1. Who Validates

ClassUse whenDo not
FE-onlyRequired, format, length, local cross-field — fully knowable in the browserTreat as a security boundary
BE-onlyAuthz, concurrency, credentials, payment channel, file content safetySkip server checks because the UI looked valid
HybridUniqueness, domain blacklist, budgets, anything that needs a round-trip and a good previewTrust async FE uniqueness alone

Locks: Server is authoritative for business invariants. Default hybrid = FE gate → submit → map FormIssue[] onto fields + Form Error Summary. Security-sensitive failures stay generic at form root (no “user does not exist” on a field).

2. Where Errors Appear

One error class → one aggregate owner. Field/cell chrome can sit next to that owner. Two red lists of the same paths is a bug.

SurfaceOwnsDoes not own
Field (Form Item)Header / panel field messagesTable cell paths
Form Error SummaryRoot / system (path omitted), header fields, array-root (lineItems with no .cell)Editable Table cell descendants (lineItems.0.sku)
Editable Table cell + region rollupThat table’s cell issues + jumpHeader fields; other tables’ cells
Message PopoverMulti-region / object readiness after Validate or blocked SubmitDraft auto-open; replacing cell chrome

Full visual language (cell value-state, toolbar, popover): Object Messaging And Table Chrome. Form Error Summary and the table region rollup share the same Primer Banner card (bg-card, 1px border, small status mark, foreground heading). They must not list the same cell paths.

When To Use Form Error Summary

Use it when the operator needs an index of non-table problems after a failed Submit (GOV.UK Error summary twin). One error class → one aggregate owner: Banner = header / array-root / root; rollup = cells; popover = multi-region object.

SituationSummary?How
Ordinary form, no Editable TableYesDefault scope="all" — lists every visible field + root issue, jump links
Form Page with header fields and line itemsYes (keep default)FormPage scope="all"; kit strips cell paths; header + array-root stay
Table-only region (no header / array-root / system rules)NoerrorSummary={false} — the rollup is the aggregate
System / unknown throw onlyRoot-onlyscope="form" (path == null only — not header fields, not lineItems)
Login / credentialsRoot-onlyOne generic root message. Do not mark which field was wrong — Security Sensitive Failures
Query Filter searchRoot-onlyShell default scope="form"
Modal with a tableRoot-only by defaultModal default scope="form" + focusOnInvalid="first-field"; cells on the rollup
Steps Form step that is an Editable TableNo on that steperrorSummary={false} — rollup owns cells
Many header errorsYesBanner caps height + thin scroll; jump still reaches the field — Many Header Field Errors
Many invalid cellsArray-root onlyBanner may show Some rows are invalid once. Cells stay in the rollup
WarningNoField (and optional Message Popover). Does not block Submit by itself
Object page, several regions, footer ValidateOptionalMessage Popover is the object index; do not also dump every cell into a page summary

scope="form" is not “everything except table cells.” It is only issues with no path.

Table Only — No Summary

No header rules. Mount no FormErrorSummary. After Submit, only the rollup lists cell problems.

Try this:

  1. The demo already submitted empty line 2. Confirm there is no page-level red banner above the table.
  2. Confirm the rollup under the table lists the bad SKU / Qty.
  3. Fix the cells. Confirm the rollup disappears.
SKU
Qty
Actions
SKU-11
0

Scope Form Vs All

Toggle scope. Empty Customer is a header path. “Could not reach the server” has no path.

Try this:

  1. Leave the switch off (scope="form"). Click Submit empty. Confirm: Customer’s inline error may show after submit, but the summary stays empty (no root issue yet).
  2. Type Ada in Customer. Click Submit. Confirm the summary shows only Could not reach the server.
  3. Turn the switch on (scope="all"). Clear Customer, Submit. Confirm the summary now lists the Customer required message. Fill Customer and Submit again — root system message and no extra cell dump.

Object Page — Message Popover

Several regions + footer Validate: Message Popover is the object index. Do not also dump every cell into a page summary.

Try this:

  1. Click Validate (or Submit). Confirm the popover opens with mixed error + warning items.
  2. Confirm this is a region/object list — not Form Error Summary repeating SKU strings.

Purchase request · PR-1042

Draft edits stay quiet. Click Validate to open the object message list.

"use client";

import { useState } from "react";

import {
  MessagePopover,
  type MessageItem,
} from "@/components/f-ui/message-popover/message-popover";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import { Button } from "@/components/ui/button";

const MESSAGES: MessageItem[] = [
  {
    id: "e1",
    severity: "error",
    title: "Missing unit price",
    subtitle: "Line 2 · Unit price",
    group: "Line items",
    description: "Enter a price greater than 0.",
  },
  {
    id: "w1",
    severity: "warning",
    title: "Qty looks high",
    subtitle: "Line 1 · Qty",
    group: "Line items",
    description: "Confirm quantity on line 1 before submit.",
  },
];

/**
 * Footer left = Message Popover; finalizing actions on the right.
 * Open after Validate / blocked Submit — not on every draft keystroke.
 */
export function ObjectMessagingFooterPopoverDemo() {
  const [open, setOpen] = useState(false);
  const [items] = useState(MESSAGES);

  return (
    <div className="overflow-hidden rounded-xl border bg-card">
      <div className="space-y-2 border-b px-6 py-3">
        <p className="text-sm font-medium">Purchase request · PR-1042</p>
        <p className="text-xs text-muted-foreground">
          Draft edits stay quiet. Click Validate to open the object message list.
        </p>
        <Button
          type="button"
          variant="outline"
          onClick={() => setOpen(true)}
        >
          Validate
        </Button>
      </div>
      <FooterToolbar
        className="border-t-0"
        extra={
          <MessagePopover
            items={items}
            open={open}
            onOpenChange={setOpen}
          />
        }
      >
        <Button type="button" variant="outline">
          Save draft
        </Button>
        <Button
          type="button"
          onClick={() => setOpen(true)}
        >
          Submit
        </Button>
      </FooterToolbar>
    </div>
  );
}

Steps Form — Table Step

A step that is an Editable Table: errorSummary={false}. Next still validates; the rollup owns cells.

Try this:

  1. Leave the SKU empty. Click Next.
  2. Confirm there is no Form Error Summary banner. Confirm the rollup lists the cell.
  3. Fix the SKU, Next, then Submit on Done.
SKU
Qty
Actions
0

How Each Error Shows

Error classExampleAfter failed Submit you should see
Header / panel fieldCustomer requiredForm Item chrome and a summary jump link
Array-root“Add at least one line” on lineItemsSummary — not a fake “Row N” rollup line
Table cellLine 2 SKU requiredCell value-state + rollup. Not in Form Error Summary
Root / system“Could not reach the server”Summary scope="form" or scope="all"
Server field issue on a headerapplyFormIssues { path: "customer" }Immediate Form Item chrome + summary
Server field issue on a cellapplyEditableArrayIssuesCell + rollup — never applyFormIssues under a mounted table
Warningseverity: "warning"Field (and optional Message Popover). Not in Form Error Summary. Does not block Submit by itself

Table cells do not follow FormItem Konjević timing. They stay hide-while-focused until reveal / Submit.

This demo already failed Submit: header, array-root, cells, root/system, and a warning — each on the locked surface.

Try this:

  1. In Form Error Summary, find Customer (header), Some rows are invalid (array-root), and the long Could not reach the server line (root). Confirm Memo’s warning is on the field, not in the Banner.
  2. Confirm SKU / Qty strings are not in the summary — they are under the table rollup.
  3. Fill Customer. Confirm that summary row drops; the root message and rollup stay until those are fixed.
SKU
Qty
Actions
SKU-12
0

Array Root Stays In The Summary

Empty table is an array-root rule, not a cell.

Try this:

  1. Type a Customer name so header validation passes.
  2. Leave no lines. Click Submit.
  3. Confirm Form Error Summary lists Add at least one line. Confirm there is no rollup Row N for that message.
  4. Click Add row, Submit again. Confirm the array-root message is gone and cell errors (if the new row is empty) move to the rollup.
SKU
Qty
Actions

Shell Defaults

ShellForm Error SummaryTable rollupFocus after invalid Submit
Form (primitive)Host composesOn with Editable Tablefalse unless you set it
Form PageDefault on, scope="all"On"summary"
Modal FormDefault scope="form"On"first-field"
Steps FormKeep on when the step has header/array-root; false when cells-onlyOnShell default
Query Filterscope="form""first-field"

Dialog summary is root-only. Cell errors must not become a long dialog essay.

Try this:

  1. Click Open line editor. Click the dialog Submit with the empty SKU.
  2. Confirm the rollup lists the cell. Confirm the dialog summary does not list SKU required (no root issue).
  3. Fix the line and Submit. Confirm the dialog can close.

Header Fields Plus Editable Table

This is the usual create/edit page: customer / dates on top, line items in an embedded table, one Submit.

Try this:

  1. Leave Customer empty. Leave line 2 SKU empty and Qty 0. Click Submit.
  2. Confirm Form Error Summary lists Customer (header). Confirm it does not list “SKU required” / “must be > 0” — those belong under the table.
  3. Confirm the rollup under the table lists the bad line(s) and jump links scroll/focus the cell.
  4. Type a customer name (no extra blur needed once shown). Confirm the summary row for Customer disappears; the rollup still lists line errors until those cells are valid.
  5. Click a rollup jump. Confirm the cell shows value-state on focus (not a second Input border, not a hover tooltip).
SKU
Qty
Actions
SKU-1042
0
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";

type Line = { id: string; sku: string; qty: number };

type Values = {
  customer: string;
  lines: Line[];
};

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 96,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 1 }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
  },
});

export function FormValidationHeaderPlusEditableTableDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: {
          customer: "",
          lines: [
            { id: "l1", sku: "SKU-104", qty: 2 },
            { id: "l2", sku: "", qty: 0 },
          ],
        },
      }),
    [],
  );

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        focusOnInvalid="summary"
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField
          name="customer"
          kind="text"
          label="Customer"
          required
        />
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={columns}
          getRowId={(row) => row.id}
          recordCreator={{
            record: () => ({
              id: crypto.randomUUID(),
              sku: "",
              qty: 1,
            }),
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Capstone with cross-row rules: Editable Table — Complete Order Entry. FormPage wiring: Form Page — Embedded Editable Table.

Extreme Cases

These demos are already in the failed-Submit state (or one click away) so you can see a long list, not imagine it.

Many Header Field Errors

16 empty required fields. The summary is the index: it scrolls (max-height + thin scrollbar), and jump links do not stop at “first three.” Submit stays enabled.

Try this:

  1. Scroll the summary — every field is listed, not truncated.
  2. Click a jump link near the bottom (e.g. External ref). Confirm the page moves to that field.
  3. Fill one field. Confirm that field’s row disappears from the summary. Do not expect Submit to disable.
"use client";

import type { Form as FormilyForm } from "@formily/core";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";

import { DesignCompare } from "@/demos/_design/design-compare";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { f } from "@/components/f-ui/field-types/catalog";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import {
  formErrorBannerBodyInnerClassName,
  formErrorBannerHeadingClassName,
  formErrorBannerLinkClassName,
  formErrorBannerRootClassName,
} from "@/components/f-ui/formily/internals/form-error-banner-chrome";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { setFormComposing } from "@/components/f-ui/formily/internals/form-state";
import { FormPage } from "@/components/f-ui/form-page/form-page";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { PageFooterSlotProvider } from "@/components/f-ui/page/page-footer-slot";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";

type Line = { id: string; sku: string; qty: number };

const lineColumns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 88,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 1 }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
  },
});

function submitCatch<T extends object>(form: FormilyForm<T>) {
  void form.submit(() => undefined).catch(() => undefined);
}

const MANY_HEADER_FIELDS = [
  "Customer",
  "PO number",
  "Buyer",
  "Ship-to",
  "Bill-to",
  "Incoterms",
  "Currency",
  "Payment terms",
  "Warehouse",
  "Carrier",
  "Contact name",
  "Contact email",
  "Cost center",
  "Project code",
  "Memo",
  "External ref",
] as const;

/** 16 empty required header fields — summary is the index and it scrolls. */
export function FormValidationManyHeaderErrorsDemo() {
  const initialValues = useMemo(() => {
    const values: Record<string, string> = {};
    for (const label of MANY_HEADER_FIELDS) {
      values[label.replace(/\s+/g, "")] = "";
    }
    return values;
  }, []);
  const form = useMemo(
    () => createForm({ initialValues }),
    [initialValues],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
          {MANY_HEADER_FIELDS.map((label) => (
            <FormField
              key={label}
              name={label.replace(/\s+/g, "")}
              kind="text"
              label={label}
              required
            />
          ))}
        </div>
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

function manyLines(count: number): Line[] {
  return Array.from({ length: count }, (_, i) => ({
    id: `r${i + 1}`,
    sku: i % 3 === 0 ? `SKU-${100 + i}` : "",
    qty: i % 3 === 0 ? 2 : 0,
  }));
}

/** 24 lines, most invalid — rollup scrolls; summary does not dump every cell. */
export function FormValidationManyCellErrorsDemo() {
  const form = useMemo(
    () =>
      createForm<{ customer: string; lines: Line[] }>({
        initialValues: {
          customer: "Northwind",
          lines: manyLines(24),
        },
      }),
    [],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField name="customer" kind="text" label="Customer" required />
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
          features={{ view: { mode: "all", maxHeight: 220 } }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Cells-only page: no FormErrorSummary — rollup is the aggregate. */
export function FormValidationTableOnlyNoSummaryDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "a", sku: "SKU-1", qty: 1 },
            { id: "b", sku: "", qty: 0 },
          ],
        },
      }),
    [],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** `scope="form"` keeps only path-less issues — not header fields. */
export function FormValidationSummaryScopeFormDemo() {
  const form = useMemo(
    () =>
      createForm<{ customer: string }>({
        initialValues: { customer: "" },
      }),
    [],
  );
  const [scope, setScope] = useState<"all" | "form">("form");

  return (
    <div className="w-full max-w-md space-y-4">
      <label className="flex items-center gap-2 text-sm">
        <Switch
          checked={scope === "all"}
          onCheckedChange={(on) => setScope(on ? "all" : "form")}
        />
        <span className="text-muted-foreground">
          {scope === "all" ? 'scope="all"' : 'scope="form" (root only)'}
        </span>
      </label>
      <Form
        key={scope}
        form={form}
        onSubmit={() => ({
          status: "error" as const,
          issues: [
            {
              source: "system" as const,
              message: "Could not reach the server",
            },
          ],
        })}
      >
        <FormErrorSummary scope={scope} />
        <FormField name="customer" kind="text" label="Customer" required />
        <FormActions>
          <Button type="submit">
            Submit empty
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Array-root “add at least one line” stays in the summary, not a fake Row N. */
export function FormValidationArrayRootDemo() {
  const form = useMemo(
    () =>
      createForm<{ customer: string; lines: Line[] }>({
        initialValues: { customer: "", lines: [] },
      }),
    [],
  );

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        focusOnInvalid="summary"
        onSubmit={(values) => {
          if (values.lines.length === 0) {
            return {
              status: "error" as const,
              issues: [
                {
                  source: "submit" as const,
                  path: "lines",
                  message: "Add at least one line",
                },
              ],
            };
          }
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField name="customer" kind="text" label="Customer" required />
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
          recordCreator={{
            record: () => ({
              id: crypto.randomUUID(),
              sku: "",
              qty: 1,
            }),
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Defer the loud rollup while drafting. */
export function FormValidationDraftRollupDemo() {
  const [showRollup, setShowRollup] = useState(false);
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "d1", sku: "", qty: 0 },
            { id: "d2", sku: "", qty: 0 },
          ],
        },
      }),
    [],
  );

  return (
    <div className="w-full max-w-2xl space-y-4">
      <label className="flex items-center gap-2 text-sm">
        <Switch checked={showRollup} onCheckedChange={setShowRollup} />
        <span className="text-muted-foreground">
          showErrorRollup={String(showRollup)}
        </span>
      </label>
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
          showErrorRollup={showRollup}
        />
        <FormActions>
          <Button type="submit">
            Validate
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Two tables → two rollups. */
export function FormValidationTwoTablesDemo() {
  const form = useMemo(
    () =>
      createForm<{ charges: Line[]; notes: Line[] }>({
        initialValues: {
          charges: [{ id: "c1", sku: "", qty: 0 }],
          notes: [{ id: "n1", sku: "", qty: 0 }],
        },
      }),
    [],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-6">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <p className="text-muted-foreground text-sm font-medium">Charges</p>
        <EditableTable<Line>
          name="charges"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
        />
        <p className="text-muted-foreground text-sm font-medium">Notes</p>
        <EditableTable<Line>
          name="notes"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Search hides an invalid row — Submit still fails; jump reveals it. */
export function FormValidationHiddenRowSearchDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "ok", sku: "WIRE-1", qty: 4 },
            { id: "bad", sku: "", qty: 0 },
            { id: "ok2", sku: "WIRE-2", qty: 2 },
          ],
        },
      }),
    [],
  );

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
          features={{ search: true }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Dual scream vs one owner — wrong is stacked Banners of the same SKU, not old pink Alert chrome. */
export function FormValidationDualScreamCompareDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-2 text-sm">
          <div className={formErrorBannerRootClassName} role="presentation">
            <p className={formErrorBannerHeadingClassName}>There is a problem</p>
            <ul className={cn(formErrorBannerBodyInnerClassName, "space-y-1")}>
              <li>
                <span className={formErrorBannerLinkClassName}>SKU is required</span>
              </li>
              <li>
                <span className={formErrorBannerLinkClassName}>
                  Qty must be &gt; 0
                </span>
              </li>
            </ul>
          </div>
          <div className={formErrorBannerRootClassName}>
            <p className={formErrorBannerHeadingClassName}>
              1 row(s) need attention
            </p>
            <p
              className={cn(
                formErrorBannerBodyInnerClassName,
                formErrorBannerLinkClassName,
                "text-xs",
              )}
            >
              Row 2 · SKU: SKU is required
            </p>
          </div>
        </div>
      }
      right={<FormValidationTableOnlyNoSummaryDemo />}
    />
  );
}

export function FormValidationImeSimulateDemo() {
  const form = useMemo(
    () => createForm<{ workEmail: string }>({ initialValues: { workEmail: "" } }),
    [],
  );

  return (
    <div className="w-full max-w-md space-y-4">
      <Form
        form={form}
        revealErrors="touch"
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormField
          name="workEmail"
          kind="email"
          label="Work email"
          required
        />
        <FormActions>
          <Button
            type="button"
            variant="outline"
            onClick={() => setFormComposing(form, true)}
          >
            Start composing
          </Button>
          <Button
            type="button"
            variant="outline"
            onClick={() => setFormComposing(form, false)}
          >
            End composing
          </Button>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

export function FormValidationSecurityRootDemo() {
  const form = useMemo(
    () =>
      createForm<{ email: string; password: string }>({
        initialValues: { email: "", password: "" },
      }),
    [],
  );

  return (
    <div className="w-full max-w-md space-y-4">
      <Form
        form={form}
        onSubmit={() => ({
          status: "error" as const,
          issues: [
            {
              source: "server" as const,
              message: "Email or password is incorrect.",
            },
          ],
        })}
      >
        <FormErrorSummary title="Sign in failed" />
        <FormField name="email" kind="email" label="Email" required />
        <FormField
          name="password"
          kind="password"
          label="Password"
          required
        />
        <FormActions>
          <Button type="submit">
            Sign in
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

type ModalLines = { lines: Line[] };

export function FormValidationModalTableDemo() {
  return (
    <ModalForm<ModalLines>
      trigger={<Button variant="outline">Open line editor</Button>}
      title="Line items"
      description="Dialog default summary is root-only. Cell errors stay on the rollup."
      initialValues={{
        lines: [{ id: "m1", sku: "", qty: 0 }],
      }}
      onFinish={async () => {
        toast.success("Saved");
      }}
    >
      <EditableTable<Line>
        name="lines"
        variant="embedded"
        columns={lineColumns}
        getRowId={(row) => row.id}
      />
    </ModalForm>
  );
}

const ALL_CLASS_ISSUES: FormIssue[] = [
  {
    source: "system",
    message:
      "Could not reach the server. Retry in a few minutes. This line is intentionally long so you can see the summary wrap and scroll instead of truncating.",
  },
  {
    source: "server",
    path: "memo",
    severity: "warning",
    message: "Memo is empty — the default text will be used.",
  },
];

function mergeAfterInvalidSubmit<T extends object>(
  form: FormilyForm<T>,
  extra: FormIssue[],
) {
  void form
    .submit(() => undefined)
    .catch(() => {
      applyFormIssues(form, extra, { mode: "merge" });
    });
}

/**
 * One Submit: header, array-root, cells, root/system, warning — each on its
 * locked surface (summary vs rollup).
 */
export function FormValidationAllClassesDemo() {
  const form = useMemo(
    () =>
      createForm<{ customer: string; memo: string; lines: Line[] }>({
        initialValues: {
          customer: "",
          memo: "",
          lines: [
            { id: "ok", sku: "SKU-1", qty: 2 },
            { id: "bad", sku: "", qty: 0 },
          ],
        },
      }),
    [],
  );

  useEffect(() => {
    mergeAfterInvalidSubmit(form, ALL_CLASS_ISSUES);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField name="customer" kind="text" label="Customer" required />
        <FormField name="memo" kind="text" label="Memo" />
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
        />
        <FormActions>
          <Button type="submit">
            Submit again
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

const VOLUME_HEADERS = [
  "Customer",
  "PO number",
  "Buyer",
  "Ship-to",
  "Warehouse",
  "Carrier",
  "Cost center",
  "External ref",
] as const;

/** Header volume + cell volume + root + warning in one failed Submit. */
export function FormValidationCombinedVolumeDemo() {
  const initialValues = useMemo(() => {
    const values: Record<string, string | Line[]> = {};
    for (const label of VOLUME_HEADERS) {
      values[label.replace(/\s+/g, "")] = "";
    }
    values.memo = "";
    values.lines = manyLines(16);
    return values;
  }, []);
  const form = useMemo(
    () => createForm({ initialValues }),
    [initialValues],
  );

  useEffect(() => {
    mergeAfterInvalidSubmit(form, ALL_CLASS_ISSUES);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
          {VOLUME_HEADERS.map((label) => (
            <FormField
              key={label}
              name={label.replace(/\s+/g, "")}
              kind="text"
              label={label}
              required
            />
          ))}
        </div>
        <FormField name="memo" kind="text" label="Memo" />
        <EditableTable<Line>
          name="lines"
          variant="embedded"
          columns={lineColumns}
          getRowId={(row) => row.id}
          features={{ view: { mode: "all", maxHeight: 200 } }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

type Claim = { id: string; qty: number; note: string };
type NestedLine = {
  id: string;
  sku: string;
  claims: Claim[];
};

const nestedParentColumns = defineEditableColumns<NestedLine>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: { component: [Input], required: true },
  },
});

const nestedClaimColumns = defineEditableColumns<Claim>({
  qty: {
    ...f.number({ label: "Claim qty" }),
    size: 110,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 1 }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
  },
  note: {
    ...f.text({ label: "Note" }),
    field: { component: [Input] },
  },
});

/** Parent cell + nested child cell — two rollup languages, still not in the page summary. */
export function FormValidationNestedTableErrorsDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: NestedLine[] }>({
        initialValues: {
          lines: [
            {
              id: "p1",
              sku: "A-100",
              claims: [{ id: "c1", qty: 0, note: "" }],
            },
            {
              id: "p2",
              sku: "",
              claims: [],
            },
          ],
        },
      }),
    [],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <EditableTable<NestedLine>
          name="lines"
          variant="embedded"
          columns={nestedParentColumns}
          getRowId={(row) => row.id}
          nested={{
            path: "claims",
            columns: nestedClaimColumns,
            getChildRowId: (c) => c.id,
            defaultExpanded: true,
            recordCreator: {
              record: () => ({
                id: crypto.randomUUID(),
                qty: 1,
                note: "",
              }),
            },
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

/** Server / submit / system issues bypass the reveal gate — no Submit, no blur. */
export function FormValidationServerImmediateDemo() {
  const form = useMemo(
    () =>
      createForm<{ email: string; warehouse: string }>({
        initialValues: { email: "ada@example.com", warehouse: "" },
      }),
    [],
  );

  return (
    <div className="w-full max-w-md space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField name="email" kind="email" label="Email" required />
        <FormField name="warehouse" kind="text" label="Warehouse" required />
        <FormActions>
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              applyFormIssues(form, [
                {
                  source: "server",
                  path: "email",
                  message: "Email is already registered.",
                },
              ]);
            }}
          >
            Apply server issue
          </Button>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

export function FormValidationFormPageShellDemo() {
  const [slot, setSlot] = useState<HTMLElement | null>(null);
  const form = useMemo(
    () =>
      createForm<{ title: string; warehouse: string; lines: Line[] }>({
        initialValues: {
          title: "",
          warehouse: "",
          lines: [{ id: "p1", sku: "", qty: 0 }],
        },
      }),
    [],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <PageFooterSlotProvider target={slot}>
      <div className="overflow-hidden rounded-xl border bg-card">
        <FormPage
          form={form}
          footerVisible
          density="compact"
          className="p-4"
          onFinish={() => {
            toast.success("Saved");
          }}
        >
          <FormField name="title" kind="text" label="Order title" required />
          <FormField name="warehouse" kind="text" label="Warehouse" required />
          <EditableTable<Line>
            name="lines"
            variant="embedded"
            columns={lineColumns}
            getRowId={(row) => row.id}
          />
        </FormPage>
        <div ref={setSlot} />
      </div>
    </PageFooterSlotProvider>
  );
}

export function FormValidationStepsTableDemo() {
  return (
    <div className="w-full max-w-2xl">
      <StepsForm
        initialValues={{
          lines: [{ id: "s1", sku: "", qty: 0 }],
        }}
        errorSummary={false}
        onFinish={async () => {
          toast.success("Queued");
        }}
      >
        <StepsForm.Step name="lines" title="Lines">
          <EditableTable<Line>
            name="lines"
            variant="embedded"
            columns={lineColumns}
            getRowId={(row) => row.id}
          />
        </StepsForm.Step>
        <StepsForm.Step name="done" title="Done">
          <p className="text-muted-foreground text-sm">
            Last step — write API would run on Submit here.
          </p>
        </StepsForm.Step>
      </StepsForm>
    </div>
  );
}

const I18N_COPY = {
  en: {
    required: "Account code is required",
    pattern: "Use letters and digits only",
  },
  zh: {
    required: "必须填写账号代码",
    pattern: "只能使用字母和数字",
  },
} as const;

export function FormValidationI18nDemo() {
  const [lang, setLang] = useState<"en" | "zh">("en");
  const copy = I18N_COPY[lang];
  const form = useMemo(
    () =>
      createForm<{ email: string; code: string }>({
        initialValues: { email: "", code: "" },
      }),
    [lang],
  );

  useEffect(() => {
    submitCatch(form);
  }, [form]);

  return (
    <div className="w-full max-w-md space-y-4">
      <label className="flex items-center gap-2 text-sm">
        <Switch
          checked={lang === "zh"}
          onCheckedChange={(on) => setLang(on ? "zh" : "en")}
        />
        <span className="text-muted-foreground">
          Custom copy: {lang === "zh" ? "zh" : "en"} (email format follows the
          docs site language)
        </span>
      </label>
      <Form
        key={lang}
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField name="email" kind="email" label="Email" required />
        <FormField
          name="code"
          kind="text"
          label="Account code"
          required
          validator={(value) => {
            if (!value) return copy.required;
            if (!/^[A-Za-z0-9]+$/.test(String(value))) return copy.pattern;
            return undefined;
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Many Invalid Line Cells

24 lines, most invalid. Summary may show the array-root Some rows are invalid (one line). The rollup lists cells and scrolls. It does not dump 40 SKU messages into the page summary.

Try this:

  1. Confirm Form Error Summary does not repeat every SKU/Qty string.
  2. Scroll the rollup under the table. Confirm many Row N · SKU/Qty links.
  3. Click a jump for a row that is below the table fold. Confirm the table scrolls and the cell focuses.
  4. Fill Customer is already set — this is a table-volume problem, not a missing header.
SKU
Qty
Actions
SKU-1002
0
0
SKU-1032
0
0
SKU-1062
0
0
SKU-1092
0
0
SKU-1122
0
0
SKU-1152
0
0
SKU-1182
0
0
SKU-1212
0
0

Submit validates the full array, not the visible page.

Try this:

  1. In the table search box, type WIRE. Confirm the empty-SKU row disappears from the view.
  2. Click Submit. Confirm it still fails and the rollup still lists the hidden row.
  3. Click the rollup jump. Confirm search clears and the bad row is focused.
SKU
Qty
Actions
WIRE-14
0
WIRE-22

Drafting — Rollup Off

showErrorRollup={false} until Validate so a large grid stays quiet.

Try this:

  1. Leave the switch off. Click Validate. Confirm cells can reveal but no rollup band appears.
  2. Turn the switch on. Click Validate again. Confirm the rollup appears.
SKU
Qty
Actions
0
0

Two Editable Tables

Each table owns its own rollup.

Try this:

  1. Confirm two rollups (Charges and Notes), not one merged page list of cells.
  2. Fix only Charges. Confirm the Notes rollup remains.

Charges

SKU
Qty
Actions
0

Notes

SKU
Qty
Actions
0

Dual Scream (Forbidden)

Same cell copy must not appear as a page summary and a rollup.

Try this:

  1. Left: stacked essays listing SKU/Qty and “N row(s) need attention.”
  2. Right: rollup only (kit). That is the ship rule.

Wrong

1 row(s) need attention

Row 2 · SKU: SKU is required

Right

SKU
Qty
Actions
SKU-11
0

Combined Volume — Headers Plus Cells Plus Root

The usual “the whole page is red” case: many required headers, many invalid lines, plus a root/system message and a warning. Summary scrolls; rollup scrolls; cells stay out of the summary.

Try this:

  1. Scroll the summary. Count should include the eight headers + root + warning + array-root — not 16× SKU strings.
  2. Scroll the rollup. Confirm cell links, not a second copy of Customer required.
  3. Click a jump near the bottom of the summary (e.g. External ref). Confirm the field is reached.
SKU
Qty
Actions
SKU-1002
0
0
SKU-1032
0
0
SKU-1062
0
0
SKU-1092
0
0
SKU-1122
0
0
SKU-1152

Nested Child Table

Parent SKU + nested claim qty. Child errors fold into the parent rollup (expand + jump). Still not a page-summary essay.

Try this:

  1. Confirm row 1 is expanded and the nested Claim qty is invalid.
  2. Confirm row 2 empty SKU is on the parent rollup.
  3. Confirm Form Error Summary does not list claims.0.qty.
SKU
Actions
A-100
Claim qty
Note
Actions
Claim qty
Note
Actions

No sub-rows

Add a sub-row to split this line.

Precedent: GOV.UK Error summary for page field indexes; Fiori for not repeating every table cell at page root. Kit lock: Object Messaging — Validation Surface Matrix.

3. When Errors Show

Pick a policy on <Form revealErrors>. Submit still validates every rule — this only changes when chrome appears. Once a field is showing an error, the message stays until that field is valid (including while focused). New errors after a failed Submit wait for blur.

PolicyFirst paintAfter an error is showingUse when
submit (default)Failed Submit (or mapped field issues)Clears as soon as the value is valid. New invalid fields wait for blurLong create / edit forms
touchBlur of an invalid field (empty required included)Same clear-as-you-fixShort forms, settings, where leaving a field is a commit
alwaysAs soon as the field is invalid — no Submit waitLivePassword strength, character count. Not the long-form default

First keystroke / first focus of a fresh field never paints under submit or touch. Hosts that used touch as “show while typing on first entry” must switch to always. Walk every numbered case in this section.

Table cells are a different language (hide while focused). Do not copy these FormItem steps onto Editable Table.

Policy Overview

Toggle submit / touch / always on one required field. Each mode’s helper text is the contract for that policy.

revealErrors

Submit empty to see the error, then type a title — it should clear without blurring.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { RevealErrorsPolicy } from "@/components/f-ui/formily/internals/reveal-errors";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

type Values = { title: string };

const MODES: RevealErrorsPolicy[] = ["submit", "touch", "always"];

export function FormValidationRevealErrorsModesDemo() {
  const [mode, setMode] = useState<RevealErrorsPolicy>("submit");
  const form = useMemo(
    () => createForm<Values>({ initialValues: { title: "" } }),
    [mode],
  );

  return (
    <div className="w-full max-w-md space-y-4">
      <div className="flex items-center gap-2">
        <span className="text-sm text-muted-foreground">revealErrors</span>
        <Select
          value={mode}
          onValueChange={(v) => setMode(v as RevealErrorsPolicy)}
        >
          <SelectTrigger className="w-40">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {MODES.map((m) => (
              <SelectItem key={m} value={m}>
                {m}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      <Form
        key={mode}
        form={form}
        revealErrors={mode}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormField
          name="title"
          kind="text"
          label="Order title"
          required
          description={
            mode === "submit"
              ? "Submit empty to see the error, then type a title — it should clear without blurring."
              : mode === "touch"
                ? "Focus, leave empty, blur to see the error. Typing before blur should not paint."
                : "always paints as soon as the field is invalid."
          }
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Submit Quiet Until Submit

Default policy (revealErrors omitted is the same as "submit"). Typing and leaving the field must not shame incomplete input.

Try this:

  1. Click Order title, type x, click outside the field.
  2. Confirm: no red border, no error text, no summary.
  3. The value is still invalid — Submit in the next demo is what reveals it.
"use client";

import type { Form } from "@formily/core";
import { useEffect, useMemo, type ReactNode } from "react";
import { toast } from "sonner";

import { Form as FuiForm } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { RevealErrorsPolicy } from "@/components/f-ui/formily/internals/reveal-errors";
import { Button } from "@/components/ui/button";

type TitleValues = { title: string };
type PairValues = { title: string; extra: string };
type EmailValues = { workEmail: string };

function TimingForm<T extends object>({
  revealErrors,
  initialValues,
  children,
}: {
  revealErrors?: RevealErrorsPolicy;
  initialValues: T;
  children: (form: Form<T>) => ReactNode;
}) {
  const form = useMemo(
    () => createForm<T>({ initialValues }),
    // Fresh instance per mount; demos remount via the page, not via deps.
    // eslint-disable-next-line react-hooks/exhaustive-deps -- demo isolation
    [],
  );

  return (
    <div className="w-full max-w-md space-y-4">
      <FuiForm
        form={form}
        revealErrors={revealErrors}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        {children(form)}
      </FuiForm>
    </div>
  );
}

function SubmitRow({
  form,
  showReset,
}: {
  form: Form;
  showReset?: boolean;
}) {
  return (
    <FormActions>
      <Button type="submit">
        Submit
      </Button>
      {showReset ? (
        <Button
          type="button"
          variant="outline"
          onClick={() => {
            form.reset();
          }}
        >
          Reset
        </Button>
      ) : null}
    </FormActions>
  );
}

/** Default `submit`: typing and blur stay quiet. */
export function FormValidationSubmitQuietUntilSubmitDemo() {
  return (
    <TimingForm<TitleValues>
      initialValues={{ title: "" }}
    >
      {(form) => (
        <>
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** Failed Submit paints field + summary; fixing clears without another blur. */
export function FormValidationSubmitEmptyThenClearDemo() {
  return (
    <TimingForm<TitleValues>
      initialValues={{ title: "" }}
    >
      {(form) => (
        <>
          <FormErrorSummary />
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** After a failed Submit, a previously valid field waits for blur. */
export function FormValidationSubmitRecoveryBlurDemo() {
  return (
    <TimingForm<PairValues>
      initialValues={{ title: "", extra: "Warehouse A" }}
    >
      {(form) => (
        <>
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <FormField
            name="extra"
            kind="text"
            label="Warehouse"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** Successful Submit does not switch the form into recovery. */
export function FormValidationSuccessfulSubmitNoRecoveryDemo() {
  return (
    <TimingForm<PairValues>
      initialValues={{ title: "Northwind", extra: "Warehouse A" }}
    >
      {(form) => (
        <>
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <FormField
            name="extra"
            kind="text"
            label="Warehouse"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** `touch`: focus-only stays quiet; leaving empty required paints. */
export function FormValidationTouchEmptyRequiredDemo() {
  return (
    <TimingForm<TitleValues>
      revealErrors="touch"
      initialValues={{ title: "" }}
    >
      {(form) => (
        <>
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** `touch`: invalid value while focused does not paint until blur. */
export function FormValidationTouchInvalidWhileFocusedDemo() {
  return (
    <TimingForm<EmailValues>
      revealErrors="touch"
      initialValues={{ workEmail: "" }}
    >
      {(form) => (
        <>
          <FormField
            name="workEmail"
            kind="email"
            label="Work email"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** Once shown, an email `onBlur` kind clears as soon as the value is valid. */
export function FormValidationTouchEmailClearsOnInputDemo() {
  return (
    <TimingForm<EmailValues>
      revealErrors="touch"
      initialValues={{ workEmail: "" }}
    >
      {(form) => (
        <>
          <FormField
            name="workEmail"
            kind="email"
            label="Work email"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** Blur stamps that field only — a sibling stays quiet. */
export function FormValidationTouchBlurThatFieldOnlyDemo() {
  return (
    <TimingForm<PairValues>
      revealErrors="touch"
      initialValues={{ title: "", extra: "" }}
    >
      {(form) => (
        <>
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <FormField
            name="extra"
            kind="text"
            label="Warehouse"
            required
          />
          <SubmitRow form={form} />
        </>
      )}
    </TimingForm>
  );
}

/** `always`: invalid chrome without Submit (password / count class). */
export function FormValidationAlwaysPaintsLiveDemo() {
  const form = useMemo(
    () => createForm<TitleValues>({ initialValues: { title: "" } }),
    [],
  );

  useEffect(() => {
    void form.validate();
  }, [form]);

  return (
    <div className="w-full max-w-md space-y-4">
      <FuiForm
        form={form}
        revealErrors="always"
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormField
          name="title"
          kind="text"
          label="Order title"
          required
        />
        <SubmitRow form={form} />
      </FuiForm>
    </div>
  );
}

/** Reset clears sticky field chrome and recovery. */
export function FormValidationResetClearsChromeDemo() {
  return (
    <TimingForm<TitleValues>
      revealErrors="touch"
      initialValues={{ title: "" }}
    >
      {(form) => (
        <>
          <FormField
            name="title"
            kind="text"
            label="Order title"
            required
          />
          <SubmitRow form={form} showReset />
        </>
      )}
    </TimingForm>
  );
}

Submit Empty Then Clear On Fix

Failed Submit stamps every invalid field. After that, fixing the value clears chrome immediately — you do not blur again.

Try this:

  1. Leave Order title empty. Click Submit.
  2. Confirm: field error and Form Error Summary both show {label} is required (same string). Do not ship Formily’s generic “The field value is required.” Custom validators keep their own copy.
  3. Click the field (keep focus) and type Northwind.
  4. Confirm: the error and summary row disappear as soon as the title is valid — no extra blur.

Submit Recovery Waits For Blur

After a failed Submit the form switches to inline recovery: already-red fields stay sticky; a field that was valid at Submit time stays quiet until you leave it.

Try this:

  1. Leave Order title empty. Leave Warehouse as Warehouse A. Click Submit.
  2. Confirm: only Order title is red. Warehouse is still quiet.
  3. Click Warehouse, delete the text, keep focus (do not click away).
  4. Confirm: Warehouse is still quiet while you are typing it empty.
  5. Click outside Warehouse. Confirm: the required error appears now.

Successful Submit Does Not Open Recovery

A successful Submit must not flip the form into “show everything on the next keystroke.”

Try this:

  1. Both fields already have values. Click Submit. Confirm the success toast — no field errors.
  2. Click Warehouse, delete the text, keep focus.
  3. Confirm: still no error while focused.
  4. Click outside Warehouse. Confirm: the required error appears on blur, same as touch.

Touch Empty Required

revealErrors="touch" first-paints on blur, including an empty required field you focused and left. Focus alone is not a commit.

Try this:

  1. Click Order title. Do not type. Click Submit (or any area outside the field) so the field blurs.
  2. Before you blur: confirm there is no error on first focus.
  3. After you leave the empty field: confirm the required error appears.
  4. Click the field again and type Northwind without blurring. Confirm the error clears immediately.

Touch Invalid While Focused

Format kinds such as email validate on blur for the first show. Typing not-an-email while focused must not paint yet.

Try this:

  1. Click Work email. Type not-an-email. Do not click away.
  2. Confirm: no error yet.
  3. Click outside the field. Confirm: the email format error appears.
  4. (Clear-as-you-fix is the next demo.)

Touch Email Clears On Input

Once the email error is showing, the kit re-validates on input so a blur-kind does not trap the message until the next blur.

Try this:

  1. Click Work email, type bad, click outside. Confirm the format error.
  2. Click the field again. Replace the value with ada@example.com without clicking away.
  3. Confirm: the error disappears as soon as the address is valid.

Touch Blur Stamps That Field Only

Leaving one invalid field does not paint siblings. Each field waits for its own blur (or a later failed Submit).

Try this:

  1. Click Order title, do not type, click Warehouse (title blurs).
  2. Confirm: Order title is red; Warehouse is still quiet even though it is also empty.
  3. Click outside Warehouse. Confirm: Warehouse now shows its own required error.

Always Paints Without Submit

always is for live exceptions (password / character count). Empty required paints without Submit. Do not set this as the default on a long form.

Try this:

  1. Do not click Submit. Confirm the required error is already visible on Order title.
  2. Type Northwind. Confirm it clears immediately.
  3. Delete the title. Confirm the error returns while you are still in the field.

Reset Clears Shown Errors

Reset wipes sticky chrome and submit-recovery. The next interaction starts from a quiet form again.

Try this:

  1. Click Order title, click outside so the required error shows (touch).
  2. Click Reset.
  3. Confirm: the error is gone. Focus the field again without blurring — still quiet, same as a fresh form.

IME Composition

Pinyin / Hangul composition is not a first-show commit. This demo simulates composing without an IME so you can see the gate.

Try this:

  1. Click Work email, type bad, click outside so the format error shows (touch).
  2. Click Start composing, then replace the value with ada@example.com without clicking End composing. Confirm the error stays (R8 skipped).
  3. Click End composing, type the same valid address again (or add a character and delete). Confirm the error clears.

Server Field Issues Paint Immediately

Server / submit / system issues bypass the reveal gate. Do not Submit and do not blur — the mapped field paints now. Empty required siblings stay quiet.

Try this:

  1. Email is already valid. Warehouse is empty. Confirm: no chrome yet (submit policy).
  2. Click Apply server issue (not Submit). Confirm Email shows already registered immediately.
  3. Confirm Warehouse is still quiet (no fake submit-all).
  4. Edit Email. Confirm the server chrome clears. Submit still required-checks Warehouse.

Full map (field + unmounted path after a real Submit): Server Issues Map.

Error Summary And Focus

After a failed Submit, FormErrorSummary lists visible field issues with jump links. Toggle focusOnInvalid between summary, first-field, and false (no auto-focus).

Try this:

  1. Leave both fields empty. Click Submit empty.
  2. With summary: confirm focus lands on the summary heading; jump links move to the field.
  3. Switch to first-field and Submit empty again: confirm the first invalid input is focused.
  4. Switch to false: Submit empty paints errors but does not move focus.
focusOnInvalid
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

type Focus = "summary" | "first-field" | false;
type Values = { email: string; note: string };

export function FormValidationErrorSummaryFocusDemo() {
  const [focusOnInvalid, setFocus] = useState<Focus>("summary");
  const form = useMemo(
    () => createForm<Values>({ initialValues: { email: "", note: "" } }),
    [focusOnInvalid],
  );

  return (
    <div className="w-full max-w-md space-y-4">
      <div className="flex items-center gap-2">
        <span className="text-sm text-muted-foreground">focusOnInvalid</span>
        <Select
          value={String(focusOnInvalid)}
          onValueChange={(v) =>
            setFocus(v === "false" ? false : (v as Focus))
          }
        >
          <SelectTrigger className="w-40">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="summary">summary</SelectItem>
            <SelectItem value="first-field">first-field</SelectItem>
            <SelectItem value="false">false</SelectItem>
          </SelectContent>
        </Select>
      </div>
      <Form
        key={String(focusOnInvalid)}
        form={form}
        focusOnInvalid={focusOnInvalid}
        revealErrors="submit"
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormErrorSummary />
        <FormField name="email" label="Email" required kind="email" />
        <FormField name="note" label="Note" required />
        <FormActions>
          <Button type="submit">
            Submit empty
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Server Issues Map

Who validates: BE. Fake API returns a field issue and an unmounted-path issue. Field chrome clears when you edit; summary keeps form-level intent until the next attempt.

Try this:

  1. Fill Email and Username with anything. Click Submit.
  2. Confirm: Email shows “already registered”; the summary also lists the captcha (unmounted path) message.
  3. Edit Email. Confirm: the field error clears; the captcha summary row stays until the next Submit.

Server rejects every email; edit the field to clear the error

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";

const API_DELAY_MS = 400;

interface SignupValues {
  email: string;
  username: string;
}

/** Pretend API: rejects every submit with one field error and one form-level error. */
async function fakeSignup(): Promise<FormIssue[]> {
  await new Promise((resolve) => setTimeout(resolve, API_DELAY_MS));
  return [
    { source: "server", path: "email", message: "Email is already registered." },
    {
      source: "server",
      path: "captcha",
      message: "Captcha session expired — retry the submit.",
    },
  ];
}

export function FormValidationServerIssuesMapDemo() {
  const form = useMemo(
    () =>
      createForm<SignupValues>({
        initialValues: { email: "", username: "" },
      }),
    [],
  );
  const [attempts, setAttempts] = useState(0);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        focusOnInvalid="summary"
        onSubmit={async () => {
          const issues = await fakeSignup();
          applyFormIssues(form, issues);
          setAttempts((n) => n + 1);
          toast.error("Server returned errors");
        }}
      >
        <FormErrorSummary scope="all" />
        <FormField
          name="email"
          label="Email"
          required
          kind="email"
          description="Server rejects every email; edit the field to clear the error"
          componentProps={{ placeholder: "you@example.com" }}
        />
        <FormField
          name="username"
          label="Username"
          required
          component={[Input, { placeholder: "lovelace" }]}
        />
        <FormActions>
          <Button type="submit">
            Sign up{attempts > 0 ? ` (attempt ${attempts + 1})` : ""}
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Hybrid Format Then Server

Who validates: Hybrid. Client checks format + @acme.com. blocked@acme.com still fails on the server via applyFormIssues — FE preview is not authority.

Try this:

  1. Type me@gmail.com and Submit. Confirm the client @acme.com error — no server round-trip needed.
  2. Type ada@acme.com and Submit. Confirm success.
  3. Type blocked@acme.com and Submit. Confirm the server “blocked by policy” message on the field.

FE checks format. Try blocked@acme.com after a valid format — server still rejects.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";

type Values = { workEmail: string };

const BLOCKED = new Set(["blocked@acme.com"]);

async function fakeSave(values: Values): Promise<FormIssue[]> {
  await new Promise((r) => setTimeout(r, 350));
  if (BLOCKED.has(values.workEmail.toLowerCase())) {
    return [
      {
        source: "server",
        path: "workEmail",
        code: "email_blocked",
        message: "This address is blocked by policy. Use another work email.",
      },
    ];
  }
  return [];
}

export function FormValidationHybridFormatThenServerDemo() {
  const form = useMemo(
    () => createForm<Values>({ initialValues: { workEmail: "" } }),
    [],
  );
  const [ok, setOk] = useState(false);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        focusOnInvalid="first-field"
        onSubmit={async (values) => {
          const issues = await fakeSave(values);
          if (issues.length) {
            applyFormIssues(form, issues);
            setOk(false);
            toast.error("Server rejected the email");
            return;
          }
          setOk(true);
          toast.success("Saved");
        }}
      >
        <FormErrorSummary />
        <FormField
          name="workEmail"
          label="Work email"
          required
          kind="email"
          description="FE checks format. Try blocked@acme.com after a valid format — server still rejects."
          validator={(value?: string) => {
            if (!value) return "";
            const domain = value.split("@")[1]?.toLowerCase();
            return domain === "acme.com"
              ? ""
              : "Email must be an @acme.com address";
          }}
        />
        <FormActions>
          <Button type="submit">
            Save
          </Button>
        </FormActions>
      </Form>
      {ok ? (
        <p className="text-sm text-muted-foreground">Last save succeeded.</p>
      ) : null}
    </div>
  );
}

Async Uniqueness Preview

Who validates: Hybrid (preview then server). createAsyncValidator blocks known taken names (admin / root / system) on input. A name the FE thinks is free can still fail on submit — try reserved, which maps a server issue via applyFormIssues. Prefer debounce so the network is not hammered.

Try this:

  1. Type admin and wait ~400ms. Confirm “already taken” from the FE preview.
  2. Change to ada. Confirm the preview error clears.
  3. Type reserved and Submit. Confirm the server issue — FE thought it was free.

Try admin (async blocks). Try reserved (preview free, submit maps server issue).

"use client";

import type { FieldValidator } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createAsyncValidator } from "@/components/f-ui/formily/internals/create-async-validator";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";

type Values = { username: string };

/** FE async preview — known taken names. */
const FE_TAKEN = new Set(["admin", "root", "system"]);

/** BE also rejects this name even when FE preview says free. */
const BE_RESERVED = "reserved";

const checkUsername = createAsyncValidator(
  async (value, ctx) => {
    await new Promise((r) => setTimeout(r, 400));
    if (ctx.signal.aborted) return "";
    const username = typeof value === "string" ? value : "";
    if (!username) return "";
    return FE_TAKEN.has(username.toLowerCase())
      ? "Username is already taken"
      : "";
  },
  { debounceMs: 300 },
);

async function fakeReserve(values: Values): Promise<FormIssue[]> {
  await new Promise((r) => setTimeout(r, 350));
  if (values.username.toLowerCase() === BE_RESERVED) {
    return [
      {
        source: "server",
        path: "username",
        code: "username_reserved",
        message: "This username is reserved. Choose another.",
      },
    ];
  }
  return [];
}

export function FormValidationAsyncUniquenessDemo() {
  const form = useMemo(
    () => createForm<Values>({ initialValues: { username: "" } }),
    [],
  );

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        focusOnInvalid="first-field"
        onSubmit={async (values) => {
          const issues = await fakeReserve(values);
          if (issues.length) {
            applyFormIssues(form, issues);
            toast.error("Server rejected the username");
            return;
          }
          toast.success(`Reserved @${values.username}`);
        }}
      >
        <FormErrorSummary />
        <FormField
          name="username"
          label="Username"
          required
          validatingDescription="Checking availability…"
          description="Try admin (async blocks). Try reserved (preview free, submit maps server issue)."
          validator={
            {
              triggerType: "onInput",
              validator: checkUsername,
            } as FieldValidator
          }
        />
        <FormActions>
          <Button type="submit">
            Reserve
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Required On Submit

Same default as Submit Empty Then Clear On Fix, with the prop omitted (revealErrors default "submit").

Try this:

  1. Type x in Order title. Confirm: no error yet.
  2. Click Submit while empty (clear the field first). Confirm the required error.
  3. Type a title without blurring. Confirm it clears.
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

type Values = { orderTitle: string };

export function FormValidationRequiredSubmitRevealDemo() {
  const form = useMemo(
    () => createForm<Values>({ initialValues: { orderTitle: "" } }),
    [],
  );

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Submitted");
        }}
      >
        <FormField
          name="orderTitle"
          kind="text"
          label="Order title"
          required
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Kind Plus Validator

kind="email" supplies format first; an appended custom validator then requires @acme.com. Try a well-formed personal address vs a work address.

Try this:

  1. In Personal email, type not-an-email, blur. Confirm the built-in format error.
  2. In Work email, type me@gmail.com, Submit. Confirm @acme.com is required.
  3. Type ada@acme.com in Work email and Submit. Confirm success.

kind only — built-in format: email on blur

Format first, then @acme.com — try me@gmail.com vs ada@acme.com

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface InviteValues {
  personalEmail: string;
  workEmail: string;
}

function acmeDomainValidator(value: string | undefined) {
  if (!value) return "";
  const domain = value.split("@")[1]?.toLowerCase();
  return domain === "acme.com"
    ? ""
    : "Email must be an @acme.com address";
}

export function FormValidationKindPlusValidatorDemo() {
  const form = useMemo(
    () =>
      createForm<InviteValues>({
        initialValues: { personalEmail: "", workEmail: "" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<InviteValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="personalEmail"
          label="Personal email"
          kind="email"
          componentProps={{ placeholder: "you@example.com" }}
          description="kind only — built-in format: email on blur"
        />
        <FormField
          name="workEmail"
          label="Work email"
          required
          kind="email"
          componentProps={{ placeholder: "you@acme.com" }}
          description="Format first, then @acme.com — try me@gmail.com vs ada@acme.com"
          validator={acmeDomainValidator}
        />
        <FormActions>
          <Button type="submit">
            Send invite
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Built-In Rules

Formily rule objects on validator: minLength / maxLength, pattern, and minimum / maximum for numbers. Submit with short codes, bad SKUs, or out-of-range qty.

Try this:

  1. Type AB in Code and Submit. Confirm “Use 3–8 characters”.
  2. Type nope in SKU and Submit. Confirm “Pattern AA-000” (use AB-001).
  3. Set Qty to 0 or 100 and Submit. Confirm “1–99”.

minLength / maxLength

"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

type Values = { code: string; qty: number | null; sku: string };

export function FormValidationBuiltinRulesDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: { code: "", qty: null, sku: "" },
      }),
    [],
  );

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("OK");
        }}
      >
        <FormField
          name="code"
          label="Code"
          required
          validator={{
            minLength: 3,
            maxLength: 8,
            message: "Use 3–8 characters",
          }}
          description="minLength / maxLength"
        />
        <FormField
          name="sku"
          label="SKU"
          required
          validator={{
            pattern: /^[A-Z]{2}-\d{3}$/,
            message: "Pattern AA-000",
          }}
        />
        <FormField
          name="qty"
          label="Qty"
          kind="number"
          required
          validator={{ minimum: 1, maximum: 99, message: "1–99" }}
          componentProps={{ min: 1, step: 1 }}
        />
        <FormActions>
          <Button type="submit">
            Validate
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Checkbox Must Be True

Formily required alone does not treat false as empty on booleans. Pair kind="checkbox" with a truthy validator (and inlineLabel for the label beside the control).

Try this:

  1. Leave the checkbox unchecked. Click Continue. Confirm “You must agree”.
  2. Check it. Confirm the error clears (or Submit succeeds).
  3. Do not rely on required alone for a boolean — that is the kit gotcha this recipe exists for.
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

type Values = { accept: boolean };

/** Formily `required` does not treat `false` as empty on boolean fields. */
function mustAgree(value: boolean | undefined) {
  return value ? "" : "You must agree";
}

export function FormValidationCheckboxMustBeTrueDemo() {
  const form = useMemo(
    () => createForm<Values>({ initialValues: { accept: false } }),
    [],
  );

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Accepted");
        }}
      >
        <FormField
          name="accept"
          label="I agree to the terms"
          required
          kind="checkbox"
          inlineLabel
          validator={mustAgree}
        />
        <FormActions>
          <Button type="submit">
            Continue
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Mixed / Approver Lock

Approver inbox (Unlock exists, not yet unlocked) is Display: applicant answers use readPretty, same as request number and created time. Comment stays editable. Unlock remounts applicant fields as controls (Fiori Display → Edit, Salesforce view → pencil). Do not paint that inbox as a wall of readOnly inputs.

An Edit session can still mix three chromes on one surface: Created stays readPretty (never a control). A field this role still cannot change stays a readOnly control. Comment stays editable. Empty pretty values use an em dash; empty locked and editable controls stay blank — Empty Value Placeholder.

A Display page with no Unlock uses form-level readPretty. Full pair: Form (Formily) — When To Use. Lock mark placement and field vs surface scope: Field Lock Affordance. Showcase: /showcases/approver-unlock.

Try this (Edit mixed, demo below):

  1. Confirm Created is a date string — not a picker. Clearing it in code would show , not an empty input.
  2. Tab to SKU. Confirm it is a textbox you cannot type into (not grey-disabled). This is leftover lock in Edit, not the approver inbox.
  3. Type in Comment. Confirm that is the only writable field. Submit.
Aug 1, 2026

System timestamp — display only

Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome.

Locked for this Edit session.

Read-only

Set by the approval route — reopen the request to change it.

Writable this session

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Textarea } from "@/components/f-ui/formily/connects/textarea";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface ReviewValues {
  createdAt: Date | null;
  sku: string;
  region: string;
  comment: string;
}

export function FormilyMixedPatternDemo() {
  const form = useMemo(
    () =>
      createForm<ReviewValues>({
        initialValues: {
          createdAt: new Date(2026, 7, 1),
          sku: "SKU-104",
          region: "apac",
          comment: "",
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ReviewValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="createdAt"
          label="Created"
          description="System timestamp — display only"
          readPretty
          kind="date"
        />
        <FormField
          name="sku"
          label="SKU"
          description="Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome."
          readOnly
          lockReason="Locked for this Edit session."
          component={[Input]}
        />
        <FormField
          name="region"
          label="Region"
          readOnly
          kind="radio"
          lockReason="Set by the approval route — reopen the request to change it."
          componentProps={{
            options: [
              { label: "APAC", value: "apac" },
              { label: "EMEA", value: "emea" },
            ],
            orientation: "horizontal",
          }}
        />
        <FormField
          name="comment"
          label="Comment"
          description="Writable this session"
          component={[Textarea, { placeholder: "Approver note" }]}
        />
        <FormActions>
          <Button type="submit">
            Submit review
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Cross-Field Local

Confirm password uses reactions + field.query(".password") for a local match error. Phone becomes required and visible only when Notify by is SMS — same reaction pattern as Formily demos. Keep local match rules on the client; server aggregates (totals, inventory, concurrency) still return path or root issues via applyFormIssues.

Try this:

  1. Type secret1 in Password and secret2 in Confirm password. Confirm “Passwords must match”.
  2. Make Confirm match Password. Confirm the match error clears.
  3. Set Notify by to SMS. Confirm Phone appears and is required. Submit empty phone to see that error. Switch back to Email: Phone hides.

Matches password via reactions + field.query

"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

type Values = {
  password: string;
  confirm: string;
  notifyBy: "email" | "sms";
  phone?: string;
};

const NOTIFY_OPTIONS = [
  { label: "Email", value: "email" },
  { label: "SMS", value: "sms" },
];

export function FormValidationCrossFieldLocalDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: {
          password: "",
          confirm: "",
          notifyBy: "email",
          phone: "",
        },
      }),
    [],
  );

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Account updated");
        }}
      >
        <FormField
          name="password"
          label="Password"
          required
          component={[
            Input,
            { type: "password", autoComplete: "new-password" },
          ]}
        />
        <FormField
          name="confirm"
          label="Confirm password"
          required
          description="Matches password via reactions + field.query"
          component={[
            Input,
            { type: "password", autoComplete: "new-password" },
          ]}
          reactions={(field) => {
            const password = field.query(".password").value() as
              | string
              | undefined;
            field.selfErrors =
              field.value && field.value !== password
                ? ["Passwords must match"]
                : [];
          }}
        />
        <FormField
          name="notifyBy"
          label="Notify by"
          required
          component={[Select, { options: NOTIFY_OPTIONS }]}
        />
        <FormField
          name="phone"
          label="Phone"
          description="Required only when Notify by is SMS"
          component={[Input, { placeholder: "+1 555 0100" }]}
          reactions={(field) => {
            const sms = field.query(".notifyBy").value() === "sms";
            field.visible = sms;
            field.required = sms;
          }}
        />
        <FormActions>
          <Button type="submit">
            Save
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Warning Severity

Who validates: Hybrid (server severity). applyFormIssues with severity: "warning" stays on the field (and Message Popover when the host builds that list). It does not appear in Form Error Summary. This demo still succeeds when only warnings are present; toggle to inject an error and block.

Try this:

  1. Leave the select on warning-only. Submit. Confirm a warning on Memo, an empty Banner, and the success toast — warnings do not block.
  2. Switch to warning-and-error. Submit. Confirm the Banner lists the invoice error only (not the Memo warning) and success does not fire.
on submit

Warnings stay on the field; this demo still succeeds when only warnings are present

Leave empty to get a warning after Save

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

type Values = { invoiceNo: string; memo: string };

type InjectMode = "warning-only" | "warning-and-error";

/**
 * Host policy: warnings do not block success. They stay on the field — not
 * in Form Error Summary. Mode select injects an error so the Banner lists
 * the blocking issue only.
 */
export function FormValidationWarningSeverityDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: { invoiceNo: "INV-1001", memo: "" },
      }),
    [],
  );
  const [injectMode, setInjectMode] = useState<InjectMode>("warning-only");
  const [lastOk, setLastOk] = useState(false);

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="flex items-center gap-2">
        <span className="text-sm text-muted-foreground">on submit</span>
        <Select
          value={injectMode}
          onValueChange={(v) => setInjectMode(v as InjectMode)}
        >
          <SelectTrigger className="w-48">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="warning-only">warning only</SelectItem>
            <SelectItem value="warning-and-error">
              warning + field error
            </SelectItem>
          </SelectContent>
        </Select>
      </div>
      <Form
        form={form}
        focusOnInvalid="summary"
        onSubmit={() => {
          const issues: FormIssue[] = [
            {
              source: "server",
              path: "memo",
              severity: "warning",
              message:
                "Memo is empty — invoice will use the default text.",
            },
          ];
          if (injectMode === "warning-and-error") {
            issues.push({
              source: "server",
              path: "invoiceNo",
              severity: "error",
              message: "Invoice number is reserved. Pick another.",
            });
            applyFormIssues(form, issues);
            setLastOk(false);
            toast.error("Submit blocked by an error");
            return;
          }
          applyFormIssues(form, issues);
          setLastOk(true);
          toast.success("Saved (warning only — host allowed submit)");
        }}
      >
        <FormErrorSummary scope="all" />
        <FormField
          name="invoiceNo"
          label="Invoice number"
          required
          description="Warnings stay on the field; this demo still succeeds when only warnings are present"
        />
        <FormField
          name="memo"
          label="Memo"
          description="Leave empty to get a warning after Save"
        />
        <FormActions>
          <Button type="submit">
            Save
          </Button>
        </FormActions>
      </Form>
      {lastOk ? (
        <p className="text-sm text-muted-foreground">
          Last save succeeded. Memo still shows a field warning; the Banner stays empty.
        </p>
      ) : null}
    </div>
  );
}

Hide Helper On Error

Long description plus hideHelperOnError: after Submit on an empty required field, the helper is visually hidden while the error shows (still available to assistive tech).

Try this:

  1. Read the long helper under Account code.
  2. Click Submit with the field empty.
  3. Confirm: the helper is visually gone; the required error is visible instead.

Use the 6-character ledger code from Finance (e.g. ACC001). This long helper is hidden visually when the required error shows, but stays in the accessibility tree.

"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

type Values = { accountCode: string };

export function FormValidationHideHelperOnErrorDemo() {
  const form = useMemo(
    () => createForm<Values>({ initialValues: { accountCode: "" } }),
    [],
  );

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={() => {
          toast.success("Saved");
        }}
      >
        <FormField
          name="accountCode"
          kind="text"
          label="Account code"
          required
          hideHelperOnError
          description="Use the 6-character ledger code from Finance (e.g. ACC001). This long helper is hidden visually when the required error shows, but stays in the accessibility tree."
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

Steps Form Hybrid

Per-step fields validate on Next (FE). The write API runs on the last Submit only.

Try this:

  1. Leave Work email empty. Click Next. Confirm the step error — finish must not run.
  2. Fill a valid email, go through Access, Submit on Message. Confirm the success toast.
"use client";

import { toast } from "sonner";

import { FormField } from "@/components/f-ui/formily/form-field";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";

interface InviteWizardValues {
  email: string;
  role: string;
  message: string;
}

/**
 * Leave the first step empty and click Next — required errors stay on Basics.
 * Fill email, advance, then Back: the email is still there.
 */
export function StepsFormValidationDemo() {
  return (
    <div className="w-full max-w-lg">
      <StepsForm<InviteWizardValues>
        initialValues={{ email: "", role: "member", message: "" }}
        onFinish={async (values) => {
          await new Promise((r) => setTimeout(r, 300));
          toast.success(`Invite queued for ${values.email}`);
        }}
      >
        <StepsForm.Step name="basics" title="Basics">
          <FormField
            kind="email"
            name="email"
            label="Work email"
            required
            componentProps={{ placeholder: "name@company.com" }}
          />
        </StepsForm.Step>
        <StepsForm.Step name="access" title="Access">
          <FormField
            kind="select"
            name="role"
            label="Role"
            required
            componentProps={{
              options: [
                { label: "Admin", value: "admin" },
                { label: "Member", value: "member" },
                { label: "Viewer", value: "viewer" },
              ],
            }}
          />
        </StepsForm.Step>
        <StepsForm.Step name="message" title="Message">
          <FormField
            kind="textarea"
            name="message"
            label="Personal note"
            componentProps={{
              rows: 3,
              placeholder: "Optional note in the invite email",
            }}
          />
        </StepsForm.Step>
      </StepsForm>
    </div>
  );
}

Query Filter Search Submit

Search runs form.submit() with submit reveal — typing alone does not paint.

Try this:

  1. Type a in Keyword (min length when filled) or not-an-email in Email. Click Search.
  2. Confirm chrome after Search, not while typing. Fix and Search again.

Click Search with keyword a or email not-an-email — validation blocks onFinish. Fix the fields, then Search again.

Optional — at least 2 characters when filled

Optional — format checked on blur

Optional — must be zero or positive

Search attempts: 0 — only increments when validation passes and onFinish runs.

No successful query yet.

"use client";

import { useState } from "react";

import { QueryFilter } from "@/components/f-ui/query-filter/query-filter";
import { FormField } from "@/components/f-ui/formily/form-field";

function minLengthWhenFilled(min: number, message: string) {
  return (value: string | undefined) => {
    const trimmed = String(value ?? "").trim();
    if (!trimmed) return "";
    return trimmed.length >= min ? "" : message;
  };
}

export function QueryFilterValidationDemo() {
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
  const [attempts, setAttempts] = useState(0);

  return (
    <div className="w-full space-y-4">
      <p className="text-muted-foreground text-xs">
        Click <strong>Search</strong> with keyword <code>a</code> or email{" "}
        <code>not-an-email</code> — validation blocks <code>onFinish</code>. Fix the
        fields, then Search again.
      </p>
      <QueryFilter
        onFinish={(values) => {
          setSubmitted(values);
          setAttempts((n) => n + 1);
        }}
        onReset={() => {
          setSubmitted(null);
          setAttempts(0);
        }}
      >
        <FormField
          kind="text"
          name="keyword"
          label="Keyword"
          description="Optional — at least 2 characters when filled"
          validator={minLengthWhenFilled(2, "Enter at least 2 characters")}
        />
        <FormField
          kind="email"
          name="customerEmail"
          label="Customer email"
          description="Optional — format checked on blur"
          componentProps={{ placeholder: "buyer@example.com" }}
        />
        <FormField
          kind="currency"
          name="minAmount"
          label="Min amount"
          description="Optional — must be zero or positive"
          componentProps={{ currency: "USD", placeholder: "0.00" }}
          validator={(value) => {
            if (value === undefined || value === null || value === "") return "";
            return Number(value) >= 0 ? "" : "Amount cannot be negative";
          }}
        />
      </QueryFilter>
      <div className="text-muted-foreground space-y-2 text-xs">
        <p>
          Search attempts: <strong>{attempts}</strong> — only increments when validation
          passes and <code>onFinish</code> runs.
        </p>
        {submitted ? (
          <pre className="bg-muted overflow-auto rounded-md p-3">
            <code>{JSON.stringify(submitted, null, 2)}</code>
          </pre>
        ) : (
          <p>No successful query yet.</p>
        )}
      </div>
    </div>
  );
}

Editable Table Cells

Do not paint cell errors with applyFormIssues. Use identity-keyed applyEditableArrayIssues, cell chrome, and the region rollup. Array-root rules stay on Form Error Summary. Composition with header fields and large-grid extremes: Where Errors Appear.

Try this:

  1. Set a line Disc % above 0, leave Disc reason empty, blur, then Submit.
  2. Focus the invalid reason cell before fixing. Confirm hide-while-focused vs FormItem sticky red.
  3. Confirm the rollup lists the cell and Form Error Summary does not duplicate that cell path.
Actions
"use client";

import { isField } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = {
  id: string;
  sku: string;
  discountPct: number;
  discountReason: string;
};

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 120,
    field: { component: [Input], readOnly: () => true },
  },
  discountPct: {
    ...f.number({
      label: "Disc %",
    }),
    size: 90,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
      validator: (v) => {
        const n = Number(v);
        if (Number.isNaN(n) || n < 0 || n > 50) return "0–50%";
        return undefined;
      },
    },
  },
  discountReason: {
    ...f.text({
      label: "Disc reason",
    }),
    size: 180,
    field: {
      component: [Input, { placeholder: "Reason for discount" }],
      reactions: (field) => {
        const lines = field.form.values.lines as Line[] | undefined;
        const row = lines?.[field.index as number];
        const discounted = Number(row?.discountPct) > 0;
        field.display = discounted ? "visible" : "none";
        if (isField(field)) field.required = discounted;
      },
      validator: (value, row) => {
        const line = row as Line | undefined;
        if (Number(line?.discountPct) > 0 && !String(value ?? "").trim()) {
          return "Required when discount > 0";
        }
        return undefined;
      },
    },
  },
});

export function EditableTableCellValidationDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "l1", sku: "SKU-001", discountPct: 0, discountReason: "" },
            { id: "l2", sku: "SKU-002", discountPct: 0, discountReason: "" },
          ],
        },
      }),
    [],
  );

  return (
    <Form
      form={form}
      onSubmit={async () => {
        toast.success("Submitted");
      }}
    >
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        features={{ footer: false, rowActions: false }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
    </Form>
  );
}

File Upload Required

Pair required with validateFileRequired. Uploading items must not look like “done.” Content safety is BE-only.

Try this:

  1. Click Submit with no file. Confirm required chrome.
  2. Add a file and wait until it is done, then Submit. Confirm success.
  3. Do not treat an in-flight upload as a valid value.

Required — upload at least one file. Progress is not an error; submit waits until uploads finish.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { validateFileRequired } from "@/components/f-ui/formily/validators/file-upload";
import { Button } from "@/components/ui/button";

import { createFakeUpload } from "./fake-upload";

interface AttachmentValues {
  attachments: FileUploadItem[];
}

const upload = createFakeUpload();

export function FileUploadFormFieldDemo() {
  const form = useMemo(
    () =>
      createForm<AttachmentValues>({
        initialValues: { attachments: [] },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<AttachmentValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="attachments"
          label="Attachments"
          description="Required — upload at least one file. Progress is not an error; submit waits until uploads finish."
          kind="file"
          required
          validator={[{ validator: validateFileRequired }]}
          componentProps={{
            multiple: true,
            upload,
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Form Page And Modal Defaults

Long pages: FormPage default is scope="all" + focusOnInvalid="summary" + a docked Submit. Dialogs: Modal With A Table. Focus toggle: Error Summary And Focus.

Try this:

  1. The demo already submitted empty. Confirm the summary lists Order title / Warehouse, not the cell SKU.
  2. Confirm the docked footer Submit is visible (Form Page chrome).
  3. Fill the headers. Confirm those summary rows drop; the table rollup remains.
SKU
Qty
Actions
0

Schema Validators

JSON Schema forms use format + x-validator the same way FormField stacks kind + validator. Timing still follows this page’s revealErrors policy.

Try this:

  1. Leave Email empty or type not-an-email, Submit. Confirm format/required chrome.
  2. Switch Role to Admin. Confirm Admin code appears and is required.
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { SchemaField } from "@/components/f-ui/formily/schema-field";
import { Button } from "@/components/ui/button";

const ROLE_OPTIONS = [
  { label: "Member", value: "member" },
  { label: "Admin", value: "admin" },
];

const schema = {
  type: "object",
  properties: {
    email: {
      type: "string",
      title: "Email",
      required: true,
      format: "email",
      "x-decorator": "FormItem",
      "x-component": "EmailInput",
      "x-component-props": { placeholder: "you@example.com" },
    },
    role: {
      type: "string",
      title: "Role",
      "x-decorator": "FormItem",
      "x-component": "Select",
      "x-component-props": { options: ROLE_OPTIONS },
    },
    adminCode: {
      type: "string",
      title: "Admin code",
      description: "Visible and required only for admins",
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-component-props": { placeholder: "1234", autoComplete: "off" },
      "x-reactions": {
        dependencies: ["role"],
        fulfill: {
          state: {
            visible: '{{$deps[0] === "admin"}}',
            required: '{{$deps[0] === "admin"}}',
          },
        },
      },
    },
  },
};

interface SchemaValues {
  email: string;
  role: string;
  adminCode?: string;
}

export function FormilySchemaDemo() {
  const form = useMemo(
    () =>
      createForm<SchemaValues>({
        initialValues: { email: "", role: "member" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<SchemaValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <SchemaField schema={schema} />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Security Sensitive Failures

Login / OTP: one root issue. Do not say which of email or password was wrong.

Try this:

  1. Type any email and password. Click Sign in.
  2. Confirm the summary shows Email or password is incorrect.
  3. Confirm neither field is singled out as “wrong password” / “unknown user.”

I18n And Issue Codes

Built-in format / required copy follows the docs site language (en vs /zh/docs/...). Custom and server copy: keep a stable code for logic; show a localized message. Toggle below only swaps the custom Account code strings.

Try this:

  1. Confirm Email’s built-in required/format string matches the site language.
  2. Flip the switch. Confirm Account code copy switches en ↔ zh. Email built-in does not follow this switch.
  3. Type !!! in Account code. Confirm the pattern message in the selected custom language.

4. Shell Defaults

ShellCookbook action
Form (Formily)Baseline for all independent demos above
Form PageLong page; default focusOnInvalid="summary" + FormErrorSummary; see Form Page And Modal Defaults
Modal FormDialog lifecycle; revealErrors / focusOnInvalid (default first-field)
Steps Form / Modal Steps FormPer-step FE; write API on last Submit — Steps Form Hybrid
Query FilterSearch runs form.submit()Query Filter Search Submit
Editable TableCells + rollup via applyEditableArrayIssuesEditable Table Cells
Inline EditSingle-field save loop; not a primary cookbook recipe — use Formily for full fill forms

5. Anti-Patterns

ForbiddenDo instead
Summary-only field errorsSummary and field chrome
Dual-list table cell errorsOne aggregate owner (rollup or popover)
applyFormIssues under Editable Table cellsapplyEditableArrayIssues
FE as security boundaryAlways re-validate on the server
Enumerating users via field errorsGeneric root message
revealErrors="always" as the long-form default"submit" (or "touch" when justified)
Hand-rolled mini formsFormily
editable={false} as a session lockreadOnly / readPretty / form readOnly — Formily maps editable={false} to pretty
Treating server message as a stable APIPrefer code for logic; show message
Calling the write API after client validation failedGate on successful submit

6. FAQ

What is the default reveal policy? revealErrors="submit" — field errors after Submit (omit the prop). Use "touch" when blur feedback is justified; avoid "always" on long forms. touch means blur-then-show, not “first keystroke.” Hosts that need live-as-you-type on first entry should use always. Walk every case in When Errors Show.

Is client validation enough? No — UX preview only. The server remains authoritative for business invariants.

When do I use async validators? For previews such as username taken (createAsyncValidator); still re-check on submit (hybrid: preview then server). Debounce so the network is not hammered.

Can I list Editable Table cell errors in Form Error Summary and the table rollup? No — pick one aggregate owner. Cell paths use applyEditableArrayIssues + rollup; array-root / header stay on the summary. Decision tree: Where Errors Appear. See Object Messaging.

When do I turn Form Error Summary off? When the only errors are table cells (no header, array-root, or system issues) — errorSummary={false} and let the rollup own the list. Keep the Form Page default on when the page also has header or “at least one line” rules; the kit strips cell paths for you.

How do I show errors on a huge form? Header: Form Error Summary is the index (jump links, focus summary first). Lines: region rollup, never flatten 50 cell messages into the page summary. Hidden rows still fail Submit — Extreme Cases.

Warning vs error — does a warning block Submit? severity: "warning" does not appear in Form Error Summary. It does not block host success by itself. Inject an error when the submit must fail.

May I hand-roll a tiny Input + Button form? No — every fill-and-submit surface uses Formily (Form / FormField / shells). Read-only confirmations and non-field actions are exempt.

How do I stop double Submit? FormActions / Form Page disable the primary while submitting. Idempotency and “already processed” responses are host / backend concerns — do not rely on the button alone.

Where is table validation documented? Editable Table + Object Messaging.

Current Limitations

  • Async validator typing — when wiring createAsyncValidator as { triggerType, validator }, TypeScript may require an as FieldValidator cast. Debounce and abort still work at runtime; cast only the prop until the helper’s return type matches Formily.

On this page