Modal Form
A Formily form pre-wired into a modal with its own open, submit, and close lifecycle.
Plus Registry
Modal Form is a Plus-registry component. See Installation — Plus Registry.
Modal Form pairs the Formily engine with a modal shell so quick create and edit flows do not need a dedicated page. It manages open state, seeds initial values on open, drives the submit lifecycle, and closes on success — mirroring Ant Design Pro's ModalForm.
When To Use
- Quick create from a page header on a resource index (or edit from a row action), where a full page is overkill. Embedded / related lists may still open Create from the table or card toolbar.
- Short forms — roughly ≤ 8 fields, no line-item grid.
- Create a related resource by selecting rows from an existing list inside the modal (groups, shipments, grants) — store selected IDs in a Formily field synced to Table selection. For client full load vs server pagination, see Table — Data Loading.
- When you want submit, cancel, and close wired for you instead of hand-rolling a
Dialog+ form. - Prefer Modal Steps Form for short multi-step overlay wizards (gated Next / final Submit).
- Use Form Page instead for multi-section forms, line items, draft save, or unsaved-changes guards.
Features
| Area | Behavior |
|---|---|
| Open modes | trigger (self-managed) or open / onOpenChange (controlled) |
| Initial values | initialValues seeded into the form on each open |
| Submit lifecycle | onFinish resolves/void → close when closeOnSuccess (default true); { status: "error", issues } or throw → stay open |
| Reset | preserve={false} (default) resets on close; preserve keeps the draft |
| Submitter | Default Cancel + Submit; submitText / cancelText / submitter={false} |
| Busy | Footer + close disabled while submitting |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.jsonInstalls under the @f-ui-plus/modal-form namespace. Depends on the @f-ui-plus/formily engine and the shadcn dialog / button primitives.
Usage
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Button } from "@/components/ui/button";
<ModalForm
trigger={<Button>New project</Button>}
title="New project"
initialValues={{ name: "" }}
onFinish={async (values) => {
await save(values); // resolve → modal closes
}}
>
<FormField kind="text" name="name" label="Project name" required />
</ModalForm>;Examples
List Create And Edit
Create sits in the list header row (page-level); edit opens from the row. Both share one form shape — create uses trigger; edit uses controlled open + initialValues from the row. Submitting updates the table in place.
Workspace members
One ModalForm for create and edit; list body is f-ui Table
Actions | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@example.com | Admin | Active | |
| Alan Turing | alan@example.com | Viewer | Invited | |
| Grace Hopper | grace@example.com | Member | Active |
"use client";
import { useMemo, useState } from "react";
import { PencilIcon, PlusIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { f } from "@/components/f-ui/field-types/catalog";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
/**
* Resource-index pattern: page-header "New" + row "Edit" share one ModalForm shape.
* List body is f-ui Table — never hand-roll `@/components/ui/table`.
*/
interface Member {
id: string;
name: string;
email: string;
phone: string;
role: "admin" | "member" | "viewer";
status: "active" | "invited" | "disabled";
department: string;
notes: string;
}
type MemberFormValues = Omit<Member, "id">;
const ROLE_OPTIONS = [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
];
const STATUS_OPTIONS = [
{ label: "Active", value: "active" },
{ label: "Invited", value: "invited" },
{ label: "Disabled", value: "disabled" },
];
const DEPT_OPTIONS = [
{ label: "Engineering", value: "Engineering" },
{ label: "Design", value: "Design" },
{ label: "Sales", value: "Sales" },
{ label: "Operations", value: "Operations" },
];
const SEED: Member[] = [
{
id: "1",
name: "Ada Lovelace",
email: "ada@example.com",
phone: "+1 415 555 0101",
role: "admin",
status: "active",
department: "Engineering",
notes: "Platform owner",
},
{
id: "2",
name: "Grace Hopper",
email: "grace@example.com",
phone: "+1 415 555 0102",
role: "member",
status: "active",
department: "Engineering",
notes: "",
},
{
id: "3",
name: "Alan Turing",
email: "alan@example.com",
phone: "+1 415 555 0103",
role: "viewer",
status: "invited",
department: "Design",
notes: "Awaiting SSO setup",
},
];
const EMPTY: MemberFormValues = {
name: "",
email: "",
phone: "",
role: "member",
status: "invited",
department: "Engineering",
notes: "",
};
const memberSchema = defineDataListSchema<Member>({
name: f.text({ label: "Name", sortable: true }),
email: f.text({ label: "Email" }),
role: f.enum({
render: "status",
label: "Role",
variants: {
admin: { label: "Admin" },
member: { label: "Member" },
viewer: { label: "Viewer" },
},
}),
status: f.enum({
render: "status",
label: "Status",
variants: {
active: { label: "Active" },
invited: { label: "Invited" },
disabled: { label: "Disabled" },
},
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function MemberFields() {
return (
<>
<FormField
kind="text"
name="name"
label="Name"
required
componentProps={{ placeholder: "Full name" }}
/>
<FormField
kind="email"
name="email"
label="Email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
<FormField
kind="phone"
name="phone"
label="Phone"
componentProps={{ placeholder: "+1 …" }}
/>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] sm:grid-cols-2">
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{ options: ROLE_OPTIONS }}
/>
<FormField
kind="select"
name="status"
label="Status"
required
componentProps={{ options: STATUS_OPTIONS }}
/>
</div>
<FormField
kind="select"
name="department"
label="Department"
required
componentProps={{ options: DEPT_OPTIONS }}
/>
<FormField
kind="textarea"
name="notes"
label="Notes"
componentProps={{ placeholder: "Optional context for the team", rows: 3 }}
/>
</>
);
}
function ModalFormDemoInner() {
const [rows, setRows] = useState<Member[]>(SEED);
const [editing, setEditing] = useState<Member | null>(null);
const handle = useDataList({
schema: memberSchema,
listCode: "demo-modal-form-members",
data: rows,
getRowId: (r) => r.id,
defaultSort: [{ field: "name", order: "asc" }],
});
const rowActions = useMemo<RowAction<Member>[]>(
() => [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: (row) => setEditing(row),
},
],
[],
);
return (
<div className="w-full space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Workspace members</p>
<p className="text-muted-foreground text-xs">
One ModalForm for create and edit; list body is f-ui Table
</p>
</div>
<ModalForm<MemberFormValues>
trigger={
<Button>
<PlusIcon className="size-4" />
New member
</Button>
}
title="New member"
description="Invite someone to this workspace"
initialValues={EMPTY}
submitter={{ submitText: "Create" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 500));
setRows((prev) => [
{ id: String(Date.now()), ...values },
...prev,
]);
toast.success(`Created ${values.name}`);
}}
>
<MemberFields />
</ModalForm>
</div>
<div className="overflow-hidden rounded-xl border bg-card">
<Table
dataList={handle}
rowActions={rowActions}
rowActionsPresentation="icon"
maxInlineRowActions={1}
/>
</div>
<ModalForm<MemberFormValues>
open={editing !== null}
onOpenChange={(next) => {
if (!next) setEditing(null);
}}
title="Edit member"
description={editing ? `Update ${editing.name}` : undefined}
initialValues={
editing
? {
name: editing.name,
email: editing.email,
phone: editing.phone,
role: editing.role,
status: editing.status,
department: editing.department,
notes: editing.notes,
}
: undefined
}
submitter={{ submitText: "Save" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 500));
if (!editing) {
return {
status: "error",
issues: [
{
source: "submit",
message: "The selected member is no longer available",
},
],
};
}
setRows((prev) =>
prev.map((row) =>
row.id === editing.id ? { ...row, ...values } : row,
),
);
toast.success(`Saved ${values.name}`);
}}
>
<MemberFields />
</ModalForm>
</div>
);
}
export function ModalFormDemo() {
return (
<QueryClientProvider client={queryClient}>
<ModalFormDemoInner />
</QueryClientProvider>
);
}Rich Field Edit
Controlled edit with a wider modal (width={640}) and mixed Formily kinds — text, currency, select, date, multi-select, switch, textarea — so Modal Form is not limited to two inputs.
Apollo Headphones
SKU-APOLLO · $249
Canvas Tote
SKU-TOTE · $48
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Button } from "@/components/ui/button";
/**
* Controlled edit with a wider modal and mixed field kinds — shows ModalForm
* is not limited to two text inputs.
*/
interface ProductForm {
sku: string;
title: string;
price: number | null;
category: string;
tags: string[];
availableFrom: Date | null;
published: boolean;
description: string;
}
const CATEGORIES = [
{ label: "Electronics", value: "electronics" },
{ label: "Apparel", value: "apparel" },
{ label: "Home", value: "home" },
];
const TAG_OPTIONS = [
{ label: "New", value: "new" },
{ label: "Sale", value: "sale" },
{ label: "Featured", value: "featured" },
{ label: "Limited", value: "limited" },
];
const CATALOG: Array<{ id: string; label: string; values: ProductForm }> = [
{
id: "p1",
label: "Apollo Headphones",
values: {
sku: "SKU-APOLLO",
title: "Apollo Headphones",
price: 249,
category: "electronics",
tags: ["featured", "new"],
availableFrom: new Date(2026, 2, 1),
published: true,
description: "Wireless ANC headphones for studio and travel.",
},
},
{
id: "p2",
label: "Canvas Tote",
values: {
sku: "SKU-TOTE",
title: "Canvas Tote",
price: 48,
category: "apparel",
tags: ["sale"],
availableFrom: new Date(2026, 1, 15),
published: false,
description: "Heavyweight tote with interior pocket.",
},
},
];
export function ModalFormEditDemo() {
const [editingId, setEditingId] = useState<string | null>(null);
const record = CATALOG.find((item) => item.id === editingId) ?? null;
return (
<div className="flex w-full flex-col gap-2">
{CATALOG.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-4 rounded-xl border px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.label}</p>
<p className="text-muted-foreground truncate text-xs">
{item.values.sku} · ${item.values.price}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => setEditingId(item.id)}
>
Edit product
</Button>
</div>
))}
<ModalForm<ProductForm>
open={editingId !== null}
onOpenChange={(next) => {
if (!next) setEditingId(null);
}}
title="Edit product"
description="Update catalog fields without leaving the list"
width={640}
initialValues={record?.values}
submitter={{ submitText: "Save changes" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 450));
toast.success(`Saved ${values.title}`);
}}
>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] sm:grid-cols-2">
<FormField
kind="text"
name="sku"
label="SKU"
required
componentProps={{ placeholder: "SKU-…" }}
/>
<FormField
kind="currency"
name="price"
label="Price"
required
componentProps={{ currency: "USD" }}
/>
</div>
<FormField
kind="text"
name="title"
label="Title"
required
componentProps={{ placeholder: "Product title" }}
/>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] sm:grid-cols-2">
<FormField
kind="select"
name="category"
label="Category"
required
componentProps={{ options: CATEGORIES }}
/>
<FormField
kind="date"
name="availableFrom"
label="Available from"
/>
</div>
<FormField
kind="multiSelect"
name="tags"
label="Tags"
componentProps={{ options: TAG_OPTIONS, placeholder: "Select tags" }}
/>
<FormField kind="switch" name="published" label="Published" />
<FormField
kind="textarea"
name="description"
label="Description"
componentProps={{ rows: 3, placeholder: "Merchandising copy" }}
/>
</ModalForm>
</div>
);
}Keep Open on Error
Return { status: "error", issues } from onFinish when the server rejects the payload. The modal stays open, applies the issues, and keeps what the user typed. Use closeOnSuccess={false} when a successful submit should also leave the modal open.
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Button } from "@/components/ui/button";
/** Server-side validation keeps the modal open with the user's input intact. */
interface InviteForm {
email: string;
role: "admin" | "member";
message: string;
}
const TAKEN = new Set(["ada@example.com", "grace@example.com"]);
export function ModalFormStayOpenDemo() {
const [attempts, setAttempts] = useState(0);
return (
<div className="flex flex-col items-start gap-3">
<ModalForm<InviteForm>
trigger={<Button variant="outline">Invite teammate</Button>}
title="Invite teammate"
description="Try ada@example.com — the server will reject it"
initialValues={{ email: "", role: "member", message: "" }}
submitter={{ submitText: "Send invite" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 600));
setAttempts((n) => n + 1);
if (TAKEN.has(values.email.trim().toLowerCase())) {
toast.error("That email is already on the workspace");
return {
status: "error",
issues: [
{
path: "email",
source: "server",
message: "That email is already on the workspace",
},
],
};
}
toast.success(`Invite sent to ${values.email}`);
}}
>
<FormField
kind="email"
name="email"
label="Work email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{
options: [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
],
}}
/>
<FormField
kind="textarea"
name="message"
label="Personal message"
componentProps={{
rows: 3,
placeholder: "Optional note included in the invite email",
}}
/>
</ModalForm>
{attempts > 0 ? (
<p className="text-muted-foreground text-xs">
Submit attempts: {attempts} (failed invites keep the modal open)
</p>
) : null}
</div>
);
}Page Header Composition
Filter stays with the list chrome; New sits in the header row above. Duplicate labels return { status: "error", issues } so the modal stays open. Plain composition — no special QueryList API. See CRUD Page Patterns — Action Placement.
Tags
Create from the header; search stays with the list
| bug | Rose | Defect triage | 34 |
| docs | Neutral | Documentation | 8 |
| release | Blue | Ship checklist | 12 |
"use client";
import { useMemo, useState } from "react";
import { PlusIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { f } from "@/components/f-ui/field-types/catalog";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
/**
* Page-header composition: "New" in the list header row; search stays with the
* list chrome below — not clustered as a toolbar sibling to Create.
* List body is f-ui Table — never a hand-rolled <ul>.
*/
interface TagForm {
label: string;
color: string;
description: string;
}
interface TagRow extends TagForm {
id: string;
count: number;
}
const COLOR_OPTIONS = [
{ label: "Neutral", value: "neutral" },
{ label: "Blue", value: "blue" },
{ label: "Green", value: "green" },
{ label: "Amber", value: "amber" },
{ label: "Rose", value: "rose" },
];
const SEED: TagRow[] = [
{ id: "1", label: "release", color: "blue", description: "Ship checklist", count: 12 },
{ id: "2", label: "bug", color: "rose", description: "Defect triage", count: 34 },
{ id: "3", label: "docs", color: "neutral", description: "Documentation", count: 8 },
];
const tagSchema = defineDataListSchema<TagRow>({
label: f.text({ label: "Label", sortable: true }),
color: f.enum({
render: "status",
label: "Color",
variants: {
neutral: { label: "Neutral" },
blue: { label: "Blue" },
green: { label: "Green" },
amber: { label: "Amber" },
rose: { label: "Rose" },
},
}),
description: f.text({ label: "Description" }),
count: f.number({ label: "Uses", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function ModalFormQueryListDemoInner() {
const [tags, setTags] = useState<TagRow[]>(SEED);
const handle = useDataList({
schema: tagSchema,
listCode: "demo-modal-form-tags",
data: tags,
getRowId: (row) => row.id,
defaultSort: [{ field: "label", order: "asc" }],
features: { filters: true },
});
const existingLabels = useMemo(
() => new Set(tags.map((tag) => tag.label)),
[tags],
);
return (
<DataListProvider dataList={handle}>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">Tags</p>
<p className="text-muted-foreground text-xs">
Create from the header; search stays with the list
</p>
</div>
<ModalForm<TagForm>
trigger={
<Button>
<PlusIcon className="size-4" />
New tag
</Button>
}
title="New tag"
description="Tags appear in list filters and row labels"
width={480}
initialValues={{ label: "", color: "neutral", description: "" }}
submitter={{ submitText: "Add tag" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 350));
const label = values.label.trim();
if (existingLabels.has(label)) {
toast.error("A tag with that label already exists");
return {
status: "error",
issues: [
{
path: "label",
source: "server",
message: "A tag with that label already exists",
},
],
};
}
setTags((prev) => [
{
id: String(Date.now()),
count: 0,
...values,
label,
},
...prev,
]);
toast.success(`Added #${label}`);
}}
>
<FormField
kind="text"
name="label"
label="Label"
required
validator={{
pattern: /^[a-z0-9-]+$/,
message: "Use lowercase letters, numbers, and hyphens",
}}
componentProps={{ placeholder: "e.g. release" }}
/>
<FormField
kind="select"
name="color"
label="Color"
required
componentProps={{ options: COLOR_OPTIONS }}
/>
<FormField
kind="textarea"
name="description"
label="Description"
componentProps={{ rows: 2, placeholder: "Where this tag is used" }}
/>
</ModalForm>
</div>
<DataListSearchInput className="sm:max-w-xs" dataList={handle} />
<div className="overflow-hidden rounded-xl border bg-card">
<Table
dataList={handle}
columns={{ count: { align: "end" } }}
/>
</div>
</div>
</DataListProvider>
);
}
export function ModalFormQueryListDemo() {
return (
<QueryClientProvider client={queryClient}>
<ModalFormQueryListDemoInner />
</QueryClientProvider>
);
}Select Members From List
Create a group by picking members from an embedded Data List Table with checkbox selection. The table selection is controlled by a Formily field (memberIds) via features.selection: { value, onChange } — one source of truth, no manual sync. Validation and onFinish receive one payload. This demo uses the client full-load recipe (createInMemoryListAdapter({ load })) — for when to use server pagination instead, see Table — Data Loading. Modal Form children unmount on close (Radix Presence), so transient table state (search, page) resets each open; pre-select members via initialValues.
Workspace groups
Pick members from a directory table inside the modal
| Design critique | Viewer | 2 |
| Platform | Member | 4 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { connect, observer } from "@formily/react";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { DataListPagination } from "@/components/f-ui/data-list-chrome/data-list-pagination";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { f } from "@/components/f-ui/field-types/catalog";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import {
createDirectoryMembers,
delay,
type DirectoryMember,
} from "@/demos/table/directory-demo-data";
/**
* Modal composition: create a group by picking members from an embedded Table.
* Data strategy (client full load) matches Table — Client Full Load; for server
* pagination copy the Table server-offset adapter into this field.
* Outer groups index is also f-ui Table — not a hand-rolled <ul>.
*/
interface CreateGroupForm {
name: string;
description: string;
defaultRole: "admin" | "member" | "viewer";
memberIds: string[];
}
interface GroupRow {
id: string;
name: string;
memberCount: number;
defaultRole: CreateGroupForm["defaultRole"];
}
const ROLE_OPTIONS = [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
];
const DIRECTORY = createDirectoryMembers(32);
const INITIAL_GROUPS: GroupRow[] = [
{ id: "g1", name: "Platform", memberCount: 4, defaultRole: "member" },
{ id: "g2", name: "Design critique", memberCount: 2, defaultRole: "viewer" },
];
const memberSchema = defineDataListSchema<DirectoryMember>({
name: f.text({ label: "Name", sortable: true }),
email: f.text({ label: "Email" }),
department: f.text({ label: "Department" }),
});
const groupSchema = defineDataListSchema<GroupRow>({
name: f.text({ label: "Name", sortable: true }),
defaultRole: f.enum({
render: "status",
label: "Default role",
variants: {
admin: { label: "Admin" },
member: { label: "Member" },
viewer: { label: "Viewer" },
},
}),
memberCount: f.number({ label: "Members", sortable: true }),
});
const EMPTY: CreateGroupForm = {
name: "",
description: "",
defaultRole: "member",
memberIds: [],
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
interface MemberPickerControlProps {
value?: string[];
onChange?: (value: string[]) => void;
}
const MemberPickerControl = observer(function MemberPickerControl({
value = [],
onChange,
}: MemberPickerControlProps) {
const adapter = useMemo(
() =>
createInMemoryListAdapter<DirectoryMember>({
load: async (signal) => {
await delay(400, signal);
return DIRECTORY;
},
}),
[],
);
// Controlled selection: the Formily field value is the single source of truth.
// useDataList mirrors it into the table and reports user toggles via onChange —
// no manual two-way sync effects required.
const handle = useDataList({
schema: memberSchema,
listCode: "demo-group-member-picker",
adapter,
getRowId: (row) => row.id,
defaultPageSize: 5,
features: {
filters: true,
selection: { value, onChange: (ids) => onChange?.(ids) },
pagination: { mode: "offset" },
},
});
return (
<DataListProvider dataList={handle}>
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<DataListSearchInput className="sm:max-w-xs" dataList={handle} />
<p className="text-muted-foreground text-xs tabular-nums">
{value.length} selected across all pages
</p>
</div>
<Table dataList={handle} stickyHeader />
<DataListPagination
dataList={handle}
pageSizeOptions={[5, 10]}
className="border-border border-t pt-2"
/>
</div>
</DataListProvider>
);
});
const MemberPickerField = connect(MemberPickerControl);
function ModalFormSelectMembersDemoInner() {
const [groups, setGroups] = useState<GroupRow[]>(INITIAL_GROUPS);
const groupsHandle = useDataList({
schema: groupSchema,
listCode: "demo-workspace-groups",
data: groups,
getRowId: (row) => row.id,
defaultSort: [{ field: "name", order: "asc" }],
});
return (
<div className="w-full space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Workspace groups</p>
<p className="text-muted-foreground text-xs">
Pick members from a directory table inside the modal
</p>
</div>
<ModalForm<CreateGroupForm>
trigger={
<Button>
<PlusIcon className="size-4" />
New group
</Button>
}
title="New group"
description="Choose members and set a default role for the group"
width={720}
initialValues={EMPTY}
submitter={{ submitText: "Create group" }}
onFinish={async (values) => {
await new Promise((resolve) => setTimeout(resolve, 400));
setGroups((prev) => [
{
id: String(Date.now()),
name: values.name.trim(),
memberCount: values.memberIds.length,
defaultRole: values.defaultRole,
},
...prev,
]);
toast.success(`Created ${values.name.trim()}`);
}}
>
<FormField
kind="text"
name="name"
label="Group name"
required
componentProps={{ placeholder: "e.g. Release squad" }}
/>
<FormField
kind="textarea"
name="description"
label="Description"
componentProps={{
rows: 2,
placeholder: "What this group is responsible for",
}}
/>
<FormField
kind="select"
name="defaultRole"
label="Default role"
required
componentProps={{ options: ROLE_OPTIONS }}
/>
<FormField
name="memberIds"
label="Members"
required
validator={{
validator: (val: unknown) =>
Array.isArray(val) && val.length > 0
? true
: "Select at least one member",
}}
component={[MemberPickerField]}
/>
</ModalForm>
</div>
<div className="overflow-hidden rounded-xl border bg-card">
<Table
dataList={groupsHandle}
columns={{ memberCount: { align: "end" } }}
/>
</div>
</div>
);
}
export function ModalFormSelectMembersDemo() {
return (
<QueryClientProvider client={queryClient}>
<ModalFormSelectMembersDemoInner />
</QueryClientProvider>
);
}Composition
ModalForm
├── Dialog (open / onOpenChange)
│ └── DialogContent (default max-width 520px; inline max-width + `sm:max-w-none` so size presets are not capped)
│ ├── DialogHeader (title, description)
│ └── Form (Formily <form>)
│ ├── scroll body (FormField / SchemaField …)
│ └── DialogFooter (Cancel + Submit, pinned)API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Form fields |
form | Form<T> | internal | External Formily form; omit to create one |
initialValues | Partial<T> | — | Seeded into the form on each open |
trigger | ReactElement | — | Element that opens the modal on click |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Uncontrolled initial open |
onOpenChange | (open: boolean) => void | — | Open-state change callback |
title | ReactNode | — | Modal title |
description | ReactNode | — | Modal description |
size | "sm" | "md" | "lg" | "xl" | — | Preset max width: 400 / 520 / 800 / 960. Ignored when width is set |
width | number | string | 520 | Max width; overrides size; number → px |
modalProps | DialogContent props | — | Passed through to the dialog content |
onFinish | (values: T) => FormSubmitResult | Promise<FormSubmitResult> | — | Submit handler; void/resolve closes when closeOnSuccess; { status: "error", issues } stays open |
closeOnSuccess | boolean | true | Close the modal after a successful onFinish |
focusOnInvalid | "summary" | "first-field" | false | "first-field" | Focus strategy after validation or known submit errors |
revealErrors | "touch" | "submit" | "always" | from Form | When invalid-field errors become visible |
onSubmitError | (error: unknown) => void | — | Called for unexpected throws (root system issue is also applied) |
preserve | boolean | false | Keep values on close instead of resetting |
loading | boolean | false | Force-disable footer actions |
submitter | ModalFormSubmitter | false | — | Footer config; false hides it |
t / locale | translator / string | — | Per-instance i18n overrides |
className / classNames | string / slot map | — | Styling (content / header / body / footer) |
Layout props (orientation, labelWidth, labelAlign, colon, density, controlMaxWidth) are inherited from FormLayoutProps.
Slots
content · header · body · footer — target via classNames.