

`formDefs` is a nested array of `step`, `group`, and `field` nodes that describes the structure, order, and behaviour of fields. Both `FormBuilder.Single` and `FormBuilder.Bulk` consume the same definition array, so you only maintain one source of truth.

## Usage [#usage]

```tsx
import type { FormDefs } from "@tilt-legal/cubitt-components/composites";

const formDefs = [
  // Fields, groups, and steps...
] as const satisfies FormDefs<MyFormData>;
```

<Callout type="info">
  Always use `as const satisfies FormDefs<T>` for full type safety with your schema. The same `FormDefs` type works for both Single and Bulk forms.
</Callout>

## How formDefs Are Used [#how-formdefs-are-used]

* **FormBuilder.Single** renders a dynamic form UI, wiring each field to TanStack Form and Zod validation
* **FormBuilder.Bulk** generates spreadsheet columns, CSV templates, and value coercion logic

Changes to labels, options, or field metadata are reflected in both experiences.

## Step Nodes [#step-nodes]

Steps create multi-step wizard flows. They're only used by `FormBuilder.Single`; `FormBuilder.Bulk` ignores them.

```tsx
const step = {
  kind: "step",
  id: "contact",
  title: "Contact Details",
  description: "Primary contact information",
  nextLabel: "Continue",
  previousLabel: "Go Back",
  showIf: (values) => values.enableWizard,
  onEnter: (values) => console.log("Entered step"),
  onExit: (values) => console.log("Exited step"),
  children: [
    // Groups and fields...
  ],
};
```

### Step Properties [#step-properties]

| Property        | Type                  | Description                                                    |
| --------------- | --------------------- | -------------------------------------------------------------- |
| `kind`          | `"step"`              | Always `"step"`                                                |
| `id`            | `string`              | Stable identifier for `defaultStep` and `stepper.actions.goTo` |
| `title`         | `string`              | Rendered in default navigation and exposed on stepper state    |
| `description`   | `string`              | Optional helper copy for the step                              |
| `nextLabel`     | `string`              | Overrides default "Next"/"Submit" button text                  |
| `previousLabel` | `string`              | Overrides default "Back" button text                           |
| `showIf`        | `(values) => boolean` | When false, the step is skipped entirely                       |
| `onEnter`       | `(values) => void`    | Callback when the step becomes active                          |
| `onExit`        | `(values) => void`    | Callback when the step is left                                 |
| `children`      | `array`               | Groups and fields rendered when active                         |

<Callout type="info">
  When you mix loose groups/fields with step nodes, the loose nodes are wrapped
  into an implicit step automatically.
</Callout>

## Group Nodes [#group-nodes]

Groups visually organise related fields with optional titles:

```tsx
const group = {
  kind: "group",
  name: "organisation",
  title: "Organisation",
  description: "Company details for invoicing",
  gap: 24,
  minWidth: 280,
  children: [
    {
      kind: "field",
      name: "companyName",
      label: "Company",
      component: "text",
      size: "full",
    },
    {
      kind: "field",
      name: "taxId",
      label: "Tax ID",
      component: "text",
      size: "half",
    },
  ],
};
```

### Group Properties [#group-properties]

| Property      | Type      | Description                                                                      |
| ------------- | --------- | -------------------------------------------------------------------------------- |
| `kind`        | `"group"` | Always `"group"`                                                                 |
| `name`        | `string`  | Optional schema path used to bind object-level validation errors to the fieldset |
| `title`       | `string`  | Rendered as the group title via `FormFieldSet` for accessibility                 |
| `description` | `string`  | Helper copy shown below the title                                                |
| `gap`         | `number`  | Gap in pixels between children (default: 22)                                     |
| `minWidth`    | `number`  | Minimum width before items wrap (default: 180)                                   |
| `children`    | `array`   | Nested groups and fields                                                         |

<Callout type="info">
  Add `name` when the schema can raise an error for the object as a whole, such
  as a checkbox collection at `practiceAreas`. Cubitt will render that message
  once under the full fieldset instead of forcing you to pin the error to the
  first child field.
</Callout>

## Field Nodes [#field-nodes]

Fields define individual form inputs:

```tsx
const field = {
  kind: "field",
  name: "email",
  label: "Email Address",
  description: "We'll send confirmation here",
  tooltip: "Must be a valid work email",
  component: "email",
  size: "full",
  showIf: (values) => values.notificationType === "email",
  requiredIf: (values) => values.notificationType === "email",
  disabledIf: (values) => values.locked,
  clearWhenHidden: true,
};
```

### Common Field Properties [#common-field-properties]

| Property          | Type                          | Description                                                |
| ----------------- | ----------------------------- | ---------------------------------------------------------- |
| `kind`            | `"field"`                     | Always `"field"`                                           |
| `name`            | `string`                      | Dot/bracket path matching schema (e.g., `"contact.email"`) |
| `component`       | `FieldComponent`              | Determines which field type renders (see below)            |
| `label`           | `string`                      | Field label (defaults to humanised `name`)                 |
| `description`     | `string`                      | Inline helper text below the label                         |
| `tooltip`         | `string`                      | Tooltip content shown next to the label                    |
| `size`            | `"full" \| "half" \| "third"` | Responsive width hint                                      |
| `breakBefore`     | `boolean`                     | Force field onto a new row                                 |
| `clearWhenHidden` | `boolean`                     | Clear value when hidden (default: true)                    |

### Conditional Predicates [#conditional-predicates]

