Form Definitions
Reference for the shared formDefs structure used by FormBuilder.Single and FormBuilder.Bulk.
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
import type { FormDefs } from "@tilt-legal/cubitt-components/form-builder";
const formDefs = [
// Fields, groups, and steps...
] as const satisfies FormDefs<MyFormData>;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.
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
Steps create multi-step wizard flows. They're only used by FormBuilder.Single; FormBuilder.Bulk ignores them.
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
| 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 |
When you mix loose groups/fields with step nodes, the loose nodes are wrapped into an implicit step automatically.
Group Nodes
Groups visually organise related fields with optional titles:
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
| 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 |
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.
Field Nodes
Fields define individual form inputs:
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
| 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
| 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
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
For select and radio components, provide an options array:
{
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:
type Option = {
id?: string | number;
value: string;
label: string;
disabled?: boolean;
};Radio-Specific Properties
| Property | Type | Description |
|---|---|---|
vertical | boolean | Display options vertically |
Text/Email/Number-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:
{ 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
| Property | Type | Description |
|---|---|---|
defaultCountry | string | Default country code (e.g., "US") |
Tags-Specific Properties
| Property | Type | Description |
|---|---|---|
enableCommaDelimiter | boolean | Create tags on comma (default: true) |
Number/Slider-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
| 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
import { z } from "zod";
import type { FormDefs } from "@tilt-legal/cubitt-components/form-builder";
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>;