Forms
Forms Overview
Form approachThe shared approach behind DOM Studio fields, form providers, validation state, and generated schemas.
Principle
Keep the contract in code
DOM Studio form components should feel simple when used directly, but should also be able to participate in a larger form system. The stable contract is: fields present one named value, useField wires that value into local and parent state, and DomForm aggregates values, errors, validation, and paths.
Use the Field and Form reference pages for prop, slot, event, and method details. When implementation changes, those component pages should be updated first; this overview should only change when the architecture changes.
Status
Current architecture and direction
Implemented
Shared field props, useField, DomField-owned field layouts, DomForm provider state, nested fieldset forms, dynamic children, terse form normalization, JSON Schema/Zod adapters, and the named forms registry.
Direction
An active-field framework where types resolve to renderers, fields describe validation and value transforms, and schemas can travel from UI to storage and back.
Future work
Async server validation, richer validator editing in Studio, conditional fields, validator registries, and backend data-type mapping.
Layers
How the pieces divide responsibility
| Layer | Responsibility |
|---|---|
| One public field shape | Form-capable controls spread fieldProps instead of redefining label, description, value, validation, disabled, and chrome props locally. |
| One field behaviour layer | Controls call useField for value updates, generated IDs, HTML names, validation state, and parent form registration. |
| Chrome stays visual | DomField renders labels, descriptions, required markers, errors, and optional layout components. It does not own form data, and DomForm does not own field layout. |
| Forms own aggregation | DomForm owns values, field state, errors, validation, nested path scopes, and programmatic form methods, not presentation. |
| Definitions stay separate from data | Terse definitions, normalized children, JSON Schema, and future storage metadata describe the form. v-model stores only submitted values. |
Definitions
Author forms as typed data shapes
A form definition can be authored like a data schema. The root type can be a data type such as object or a component type such as DomForm. Keyed properties or children become child fields, and each key becomes the field name unless the field explicitly sets another name.
The normalizer turns that authoring shape into the canonical render shape: every node has a semantic type, a renderer component, and component props. Top-level non-structural keys are passed as props; explicit props win when both are present.
Terse authoring shape
{
type: 'DomForm',
properties: {
name: {
type: 'string',
label: 'Full name',
required: true,
},
email: {
type: 'email',
label: 'Email',
},
role: {
type: 'string',
component: 'DomSelectInput',
label: 'Role',
options: ['viewer', 'editor', 'admin'],
},
},
}Equivalent render shape
{
component: 'DomForm',
children: [
{
component: 'DomTextInput',
props: {
name: 'name',
label: 'Full name',
required: true,
},
},
{
component: 'DomEmailInput',
props: {
name: 'email',
label: 'Email',
},
},
{
component: 'DomSelectInput',
props: {
name: 'role',
label: 'Role',
options: ['viewer', 'editor', 'admin'],
},
},
],
}Editable definition
Edit the terse JSON and compare the normalized render definition with the JSON Schema projection.
Live form
The renderer receives normalized children, while the model remains plain form data.
Normalized form definition
{
"type": "object",
"component": "DomForm",
"props": {},
"children": [
{
"type": "string",
"component": "DomTextInput",
"props": {
"label": "Team name",
"required": true,
"placeholder": "Platform team",
"name": "teamName"
}
},
{
"type": "email",
"component": "DomEmailInput",
"props": {
"label": "Owner email",
"required": true,
"name": "ownerEmail"
}
},
{
"type": "string",
"component": "DomSelectInput",
"props": {
"label": "Default role",
"options": [
{
"label": "Admin",
"value": "admin"
},
{
"label": "Member",
"value": "member"
},
{
"label": "Viewer",
"value": "viewer"
}
],
"name": "defaultRole"
}
},
{
"type": "boolean",
"component": "DomToggle",
"props": {
"label": "Active team",
"name": "active"
}
},
{
"type": "object",
"component": "DomForm",
"props": {
"label": "Billing contact",
"name": "billing"
},
"children": [
{
"type": "string",
"component": "DomTextInput",
"props": {
"label": "Contact name",
"name": "name"
}
},
{
"type": "email",
"component": "DomEmailInput",
"props": {
"label": "Contact email",
"name": "email"
}
}
]
},
{
"type": "array",
"component": "DomJsonListInput",
"props": {
"label": "Invites",
"name": "invites"
},
"items": {
"type": "object",
"component": "DomForm",
"props": {},
"children": [
{
"type": "email",
"component": "DomEmailInput",
"props": {
"label": "Invitee email",
"required": true,
"name": "email"
}
},
{
"type": "string",
"component": "DomSelectInput",
"props": {
"label": "Role",
"options": [
"admin",
"member",
"viewer"
],
"name": "role"
}
}
]
}
}
]
}JSON Schema projection
{
"type": "object",
"properties": {
"teamName": {
"type": "string",
"title": "Team name",
"examples": [
"Platform team"
]
},
"ownerEmail": {
"type": "string",
"title": "Owner email",
"format": "email"
},
"defaultRole": {
"type": "string",
"title": "Default role",
"enum": [
"admin",
"member",
"viewer"
]
},
"active": {
"type": "boolean",
"title": "Active team"
},
"billing": {
"type": "object",
"title": "Billing contact",
"properties": {
"name": {
"type": "string",
"title": "Contact name"
},
"email": {
"type": "string",
"title": "Contact email",
"format": "email"
}
}
},
"invites": {
"type": "array",
"title": "Invites",
"items": {
"type": "object",
"properties": {
"email": {
"type": "string",
"title": "Invitee email",
"format": "email"
},
"role": {
"type": "string",
"title": "Role",
"enum": [
"admin",
"member",
"viewer"
]
}
},
"required": [
"email"
]
}
}
},
"required": [
"teamName",
"ownerEmail"
]
}Field controls
Build inputs from the shared field contract
A packaged input should be mostly presentation and interaction. It spreads fieldProps, calls useField, and passes the returned attrs into DomField and the real control. That gives standalone v-model and parent DomForm participation without two separate component APIs.
<script setup>
import { DomField, fieldProps, useField } from '@getdom/studio/vue';
const props = defineProps({
...fieldProps,
type: { type: String, default: 'text' },
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
const field = useField(props, emit, { idPrefix: 'my-input' });
</script>
<template>
<DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
<input
v-bind="field.inputAttrs.value"
:type="type"
class="dom-input"
@input="field.onInput($event.target.value)"
@focus="field.onFocus"
@blur="field.onBlur"
/>
</DomField>
</template>Layout
Swap field layout by subtree
For one-off layouts, wrap controls in DomField and set the inner controls to :chrome="false". For a repeated layout pattern, pass a fieldLayout component to DomField. Use :chrome="false" when the field is only providing layout to descendant controls. That changes presentation without creating a new data provider or changing field paths. The layout component receives the same label, default, errors, and description slots that DomField renders by default.
<script setup>
import { DomField, DomGridFieldLayout } from '@getdom/studio/vue';
import LetterFieldLayout from './LetterFieldLayout.vue';
</script>
<template>
<DomForm>
<DomField :field-layout="LetterFieldLayout" :chrome="false">
<DomTextInput name="name" label="Name" />
<DomEmailInput name="email" label="Email" />
</DomField>
<DomForm name="invitees.0" class="grid grid-cols-2 gap-2">
<DomField :field-layout="DomGridFieldLayout" :chrome="false">
<DomTextInput name="first" label="First" />
<DomEmailInput name="email" label="Email" />
</DomField>
</DomForm>
</DomForm>
</template>Field contract
One named chunk of form data
A field should represent one named chunk of form data. Form inputs should not define the common prop block manually; they should spread fieldProps and extend it only with control-specific props.
- Standalone v-model without a parent form.
- Registration with the nearest parent DomForm when a name is present.
- Derived dot paths, native HTML names, and generated IDs.
- Validation state and error display.
- Default chrome through DomField, or :chrome="false" when another DomField owns the visual layout.
{
id: String,
name: String,
label: String,
description: String,
placeholder: String,
modelValue: unknown,
required: Boolean,
disabled: Boolean,
readOnly: Boolean,
invalid: Boolean,
errors: Array | Object | String,
visible: Boolean,
validators: Array,
validateOnBlur: Boolean,
chrome: 'field' | false,
}Field state
The state every form-capable control should understand
| State | Meaning |
|---|---|
| interaction | State-machine axis for user interaction: untouched, focused, or blurred. |
| modification | State-machine axis for value changes: clean or changed. |
| validation | State-machine axis for validation: unknown, validating, valid, or invalid. |
| focused | True while the control inside the field has focus. |
| touched | Derived from interaction; true when the field is focused or blurred. |
| dirty | True after input changes the value from its initial state. |
| validating | True while async validators are running. |
| invalid / valid | Derived from validation. Boolean patches are accepted as shorthand, such as { invalid: true } becoming validation: invalid. |
| errors | Messages or error records from props, validators, or the parent form. |
| visible | False hides the field but keeps its model value unless the form removes it. |
| readOnly | Shows the value but prevents editing. |
| disabled | Disables interaction and may be omitted from native submission. |
Form provider
Let forms derive paths and aggregate state
Fields use local names. Parent forms derive the dot path, native bracket name, and default ID from the form hierarchy. For example, a field named postcode inside the billing subform writes to billing.postcode, and receives the native HTML name billing[postcode]. The default input ID is billing_postcode.
<DomForm v-model="account" name="account">
<DomTextInput name="name" label="Name" />
<DomEmailInput name="email" label="Email" required />
<DomForm name="billing">
<DomTextInput name="postcode" label="Postcode" />
</DomForm>
</DomForm>Form context
DomForm owns aggregation, not business state
A root form owns ordinary Vue state unless an app binds the model to Pinia or another store. Fields should only depend on the form provider contract, not on Pinia directly.
When an app already uses Pinia, keep business data in the store and bind it to DomForm with v-model. The form instance still provides registration, validation, path helpers, and programmatic updates through refs or the named forms registry.
- The data object and model updates.
- The field registry and field state.
- Form-level validity and submission validation.
- Nested form contexts and path scopes.
- Programmatic updates through refs and the named forms registry.
{
values,
errors,
fieldStates,
fields,
state,
getValue(name),
setValue(name, value),
getFieldPath(name),
getHtmlName(name),
getHtmlId(name),
getFieldState(name),
setFieldState(name, patch),
getState(),
registerField(field),
unregisterField(name),
validate(),
reset(nextValues),
}Nested data
Subforms scope paths without nested native forms
Forms can contain subforms for billing addresses, contact lists, repeatable rows, or conditional sections. A root DomForm renders a native <form>. An DomForm inside another form renders as a <fieldset> while keeping the same scoped API.
Each subform contributes its local name to the derived path. A field named email inside an invitees.0 subform writes to invitees.0.email, receives the native name invitees[0][email], and defaults to the ID invitees_0_email.
Validation
Validators should become portable records
Validators can currently be functions or objects attached through field props. The long-term direction is a serializable validator record backed by a registry, so Studio editing, runtime validation, and server-side validation all speak the same shape.
[
{ name: 'required' },
{ name: 'email' },
{
name: 'minLength',
props: {
min: 3,
},
message: 'Use at least 3 characters.',
},
{
name: 'maxLength',
props: {
max: 32,
},
},
{
name: 'serverUnique',
props: {
resource: 'users',
field: 'username',
message: 'That username is already taken.',
},
},
]String rules and helper decorators can still be authoring sugar, but the stored and server-visible result should be the same records.
| Phase | Direction |
|---|---|
| 1 | Use the validator registry helpers: defineValidator, getValidator, listValidators, and compileValidators. |
| 2 | Keep built-in validators in registry definitions while preserving existing function validators. |
| 3 | Add DomValidatorInput so Studio can edit validator records with form controls. |
| 4 | Use string rules only as sugar that compiles into the same serializable records. |
| 5 | Add async/server validation with stale-result protection, debouncing, and a standard request payload. |
Server validation
Keep async validation tied to field paths
Async server validators should use stale-result protection and return normalized errors keyed by field path. The request should include the validator name, validator props, the field identity, the current value, and the surrounding form values needed for context.
This keeps server validation as part of the same form contract rather than a separate system that can drift from Studio and client validation.
{
validator: 'serverUnique',
props: { resource: 'users', field: 'username' },
field: {
name: 'username',
path: 'account.username',
value: 'steve',
},
values: {
account: {
username: 'steve',
},
},
}Runtime state
Keep authored props separate from runtime errors
| Boundary | Meaning |
|---|---|
| Authored errors prop | Static errors configured on a field node. These are editable in Studio because they are part of the authored component props. |
| Runtime form state errors | Errors produced by validators, server responses, or calls such as form.setFieldState(name, { errors }). These display on the field but should not be written back into authored props. |
| Future inspector state | Studio should expose read-only runtime value, dirty/touched/focused state, invalid state, and normalized errors for the selected field. |
Packaged inputs
Convenience wrappers should not duplicate fields
Some inputs should exist as convenience components with default presentation, input type, validators, and transformers. They should usually wrap a more primitive field component instead of duplicating the field contract.
| Layer | Role |
|---|---|
| DomField | Shared label, description, error display, and required marker. |
| DomGridFieldLayout | Compact field layout for grid or table-style form sections where headers carry visible labels. |
| DomTextInput | Generic text entry with the shared field contract. |
| DomEmailInput | Text input wrapper with type=email, email autocomplete, and email validation defaults. |
| DomUrlInput | Text input wrapper with type=url, URL validation, and optional normalization. |
Active fields
Backend data types and value lifecycles
Longer term, a field can map to a backend data type such as string, text, integer, decimal, boolean, date, datetime, JSON, enum, relation, array, object, or file.
The field definition should make the lifecycle explicit: accept user input, validate it, transform it to an application value, transform it to storage, and hydrate stored values back into display values.
{
name: 'price',
type: 'decimal',
component: 'DomMoneyInput',
label: 'Price',
storage: {
type: 'decimal',
precision: 10,
scale: 2,
},
transformers: ['trim', 'emptyStringToNull', 'currencyToDecimal'],
validators: [
{ name: 'required' },
{ name: 'min', props: { min: 0 } },
],
}Server assembly
Server schemas can feed the same client contract
Eventually, forms may be assembled on the server. The client should still render the same schema shape and post structured data back; server form classes can read submitted input, validate, transform to domain data, transform to storage, persist, and hydrate stored data back into display data.
Conditional fields
Prefer form-level expressions over field coupling
Fields often need to react to other fields: a toggle reveals a subform, a country changes postcode validation, a payment method changes required fields, or a checkbox enables a preferences section.
Avoid coupling fields directly to each other. Prefer serializable field expressions or form-level watchers that Studio, AI-generated schemas, and server-provided forms can all understand.
{
name: 'companyDetails',
component: 'DomForm',
visibleWhen: {
field: 'accountType',
equals: 'company',
},
}Coverage
Current component coverage
- Text, textarea, number, password, email, and URL inputs.
- Native select, select input, listbox, radio group, combobox, autocomplete, and tag combobox.
- Checkbox, toggle, toggle button, and toggle button group.
- Calendar, color, code, JSON, JSON list, position input, and range input.
- Wrapper inputs such as DomEmailInput and DomUrlInput delegate to DomTextInput instead of registering an extra field.
- Composite inputs such as DomJsonInput and DomJsonListInput own the field and disable internal child registration where needed.
Open questions
Questions to resolve as the system grows
- 1.Should server validators be represented as validators, transformers, or separate field actions?
- 2.Should field visibility remove data from the form result, or only hide the input?
- 3.How much component metadata should live in the type registry, and how much should stay beside props through _edit?
- 4.How should conditional field expressions work across Studio, AI-generated schemas, and server-provided forms?
- 5.Should native form submission serialize hidden JSON/list fields as strings, or remain a secondary fallback?
Reference