# Plugin Form





Advanced form components with validation, multi-step forms, and comprehensive field support.

## Installation [#installation]

```bash
npm install @object-ui/plugin-form
```

<PluginLoader plugins="['form']">
  ## Interactive Examples [#interactive-examples]

  Each preview below **is** an `object-form` node drawn by this plugin — the JSON
  in the Code tab is the whole example, and the fields on screen were built from
  the `users` object's own metadata rather than written into that JSON. Both the
  metadata and the record come from the docs site's demo data source, because
  `dataSource` is not a schema key: it is the prop the registered renderer pulls
  off `SchemaRendererProvider` context (see
  [Registration is a side effect of the import](#registration-is-a-side-effect-of-the-import)
  below), so in your own app these same nodes read whatever object your data
  source serves.

  A plain `form` node with its fields written out inline is the
  `@object-ui/components` renderer, not this plugin — that is what these two
  examples used to be, and they now live in the catalog under
  `components-form-form`.

  ### One record, fields from the object [#one-record-fields-from-the-object]

  <SchemaExample id="plugin-form/object-form-record" />

  ### Sections as tabs [#sections-as-tabs]

  <SchemaExample id="plugin-form/object-form-tabbed-sections" />

  ## Features [#features]

  * **Form Builder** - Create complex forms from schemas
  * **Validation** - Built-in validation with custom rules
  * **Multi-Step Forms** - Wizard-style multi-step forms
  * **Field Types** - All standard HTML5 input types
  * **Form State** - Automatic state management
  * **Error Handling** - Field-level and form-level errors

  ## Schema API [#schema-api]

  ### Form [#form]

  ```plaintext
  {
    type: 'form',
    fields: FormField[],
    submitLabel?: string,
    cancelLabel?: string,
    onSubmit?: (data) => void,
    onCancel?: () => void,
    className?: string
  }
  ```

  ### Form Field [#form-field]

  `FormField` here means the one declared by `@object-ui/types`
  (`packages/types/src/form.ts`) — `@objectstack/spec` ships its own copy of this
  name (`json-schema/ui/FormField.json`) with a different key set, so read this
  table against the `@object-ui/types` copy rather than the same-named schema in
  the spec. This page does not redeclare it, because a locally written
  `interface` compiles no matter how far it has drifted from the real one. It
  has **23 declared keys**, and `name` is the only **required** one: `type` and
  `label` are both optional.

  | Key                                             | Type                              | What it does                                                                                                                                                                              |
  | ----------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `name`                                          | `string`                          | **required** — the key the value is submitted under                                                                                                                                       |
  | `id`                                            | `string`                          | stable render key; falls back to `name`                                                                                                                                                   |
  | `label`                                         | `string`                          | optional. With none, no label element is rendered at all, and validation messages fall back to `name`                                                                                     |
  | `description`                                   | `string`                          | help text rendered under the control                                                                                                                                                      |
  | `type`                                          | `string`                          | optional, defaults to `'input'`. Built-ins: `input`, `textarea`, `checkbox`, `switch`, `select`; any other value resolves through the registry (`field:<type>` first, then the bare name) |
  | `inputType`                                     | `string`                          | HTML input type for `type: 'input'` — `'email'`, `'password'`, `'tel'`, …                                                                                                                 |
  | `widget`                                        | `string`                          | widget override; wins over `type` (spec `FormField.widget`)                                                                                                                               |
  | `placeholder`                                   | `string`                          | placeholder text                                                                                                                                                                          |
  | `required`                                      | `boolean`                         | the presence rule. `validation.required` does **not** make a field required — it only supplies the message                                                                                |
  | `disabled`                                      | `boolean`                         | not interactive, muted                                                                                                                                                                    |
  | `readonly`                                      | `boolean`                         | shown plainly, not editable — deliberately distinct from `disabled`                                                                                                                       |
  | `hidden`                                        | `boolean`                         | the field is not rendered at all                                                                                                                                                          |
  | `options`                                       | `SelectOption[] \| RadioOption[]` | choices for `select` / radio fields                                                                                                                                                       |
  | `validation`                                    | `FieldValidationRules`            | **an object keyed by rule name** — see below                                                                                                                                              |
  | `condition`                                     | `FieldCondition`                  | legacy `{ field, equals, notEquals, in, custom }` matcher                                                                                                                                 |
  | `visibleWhen` / `readonlyWhen` / `requiredWhen` | `string \| { dialect?, source }`  | CEL predicates over the live record, evaluated by `@objectstack/formula` — the same engine and dialect the server enforces. They fail open                                                |
  | `visibleOn`                                     | `string \| { dialect?, source }`  | view-level visibility predicate (spec `FormField.visibleOn`)                                                                                                                              |
  | `dependsOn`                                     | `DependsOnInput`                  | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries                                                                                                          |
  | `span`                                          | `'auto' \| 'full'`                | relative field width, independent of the column count (preferred)                                                                                                                         |
  | `colSpan`                                       | `number`                          | legacy column span (1–4), clamped to the current column count                                                                                                                             |
  | `field`                                         | `Record<string, any>`             | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference_to`, …                                           |

  `FormField` also declares `[key: string]: any`, so a misspelled or invented key
  is **not** a compile error — it is simply outside the contract. Two that a
  reader might expect here, and that are **not** declared members:

  | Not a `FormField` key | Write this instead                                                                                                                |
  | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
  | `defaultValue`        | `FormSchema.defaultValues`, at form level. An object-bound form seeds from the object field's own declared `defaultValue` instead |
  | `className`           | `span` / `colSpan` for width, `FormSchema.fieldContainerClass` for the field grid                                                 |

  An undeclared key still rides the props spread down to whichever component the
  field resolves to, so a field-level `className` can visibly land on a built-in
  control — but nothing in the contract promises that, and a registered widget
  honours it only if it happens to spread its leftover props. A field-level
  `defaultValue` reaches the widget the same way and changes nothing: the control
  is bound to react-hook-form, whose initial values come from the form.

  #### `validation` is an object keyed by rule name [#validation-is-an-object-keyed-by-rule-name]

  There is no `ValidationRule` type in this repository. Near-spellings do exist —
  `AdvancedValidationRule`, `ValidationRuleType`, `ObjectValidationRule` and
  `DesignerValidationRule` — and none of them types this key. The type of the key
  is `FieldValidationRules` (`packages/types/src/form.ts`), and it is **not** an
  array of `{ type, value, message }` entries:

  | Rule                      | Type                                                         | Notes                                                                                                                                                                                                                                                                                                                             |
  | ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `required`                | `string \| boolean`                                          | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field**                                                                                                                                                                                                  |
  | `minLength` / `maxLength` | `{ value: number; message: string }`                         | text length                                                                                                                                                                                                                                                                                                                       |
  | `min` / `max`             | `{ value: number; message: string }`                         | numeric range                                                                                                                                                                                                                                                                                                                     |
  | `pattern`                 | `{ value: RegExp; message: string }`                         | hand-authored schemas must pass a **RegExp**: react-hook-form applies a pattern only when its value is `instanceof RegExp`. It is the object-metadata path (`buildValidationRules` in `@object-ui/fields`) that compiles a declared string into one, so a `pattern` string written straight into a plain `form` schema never runs |
  | `validate`                | `(value) => boolean \| string \| Promise<boolean \| string>` | custom check; TypeScript-authored schemas only                                                                                                                                                                                                                                                                                    |

  There is no `email` rule name — an email check is a `pattern`, which is exactly
  what `buildValidationRules` emits for an object field of type `email`.

  **Why the array spelling fails silently.** The only reader of this key is the
  basic form renderer, which spreads it into the rule object it hands to
  react-hook-form — `const rules: any = { ...validation }`
  (`packages/components/src/renderers/form/form.tsx:1652`). Spreading an **array**
  into an object literal produces numeric keys (`{ '0': …, '1': … }`), and
  react-hook-form recognises none of them: every rule is dropped, nothing throws,
  and the form looks validated while validating nothing.

  ### Column width of a sectioned form [#column-width-of-a-sectioned-form]

  A sectioned form renders as ONE grid. The form view's `columns` (spec
  `FormView.columns`) sets how wide that grid is; a section's `columns` sets how
  densely that section fills it. The view's value wins; without it the grid takes
  the widest section's density, and without either it is single-column.

  `ObjectForm` (simple), `ModalForm`, `TabbedForm`, `SplitForm` and `WizardForm` all
  resolve it that way, so the same metadata lays out identically in every host
  (`WizardForm` has no widest-section fallback — its steps never share a viewport,
  so each keeps its own authored width). The grid is applied to the field container
  inside the form, never wrapped around the `<form>`.

  ### Tabbed field layout (`fieldTabs`) [#tabbed-field-layout-fieldtabs]

  A sectioned form stays **one** form: declare the tabs on it and the renderer
  distributes the fields into panels, instead of rendering a form per section
  (which strands every section but the first outside the submit, and lets an
  inactive tab unmount along with its values).

  ```plaintext
  {
    type: 'form',
    fields: [/* every tab's fields, in one flat list */],
    fieldTabs: [
      { key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
      { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
      { key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'status == "won"' },
    ],
    defaultFieldTab?: 'basics',       // defaults to the first tab
    fieldTabsPosition?: 'top',        // 'top' | 'bottom' | 'left' | 'right'
  }
  ```

  * Panels are force-mounted and only CSS-hidden, so a tab the user leaves keeps
    its values **and** its validation.
  * A failed submit activates the tab holding the first offending field and marks
    every tab with a rejected field — client rules and server `fields[]` alike.
  * Fields no tab claims render above the tab strip rather than disappearing.
  * Needs at least two tabs; ignored when the form uses `children`.

  `ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this, and both
  forward a section's `visibleWhen` onto the tab they synthesise — see the support
  table at the end of the next section.

  #### Conditional tabs (`FormFieldTab.visibleWhen`) [#conditional-tabs-formfieldtabvisiblewhen]

  A tab may carry a `visibleWhen` predicate — the same slot, vocabulary and
  engine as the field-level rule (`string | { dialect?, source }`, a CEL
  predicate over the live record, evaluated by `@objectstack/formula` with the
  host predicate scope bound, so it can read `current_user` / `app` / `data` /
  `features` exactly as a field rule can). Like every conditional rule in this
  system it **fails open**: a predicate that cannot be evaluated leaves the tab
  visible rather than hiding data behind a broken expression.

  When the predicate resolves FALSE the renderer draws **neither the tab's
  trigger nor its panel** — a hidden tab disappears from the tab strip entirely,
  it is not merely an empty panel.

  **Submit semantics are deliberately counter-intuitive — visibility gates
  drawing, and nothing else:**

  * **A hidden tab's values still submit.** Hiding a tab does not remove its
    fields' values from the record: whatever they hold (loaded from the record,
    seeded by defaults, or typed before the tab hid) is carried in the payload.
    A visibility rule is not a data filter — dropping the values would turn a
    cosmetic predicate into a silent data-loss door.
  * **A hidden tab's fields skip client-side validation.** A user is never
    blocked by an error pointing at a control they cannot see. The flip side:
    a *required* field on a hidden tab sails past the client — **the
    server-side contract is the loud floor** for genuinely-required data, and a
    submit missing it fails there, visibly.
  * **Stale errors clear.** A tab hiding mid-session (its predicate flipping on
    a keystroke or a scope change) clears its fields' leftover validation
    errors, the same hygiene a field's own `visibleWhen` applies.

  These are not tab-specific rules: they are the ruled hidden-group semantics
  every section `visibleWhen` follows, inherited through the same mechanism a
  field's own FALSE predicate uses (the fields simply stop being drawn; the form
  keeps their values and skips unmounted controls at validation).

  Two mechanics worth knowing:

  * **Selection is derived over the visible tabs.** If the predicate hides the
    ACTIVE tab, the renderer re-selects deterministically — the user's own pick
    if still visible, else `defaultFieldTab`, else the first visible tab — so
    the form never shows an empty panel. The pick itself is kept: the tab the
    user chose becomes active again the moment its predicate re-admits it.
  * **Whether the tabbed layout engages at all is judged on the DECLARED tabs**
    (the "needs at least two tabs" rule above counts declarations, not
    verdicts). A predicate hiding all but one tab filters what is drawn; it
    never collapses the strip into the flat layout mid-interaction.

  **Where `visibleWhen` on a tab works today** — this key landed renderer-first,
  and only where the renderer actually evaluates it:

  | Authoring surface                                                | Carries the predicate?                                                                                                                                                                                                  |
  | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `type: 'form'` with `fieldTabs[].visibleWhen` (as above)         | **Yes** — evaluated by the form renderer                                                                                                                                                                                |
  | `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab                                                                                                                                                            |
  | `TabbedForm` / `formType: 'tabbed'` sections                     | **Yes** — the section's `visibleWhen` is copied onto the tab `TabbedForm` synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead |
  | `WizardForm` / `formType: 'wizard'` steps                        | **No** — steps are not tabs; nothing evaluates a step-level predicate                                                                                                                                                   |

  The remaining **No** row is deliberate, not an oversight: declaring the key on a
  surface whose renderer ignores it would make the metadata lie (objectui#6111),
  so the key stops at the boundary until each surface enforces it.

  ⛔ &#x2A;*A wizard step has no predicate slot at all, and that is enforced by the
  type.** A step predicate is not the tab predicate under another name — steps are
  mounted one at a time and keyed by index, so a step predicate would be
  step-boundary reactive where a tab's is live-record reactive, and it would need
  navigation, indicator, `isLastStep`, final-gate and re-selection semantics that
  none of the tab machinery supplies. So `WizardStepConfig` is declared
  independently of `FormSectionConfig` (maintainer ruling 2026-08-30,
  objectui#6237) and simply does not carry the key: `visibleWhen` on a wizard step
  literal is a **compile error**, not a silently ignored key. Track objectui#6237
  for the step contract.

  That row also **reports itself** rather than failing silently — untyped JSON
  reaches the renderer too, so the compile-time half cannot be the only one.
  Authoring a section `visibleWhen` on `formType: 'wizard'` logs a console warning
  naming the layout and the sections whose predicate is being dropped:

  > `[ObjectForm] Section \`visibleWhen\` is not yet supported on this layout: the
  > \`wizard\` layout's steps drop the predicate, so section(s) pay render
  > unconditionally. …\`

  It changes no behaviour — the predicate is still dropped on that one arm — it
  only stops the drop from being invisible. Nothing warns on the five arms that
  honour the key.

  ### Wizard steps and `allowSkip` [#wizard-steps-and-allowskip]

  `allowSkip` lets the user jump to any step from the indicator instead of walking
  through them in order. It is navigation freedom, not an exemption from the
  object's rules: the **final submit checks every step's required fields**, and if
  something is outstanding it returns the user to the first step that has one, marks
  that step's indicator (`data-error="true"`), and names the fields in a toast —
  nothing is sent. Conditional rules are respected (`visibleWhen` / `requiredWhen`
  are evaluated on the same canonical engine as the renderer and the server).

  Those conditional rules are the &#x2A;*fields'** rules. A wizard *step* carries no
  `visibleWhen` of its own today — see the support table under
  [Conditional tabs](#conditional-tabs-formfieldtabvisiblewhen).

  This matters because react-hook-form only validates the fields currently mounted,
  and a wizard mounts one step at a time — so a required field on a step nobody
  opened used to be absent from the payload with nothing on screen saying so.

  ### Split field layout (`fieldPanes`) [#split-field-layout-fieldpanes]

  Side-by-side panels follow the same rule: the `<form>` wraps the whole panel
  group and each pane holds only fields, so one react-hook-form instance spans the
  divider.

  ```plaintext
  {
    type: 'form',
    fields: [/* every pane's fields, in one flat list */],
    fieldPanes: [
      { key: 'primary', fields: ['subject'], defaultSize: 50 },
      { key: 'secondary', fields: ['status', 'priority'], defaultSize: 50, minSize: 20 },
    ],
    fieldPanesOrientation?: 'horizontal',   // 'horizontal' | 'vertical'
    fieldPanesResizable?: true,             // false pins the divider
  }
  ```

  * A submit from anywhere carries every pane's values, and a field rule in one
    pane can read a field in another — neither works with a form per panel.
  * `defaultSize` / `minSize` are percentages of the group.
  * Each pane is its own `@container`, so a multi-column group collapses as the
    divider is dragged narrower.
  * Fields no pane claims render above the panel group rather than disappearing.
  * Needs at least two panes; ignored when the form uses `children` or `fieldTabs`.

  `SplitForm` builds on this. Each section declares its panel via `pane: 'primary' | 'secondary'` (spec `FormSection.pane`) — explicit placement that survives
  reordering. When omitted, the legacy rule applies: section 1 becomes the primary
  pane, the rest stack in the secondary one behind inline section headers. (The
  spec rejects `pane` on non-split form types at parse.)

  ## Usage [#usage]

  ### Registration is a side effect of the import [#registration-is-a-side-effect-of-the-import]

  ```ts
  import '@object-ui/plugin-form';
  ```

  That single import is the whole of registration — there is no components map to
  iterate over. Importing the entry runs the six `ComponentRegistry.register(...)`
  calls in `packages/plugin-form/src/index.tsx`, which claim exactly these schema
  types:

  | Namespaced key                          | Bare-name fallback          | Renderer behind it                                              |
  | --------------------------------------- | --------------------------- | --------------------------------------------------------------- |
  | `plugin-form:object-form`               | `object-form`               | `ObjectForm` — metadata-driven form over one record             |
  | `view:form`                             | none — `skipFallback: true` | the same renderer, under the view protocol                      |
  | `plugin-form:embeddable-form`           | `embeddable-form`           | `EmbeddableForm` — standalone public form                       |
  | `plugin-form:form-analytics`            | `form-analytics`            | `FormAnalytics` — submission dashboard                          |
  | `plugin-form:object-master-detail-form` | `object-master-detail-form` | `MasterDetailForm` — parent plus child line items in one submit |
  | `record:line_items`                     | none — `skipFallback: true` | `LineItemsPanel` — child grid bound to the record on the page   |

  `ComponentRegistry.register` publishes `namespace:type`, and — unless the call
  passes `skipFallback: true` — the bare `type` as a back-compat fallback
  (`packages/core/src/registry/Registry.ts:194`, fallback at `:226`). The two
  `skipFallback` calls here are deliberate: bare `form` stays the basic
  `@object-ui/components` form, and bare `line_items` is left to whoever else
  claims it.

  ### Registering a component under your own key [#registering-a-component-under-your-own-key]

  To serve one of this package's components under a key of your own, register the
  exported component — that is what a manual registration is here:

  ```ts
  import { ComponentRegistry } from '@object-ui/core';
  import { ObjectForm } from '@object-ui/plugin-form';

  ComponentRegistry.register('my-form', ObjectForm, { namespace: 'my-app' });
  ```

  The renderers this package registers for *itself* are internal wrappers rather
  than these exported components. `SchemaRenderer` hands a registered component
  its `schema`, but never a `dataSource` — that travels on
  `SchemaRendererContext` — so each wrapper resolves it from the context first.
  `ObjectForm` takes `dataSource` as a prop (optional, because inline
  `customFields` need no adapter), so a custom-key registration either supplies
  one or wraps the component the same way.

  The components are on the package's export surface — `ObjectForm`,
  `TabbedForm`, `WizardForm`, `SplitForm`, `DrawerForm`, `ModalForm`,
  `EmbeddableForm`, `MasterDetailForm`, `LineItemsPanel`, `FormAnalytics`,
  `FormSectionContainer` — alongside the layout helpers (`applyAutoLayout`,
  `inferColumns`, `filterCreateModeFields`, …) and the per-container schema types
  (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …). There is no
  aggregate map among them.

  ## Examples [#examples]

  ### Form with Validation [#form-with-validation]

  One `validation` object per field, keyed by rule name. The annotation is part of
  the example: `FormField` and `FormSchema` both carry an index signature, so an
  un-annotated `const schema = { … }` type-checks whatever is written in it, while
  `const signUpForm: FormSchema` makes a wrong `validation` shape a compile error.

  ```typescript
  import type { FormSchema } from '@object-ui/types';

  const signUpForm: FormSchema = {
    type: 'form',
    fields: [
      {
        name: 'username',
        type: 'input',
        label: 'Username',
        required: true,                    // the presence rule lives here, not in `validation`
        validation: {
          minLength: { value: 3, message: 'Min 3 characters' },
          maxLength: { value: 20, message: 'Max 20 characters' },
        },
      },
      {
        name: 'email',
        type: 'input',
        inputType: 'email',
        label: 'Email',
        required: true,
        validation: {
          // No `email` rule name exists — an email check is a `pattern`, and its
          // value has to be a RegExp for react-hook-form to apply it at all.
          pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Enter a valid email address' },
        },
      },
      {
        name: 'password',
        type: 'input',
        inputType: 'password',
        label: 'Password',
        required: true,
        validation: {
          minLength: { value: 8, message: 'Min 8 characters' },
          required: 'Choose a password',   // message only — `required: true` above is the rule
        },
      },
    ],
    submitLabel: 'Sign Up',
  };
  ```

  Spelled as an array instead — `validation: [{ type: 'minLength', value: 3, … }]`
  — every rule is dropped in silence: the field above accepts `ab` and the form
  submits it with no message shown. Under the annotation that spelling is a
  compile error (TS2559, "no properties in common with type
  `FieldValidationRules`") rather than a runtime surprise; a JSON metadata
  document, which cannot be annotated, has only this page to go by.

  The same shape as JSON metadata — the length and range rules carry over
  unchanged:

  ```json
  {
    "type": "form",
    "fields": [
      {
        "name": "username",
        "type": "input",
        "label": "Username",
        "required": true,
        "validation": {
          "minLength": { "value": 3, "message": "Min 3 characters" },
          "maxLength": { "value": 20, "message": "Max 20 characters" }
        }
      }
    ],
    "submitLabel": "Sign Up"
  }
  ```

  `pattern` and `validate` are the two rules a JSON document cannot express on
  this path: JSON has no RegExp literal and no function, and a `pattern` string
  reaches react-hook-form as a string, which it ignores. An object-bound form
  (`object-form`) does not have that limitation — it compiles the object field's
  own declared `pattern` into a RegExp before the form ever sees it.

  ### Multi-Step Form [#multi-step-form]

  Multi-step is a **mode of the object-bound form**, not a component of its own — there is
  no `multi-step-form` type. The registered type is `object-form`, and `formType: 'wizard'`
  turns its `sections` into steps; `WizardFormSchema` (exported by
  `@object-ui/plugin-form`) declares the shape. Because the wizard resolves its fields from
  the object's own metadata, a section lists field **names** rather than field definitions.

  ```json
  {
    "type": "object-form",
    "objectName": "contact",
    "mode": "create",
    "formType": "wizard",
    "showStepIndicator": true,
    "sections": [
      {
        "name": "personal",
        "label": "Personal Info",
        "fields": ["first_name", "last_name"]
      },
      {
        "name": "contact_details",
        "label": "Contact Info",
        "fields": ["email", "phone"]
      }
    ]
  }
  ```

  ## Field Types [#field-types]

  The plugin supports these field types:

  * **input** - Text, email, password, number, tel, url, etc.
  * **textarea** - Multi-line text input
  * **select** - Dropdown select
  * **checkbox** - Single checkbox
  * **radio-group** - Radio button group
  * **date-picker** - Date selection
  * **file-upload** - File upload

  ## TypeScript Support [#typescript-support]

  `FormSchema` and `FormField` are protocol types, so they live in
  `@object-ui/types` alongside the rest of the JSON contract. This package imports
  them and does not re-export them — the form types on its own entry are the
  per-container ones (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`,
  `SplitFormSchema`, `DrawerFormSchema`, `MasterDetailFormSchema`).

  ```typescript
  import type { FormSchema, FormField } from '@object-ui/types';

  const emailField: FormField = {
    name: 'email',
    type: 'input',
    inputType: 'email',
    label: 'Email',
    required: true
  };

  const loginForm: FormSchema = {
    type: 'form',
    fields: [emailField],
    submitLabel: 'Sign In'
  };
  ```

  ## License [#license]

  MIT
</PluginLoader>
