f-ui
Components

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

AreaBehavior
Open modestrigger (self-managed) or open / onOpenChange (controlled)
Initial valuesinitialValues seeded into the form on each open
Submit lifecycleonFinish resolves/void → close when closeOnSuccess (default true); { status: "error", issues } or throw → stay open
Resetpreserve={false} (default) resets on close; preserve keeps the draft
SubmitterDefault Cancel + Submit; submitText / cancelText / submitter={false}
BusyFooter + close disabled while submitting

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.json
npx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.json
bunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-form.json

Installs 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 Lovelaceada@example.comAdminActive
Alan Turingalan@example.comViewerInvited
Grace Hoppergrace@example.comMemberActive
"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

bugRoseDefect triage34
docsNeutralDocumentation8
releaseBlueShip checklist12
"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 critiqueViewer2
PlatformMember4
"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

PropTypeDefaultDescription
childrenReactNodeForm fields
formForm<T>internalExternal Formily form; omit to create one
initialValuesPartial<T>Seeded into the form on each open
triggerReactElementElement that opens the modal on click
openbooleanControlled open state
defaultOpenbooleanfalseUncontrolled initial open
onOpenChange(open: boolean) => voidOpen-state change callback
titleReactNodeModal title
descriptionReactNodeModal description
size"sm" | "md" | "lg" | "xl"Preset max width: 400 / 520 / 800 / 960. Ignored when width is set
widthnumber | string520Max width; overrides size; number → px
modalPropsDialogContent propsPassed through to the dialog content
onFinish(values: T) => FormSubmitResult | Promise<FormSubmitResult>Submit handler; void/resolve closes when closeOnSuccess; { status: "error", issues } stays open
closeOnSuccessbooleantrueClose 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 FormWhen invalid-field errors become visible
onSubmitError(error: unknown) => voidCalled for unexpected throws (root system issue is also applied)
preservebooleanfalseKeep values on close instead of resetting
loadingbooleanfalseForce-disable footer actions
submitterModalFormSubmitter | falseFooter config; false hides it
t / localetranslator / stringPer-instance i18n overrides
className / classNamesstring / slot mapStyling (content / header / body / footer)

Layout props (orientation, labelWidth, labelAlign, colon, density, controlMaxWidth) are inherited from FormLayoutProps.

Slots

content · header · body · footer — target via classNames.

On this page