Form plugin for Object UI - Advanced form components with validation, multi-step forms, and field-level control.
- Form Builder - Create complex forms from schemas
- Validation - Built-in validation with error messages
- Multi-Step Forms - Wizard-style multi-step forms
- Field Types - Support for all standard field types
- Form State - Automatic form state management
- Customizable - Tailwind CSS styling support
pnpm add @object-ui/plugin-form// In your app entry point (e.g., App.tsx or main.tsx)
import '@object-ui/plugin-form';
// Now you can use form types in your schemas
const schema = {
type: 'form',
fields: [
{ name: 'email', type: 'input', label: 'Email', required: true },
{ name: 'password', type: 'input', inputType: 'password', label: 'Password', required: true }
]
};import { formComponents } from '@object-ui/plugin-form';
import { ComponentRegistry } from '@object-ui/core';
// Register form components
Object.entries(formComponents).forEach(([type, component]) => {
ComponentRegistry.register(type, component);
});Complete form with fields and validation:
{
type: 'form',
fields: FormField[],
submitLabel?: string,
cancelLabel?: string,
onSubmit?: (data) => void,
onCancel?: () => void,
className?: string
}Individual form field configuration:
interface FormField {
name: string;
type: string; // 'input', 'select', 'checkbox', etc.
label: string;
placeholder?: string;
required?: boolean;
validation?: ValidationRule[];
defaultValue?: any;
disabled?: boolean;
className?: string;
}A sectioned form renders as ONE grid, and two keys decide its shape:
| Key | Meaning |
|---|---|
the form view's columns |
how wide the grid is (1–4) — spec FormView.columns |
a section's columns |
how densely THAT section fills the grid (via per-field colSpan) |
The view's columns wins; without it the grid takes the widest section's
density, and without either it is single-column. Every sectioned host resolves it
the same way — ObjectForm (simple), ModalForm, TabbedForm, SplitForm,
WizardForm — so one piece of metadata lays out identically wherever it is
rendered. (WizardForm has no widest-section fallback: its steps never share a
viewport, so each step keeps its own authored width.)
The grid is applied to the field container inside the form, never wrapped
around the <form> (which would put the whole form in cell 1 and leave the other
columns empty — #2128).
A sectioned form is one form. Instead of rendering a form per section — which strands every section but the first outside the submit, and (in tabs) lets the inactive panel unmount with its values — declare tabs on the single form and let the renderer distribute the fields:
{
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'] },
],
defaultFieldTab?: 'basics', // defaults to the first tab
fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
}- All panels are force-mounted and only CSS-hidden, so a tab the user leaves keeps its values and its validation (react-hook-form skips unmounted fields, which is how a required field on an unopened tab used to reach the server).
- A failed submit activates the tab holding the first offending field and
marks every tab with a rejected field (
data-erroron the trigger) — for both client-side rules and serverfields[]rejections. - Fields no tab claims render above the tab strip rather than disappearing.
- Needs at least two tabs, and is ignored when the form uses
children.
ModalForm (contentLayout: 'tabbed') and TabbedForm are built on this.
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:
- Next validates the step you are leaving, as always.
- The final submit checks every step's required fields — not just the last
one's — and if something is outstanding it sends 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: a field whose
visibleWhenis false, or whoserequiredWhenis false, is not demanded. The check runs on the same canonical engine as the renderer and the server's rule-validator, so all three agree.
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 (#2959's validation half, in a wizard).
The same rule for side-by-side panels: the <form> wraps the whole panel group
and each pane holds only fields, so one react-hook-form instance spans the
divider.
{
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
(
visibleWhen/requiredWhen/ …) in one pane can read a field in another — neither is possible with a form per panel. defaultSize/minSizeare percentages of the group.- Each pane is its own
@container, so a multi-column group inside it 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
childrenorfieldTabs.
SplitForm is built on this. Each section declares its panel via pane: 'primary' | 'secondary' (spec FormSection.pane) — explicit per-section
placement, so reordering sections never silently moves them across the divider.
When omitted, the legacy positional rule applies: the first section 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.)
const schema = {
type: 'form',
fields: [
{
name: 'name',
type: 'input',
label: 'Full Name',
placeholder: 'Enter your name',
required: true
},
{
name: 'email',
type: 'input',
inputType: 'email',
label: 'Email Address',
required: true,
validation: [
{ type: 'email', message: 'Invalid email format' }
]
},
{
name: 'country',
type: 'select',
label: 'Country',
options: [
{ label: 'United States', value: 'us' },
{ label: 'Canada', value: 'ca' },
{ label: 'United Kingdom', value: 'uk' }
]
},
{
name: 'subscribe',
type: 'checkbox',
label: 'Subscribe to newsletter'
}
],
submitLabel: 'Register',
onSubmit: (data) => {
console.log('Form submitted:', data);
}
};const schema = {
type: 'multi-step-form',
steps: [
{
title: 'Personal Info',
fields: [
{ name: 'firstName', type: 'input', label: 'First Name', required: true },
{ name: 'lastName', type: 'input', label: 'Last Name', required: true }
]
},
{
title: 'Contact Info',
fields: [
{ name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
{ name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' }
]
},
{
title: 'Review',
fields: []
}
],
onSubmit: (data) => {
console.log('Multi-step form completed:', data);
}
};const schema = {
type: 'form',
fields: [
{
name: 'username',
type: 'input',
label: 'Username',
required: true,
validation: [
{ type: 'minLength', value: 3, message: 'Username must be at least 3 characters' },
{ type: 'maxLength', value: 20, message: 'Username must be less than 20 characters' },
{ type: 'pattern', value: '^[a-zA-Z0-9_]+$', message: 'Only letters, numbers, and underscores' }
]
},
{
name: 'password',
type: 'input',
inputType: 'password',
label: 'Password',
required: true,
validation: [
{ type: 'minLength', value: 8, message: 'Password must be at least 8 characters' },
{ type: 'pattern', value: '(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])', message: 'Must contain uppercase, lowercase, and number' }
]
}
]
};Connect forms to backend APIs:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-auth-token'
});
const schema = {
type: 'form',
dataSource,
resource: 'users',
fields: [/* fields */],
onSubmit: async (data) => {
await dataSource.create('users', data);
}
};import type { FormSchema, FormField } from '@object-ui/plugin-form';
const emailField: FormField = {
name: 'email',
type: 'input',
inputType: 'email',
label: 'Email',
required: true
};
const loginForm: FormSchema = {
type: 'form',
fields: [emailField],
submitLabel: 'Sign In'
};The plugin includes these field components:
- Text input
- Email input
- Password input
- Number input
- Textarea
- Select dropdown
- Checkbox
- Radio group
- Date picker
- File upload
- React: 18.x or 19.x
- Node.js: ≥ 18
- TypeScript: ≥ 5.0 (strict mode)
@objectstack/spec: ^3.3.0@objectstack/client: ^3.3.0- Tailwind CSS: ≥ 3.4 (for packages with UI)
MIT — see LICENSE.