| Property     | Type                  | Description            |
| ------------ | --------------------- | ---------------------- |
| `showIf`     | `(values) => boolean` | Show/hide the field    |
| `disabledIf` | `(values) => boolean` | Disable the field      |
| `readOnlyIf` | `(values) => boolean` | Make field read-only   |
| `requiredIf` | `(values) => boolean` | Add required indicator |

## Component Types [#component-types]

### Available Components [#available-components]

| Component        | Description                    |
| ---------------- | ------------------------------ |
| `"text"`         | Standard text input            |
| `"textarea"`     | Multi-line text                |
| `"email"`        | Email input with validation    |
| `"number"`       | Numeric input                  |
| `"phone"`        | Phone number with country code |
| `"date"`         | Date picker                    |
| `"select"`       | Dropdown select                |
| `"radio"`        | Radio button group             |
| `"checkbox"`     | Checkbox input                 |
| `"switch"`       | Toggle switch                  |
| `"slider"`       | Range slider                   |
| `"tags"`         | Tag/chip input                 |
| `"autocomplete"` | Autocomplete with search       |

### Select and Radio Options [#select-and-radio-options]

For `select` and `radio` components, provide an `options` array:

```tsx
{
  kind: "field",
  name: "role",
  label: "Role",
  component: "select",
  options: [
    { value: "admin", label: "Administrator" },
    { value: "member", label: "Member" },
    { value: "guest", label: "Guest", disabled: true },
  ],
  size: "full",
}
```

| Property  | Type       | Description                       |
| --------- | ---------- | --------------------------------- |
| `options` | `Option[]` | Required for `select` and `radio` |

Option type:

```tsx
type Option = {
  id?: string | number;
  value: string;
  label: string;
  disabled?: boolean;
};
```

### Radio-Specific Properties [#radio-specific-properties]

| Property   | Type      | Description                |
| ---------- | --------- | -------------------------- |
| `vertical` | `boolean` | Display options vertically |

### Text/Email/Number-Specific Properties [#textemailnumber-specific-properties]

| Property     | Type                  | Description                                                                     |
| ------------ | --------------------- | ------------------------------------------------------------------------------- |
| `inputProps` | `InputHTMLAttributes` | HTML attributes passed to the input element (e.g., `autoComplete`, `inputMode`) |

Useful for browser autofill hints and password manager integration:

```tsx
{ kind: "field", name: "email", component: "email", inputProps: { autoComplete: "email" } }
{ kind: "field", name: "password", component: "text", inputProps: { autoComplete: "current-password" } }
{ kind: "field", name: "apiKey", component: "text", inputProps: { "data-1p-ignore": true, autoComplete: "off" } }
```

### Phone-Specific Properties [#phone-specific-properties]

| Property         | Type     | Description                         |
| ---------------- | -------- | ----------------------------------- |
| `defaultCountry` | `string` | Default country code (e.g., `"US"`) |

### Tags-Specific Properties [#tags-specific-properties]

| Property               | Type      | Description                          |
| ---------------------- | --------- | ------------------------------------ |
| `enableCommaDelimiter` | `boolean` | Create tags on comma (default: true) |

### Number/Slider-Specific Properties [#numberslider-specific-properties]

| Property     | Type                                | Description                       |
| ------------ | ----------------------------------- | --------------------------------- |
| `min`        | `number`                            | Minimum value                     |
| `max`        | `number`                            | Maximum value                     |
| `step`       | `number`                            | Step increment                    |
| `valueLabel` | `boolean \| ((props) => ReactNode)` | Show value label or custom render |

### Autocomplete-Specific Properties [#autocomplete-specific-properties]

| Property                   | Type               | Description                                            |
| -------------------------- | ------------------ | ------------------------------------------------------ |
| `autocompleteItems`        | `unknown[]`        | Items to display in the list                           |
| `autocompleteItemToString` | `(item) => string` | Convert item to display string                         |
| `autocompleteItemToKey`    | `(item) => string` | Unique key for each item                               |
| `autocompleteEmptyMessage` | `string`           | Message when no results (default: "No results found.") |

## Complete Example [#complete-example]

```tsx
import { z } from "zod";
import type { FormDefs } from "@tilt-legal/cubitt-components/composites";

const schema = z.object({
  firstName: z.string().min(1, "Required"),
  lastName: z.string().min(1, "Required"),
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
  department: z.string().optional(),
  notifications: z.boolean().default(true),
});

type FormData = z.infer<typeof schema>;

const formDefs = [
  {
    kind: "group",
    title: "Personal Info",
    children: [
      {
        kind: "field",
        name: "firstName",
        label: "First name",
        component: "text",
        size: "half",
      },
      {
        kind: "field",
        name: "lastName",
        label: "Last name",
        component: "text",
        size: "half",
      },
      {
        kind: "field",
        name: "email",
        label: "Email",
        component: "email",
        size: "full",
      },
    ],
  },
  {
    kind: "group",
    title: "Access",
    children: [
      {
        kind: "field",
        name: "role",
        label: "Role",
        component: "select",
        options: [
          { value: "admin", label: "Admin" },
          { value: "member", label: "Member" },
        ],
        size: "half",
      },
      {
        kind: "field",
        name: "department",
        label: "Department",
        component: "text",
        size: "half",
        showIf: (values) => values.role === "admin",
      },
      {
        kind: "field",
        name: "notifications",
        label: "Email notifications",
        component: "switch",
        size: "full",
      },
    ],
  },
] as const satisfies FormDefs<FormData>;
```
