Forms

Forms Overview

Form approach

The 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

LayerResponsibility
One public field shapeForm-capable controls spread fieldProps instead of redefining label, description, value, validation, disabled, and chrome props locally.
One field behaviour layerControls call useField for value updates, generated IDs, HTML names, validation state, and parent form registration.
Chrome stays visualDomField 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 aggregationDomForm owns values, field state, errors, validation, nested path scopes, and programmatic form methods, not presentation.
Definitions stay separate from dataTerse 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.

Active team

Billing contact

Row 1

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

StateMeaning
interactionState-machine axis for user interaction: untouched, focused, or blurred.
modificationState-machine axis for value changes: clean or changed.
validationState-machine axis for validation: unknown, validating, valid, or invalid.
focusedTrue while the control inside the field has focus.
touchedDerived from interaction; true when the field is focused or blurred.
dirtyTrue after input changes the value from its initial state.
validatingTrue while async validators are running.
invalid / validDerived from validation. Boolean patches are accepted as shorthand, such as { invalid: true } becoming validation: invalid.
errorsMessages or error records from props, validators, or the parent form.
visibleFalse hides the field but keeps its model value unless the form removes it.
readOnlyShows the value but prevents editing.
disabledDisables 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.

PhaseDirection
1Use the validator registry helpers: defineValidator, getValidator, listValidators, and compileValidators.
2Keep built-in validators in registry definitions while preserving existing function validators.
3Add DomValidatorInput so Studio can edit validator records with form controls.
4Use string rules only as sugar that compiles into the same serializable records.
5Add 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

BoundaryMeaning
Authored errors propStatic errors configured on a field node. These are editable in Studio because they are part of the authored component props.
Runtime form state errorsErrors 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 stateStudio 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.

LayerRole
DomFieldShared label, description, error display, and required marker.
DomGridFieldLayoutCompact field layout for grid or table-style form sections where headers carry visible labels.
DomTextInputGeneric text entry with the shared field contract.
DomEmailInputText input wrapper with type=email, email autocomplete, and email validation defaults.
DomUrlInputText 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.

fieldslabelsdefaultsvalidation rulesbackend data typestransformerspermissionsconditional visibility rulesrelation options

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. 1.Should server validators be represented as validators, transformers, or separate field actions?
  2. 2.Should field visibility remove data from the form result, or only hide the input?
  3. 3.How much component metadata should live in the type registry, and how much should stay beside props through _edit?
  4. 4.How should conditional field expressions work across Studio, AI-generated schemas, and server-provided forms?
  5. 5.Should native form submission serialize hidden JSON/list fields as strings, or remain a secondary fallback?

Reference

Where to find details