Component

Form

<DomForm>

A Pinia-friendly form provider that gathers child field values, errors, and field state.

Purpose

A provider for fields

DomForm provides a field store to its children. Inputs with a name register themselves, write into the form value, and report validation errors back to the form.

Root forms own the data. Subforms scope child paths, so invitees.0.email can be read, changed, and validated from the top-level form API.

The global forms registry is plain Vue state and integrates neatly with Pinia. A Pinia store can own the model value, while DomForm continues to provide field registration, validation, and path helpers.

Demo

Child values and errors

The form value is built from named children. Email validates on blur; website validates as the model changes.

0 errors

{
  "name": "",
  "email": "",
  "website": ""
}
{}
AccountForm.vuevue
<script setup>
import { reactive, ref } from 'vue';
import { DomButton, DomEmailInput, DomForm, DomTextInput, DomUrlInput } from '../../../lib/vue';

const account = ref({
	name: '',
	email: '',
	website: '',
});

const lastSubmit = reactive({
	type: '',
	message: '',
});

function onSubmit({ values }) {
	lastSubmit.type = 'success';
	lastSubmit.message = `Ready to save ${values.name || 'this account'}.`;
}

function onInvalid({ errors }) {
	lastSubmit.type = 'danger';
	lastSubmit.message = `Fix ${Object.keys(errors).length} field before continuing.`;
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm
			v-slot="{ values, errors, state }"
			v-model="account"
			class="space-y-4"
			@submit="onSubmit"
			@invalid="onInvalid"
		>
			<DomTextInput
				name="name"
				label="Name"
				placeholder="Ada Lovelace"
				required
			/>
			<DomEmailInput
				name="email"
				label="Email"
				required
			/>
			<DomUrlInput
				name="website"
				label="Website"
				:validate-on-blur="false"
			/>

			<div class="flex items-center justify-between gap-3">
				<DomButton type="submit">Create account</DomButton>
				<p class="text-xs text-muted-fg">
					{{ state.valid ? 'Valid' : `${state.errorCount} error${state.errorCount === 1 ? '' : 's'}` }}
				</p>
			</div>

			<p
				v-if="lastSubmit.message"
				class="rounded-lg border px-3 py-2 text-xs"
				:class="lastSubmit.type === 'success'
					? 'border-success/40 bg-success/10 text-success'
					: 'border-destructive/40 bg-destructive/10 text-destructive'"
			>
				{{ lastSubmit.message }}
			</p>

			<div class="grid gap-3 rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg sm:grid-cols-2">
				<pre class="overflow-auto font-mono">{{ values }}</pre>
				<pre class="overflow-auto font-mono">{{ errors }}</pre>
			</div>
		</DomForm>
	</div>
</template>

Server definitions

Render fields from children

The children prop accepts normalized component records or terse typed definitions. Slot content renders after those programmatic children, so submit buttons and local actions can still be authored normally.

{
  "id": "usr_1024",
  "name": "Ada Lovelace",
  "email": "ada@example.com"
}
ServerDefinedForm.vuevue
<script setup>
import { ref } from 'vue';
import { DomButton, DomForm, forms } from '../../../lib/vue';

const user = ref({
	id: 'usr_1024',
	name: 'Ada Lovelace',
	email: 'ada@example.com',
});

const children = [
	{
		component: 'DomTextInput',
		props: {
			name: 'id',
			label: 'User ID',
			readOnly: true,
			validators: [
				{
					name: 'pattern',
					props: { pattern: '^usr_[0-9]+$' },
					message: 'Use an ID like usr_1024.',
				},
			],
		},
	},
	{
		component: 'DomTextInput',
		props: {
			name: 'name',
			label: 'Name',
			required: true,
			validators: [
				{
					name: 'minLength',
					props: { min: 2 },
				},
			],
		},
	},
	{
		component: 'DomEmailInput',
		props: {
			name: 'email',
			label: 'Email',
			required: true,
		},
	},
];

const result = ref('');

async function validate() {
	const valid = await forms.serverUser?.validate();
	result.value = valid ? 'Server-defined form is valid.' : 'Server-defined form has errors.';
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm name="serverUser" v-model="user" :children="children" class="space-y-4">
			<div class="border-t border-border pt-4">
				<DomButton type="button" @click="validate">Validate generated fields</DomButton>
			</div>
		</DomForm>
		<p v-if="result" class="mt-4 text-sm text-muted-fg">{{ result }}</p>
		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ user }}</pre>
	</div>
</template>

Schema

Generate fields from a Zod-like shape

The Zod adapter compiles a Zod schema, or a plain Zod-like object, into the same children records used by the Studio renderer. DomForm only receives those children, so schema-specific logic stays outside the provider.

The exported zodSchemaToChildren(schema, options) helper is available when an app wants to inspect or store the generated children before rendering.

The schema defines the data shape. Adapter options customise labels, components, options, and input props without changing the model.

Enum values become a styled native select.

Number constraints become input props and validators.

Receive product updatesBoolean fields become checkboxes.
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "website": "https://example.com",
  "plan": "team",
  "seats": 5,
  "newsletter": true
}
ZodSchemaForm.vuevue
<script setup>
import { ref } from 'vue';
import { DomButton, DomForm, forms, zodSchemaToChildren } from '../../../lib/vue';

const account = ref({
	name: 'Ada Lovelace',
	email: 'ada@example.com',
	website: 'https://example.com',
	plan: 'team',
	seats: 5,
	newsletter: true,
});

const accountSchema = {
	type: 'object',
	shape: {
		name: {
			type: 'string',
			minLength: 2,
			description: 'Name',
			placeholder: 'Grace Hopper',
		},
		email: {
			type: 'string',
			format: 'email',
			description: 'Email address',
		},
		website: {
			type: 'string',
			format: 'url',
			optional: true,
			description: 'Website',
		},
		plan: {
			type: 'enum',
			values: ['starter', 'team', 'enterprise'],
			description: 'Plan',
		},
		seats: {
			type: 'number',
			min: 1,
			max: 50,
			description: 'Seats',
		},
		newsletter: {
			type: 'boolean',
			description: 'Receive product updates',
			optional: true,
		},
	},
};

const adapterOptions = {
	fields: {
		plan: {
			description: 'Enum values become a styled native select.',
			props: {
				options: [
					{ label: 'Starter', value: 'starter' },
					{ label: 'Team', value: 'team' },
					{ label: 'Enterprise', value: 'enterprise' },
				],
			},
		},
		seats: {
			description: 'Number constraints become input props and validators.',
			props: {
				step: 1,
			},
		},
		newsletter: {
			description: 'Boolean fields become checkboxes.',
		},
	},
};
const formChildren = zodSchemaToChildren(accountSchema, adapterOptions);

const result = ref(null);

async function validate() {
	const valid = await forms.schemaAccount?.validate();
	result.value = valid ? 'Schema form is valid.' : 'Schema form has errors.';
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm
			name="schemaAccount"
			v-model="account"
			:children="formChildren"
			class="space-y-4"
		>
			<div class="border-t border-border pt-4">
				<DomButton type="button" @click="validate">Validate schema form</DomButton>
			</div>
		</DomForm>

		<p v-if="result" class="mt-4 text-sm text-muted-fg">{{ result }}</p>
		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ account }}</pre>
	</div>
</template>
import { z } from 'zod';
import { zodSchemaToChildren } from '@getdom/studio/vue';

const accountSchema = z.object({
	name: z.string().min(2).describe('Name'),
	email: z.string().email().describe('Email address'),
	website: z.string().url().optional().describe('Website'),
	plan: z.enum(['starter', 'team', 'enterprise']).describe('Plan'),
});

const children = zodSchemaToChildren(accountSchema, {
	fields: {
		plan: {
			props: {
				options: [
					{ label: 'Starter', value: 'starter' },
					{ label: 'Team', value: 'team' },
					{ label: 'Enterprise', value: 'enterprise' },
				],
			},
		},
	},
});

<DomForm
	name="account"
	v-model="account"
	:children="children"
/>

Schema

Generate fields from JSON Schema

The JSON Schema adapter reads JSON Schema objects and emits form children. The JSON Schema controls the model shape and common constraints; form-only decoration can live in a vendor extension such as x-el or in external adapter options.

The exported jsonSchemaToChildren(schema, options) helper returns renderable children records. The current inline convention is x-el, which follows JSON Schema's vendor-extension pattern: unknown keywords are annotations and should be ignored by validators that do not understand them.

JSON Schema properties become fields, required arrays mark fields as required, nested objects keep nested data paths, and x-el decorates form-only options.

Enum values render as a native select.

Active accountRendered as a switch through the JSON Schema vendor extension.

Integer constraints become number input props and validators.

Row 1
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "role": "admin",
  "active": true,
  "bio": "Computing notes, analytical engines, and careful correspondence.",
  "team": {
    "name": "Research",
    "size": 6
  },
  "links": [
    {
      "label": "Website",
      "url": "https://example.com"
    }
  ]
}
JsonSchemaForm.vuevue
<script setup>
import { ref } from 'vue';
import { DomButton, DomForm, forms, jsonSchemaToChildren } from '../../../lib/vue';

const profile = ref({
	name: 'Ada Lovelace',
	email: 'ada@example.com',
	role: 'admin',
	active: true,
	bio: 'Computing notes, analytical engines, and careful correspondence.',
	team: {
		name: 'Research',
		size: 6,
	},
	links: [
		{ label: 'Website', url: 'https://example.com' },
	],
});

const profileSchema = {
	$schema: 'https://json-schema.org/draft/2020-12/schema',
	type: 'object',
	required: ['name', 'email', 'role', 'team'],
	properties: {
		name: {
			type: 'string',
			title: 'Full name',
			minLength: 2,
			examples: ['Grace Hopper'],
		},
		email: {
			type: 'string',
			format: 'email',
			title: 'Email address',
		},
		role: {
			type: 'string',
			title: 'Role',
			enum: ['viewer', 'editor', 'admin'],
			'x-el': {
				description: 'Enum values render as a native select.',
				props: {
					options: [
						{ label: 'Viewer', value: 'viewer' },
						{ label: 'Editor', value: 'editor' },
						{ label: 'Admin', value: 'admin' },
					],
				},
			},
		},
		active: {
			type: 'boolean',
			title: 'Active account',
			default: true,
			'x-el': {
				component: 'DomToggle',
				description: 'Rendered as a switch through the JSON Schema vendor extension.',
			},
		},
		bio: {
			type: 'string',
			title: 'Bio',
			maxLength: 240,
			'x-el': {
				component: 'DomTextareaInput',
				props: {
					rows: 4,
					placeholder: 'Short public note',
				},
			},
		},
		team: {
			type: 'object',
			required: ['name', 'size'],
			properties: {
				name: {
					type: 'string',
					title: 'Team name',
				},
				size: {
					type: 'integer',
					title: 'Team size',
					minimum: 1,
					maximum: 30,
				},
			},
		},
		links: {
			type: 'array',
			title: 'Links',
			items: {
				type: 'object',
				properties: {
					label: {
						type: 'string',
						title: 'Label',
					},
					url: {
						type: 'string',
						format: 'uri',
						title: 'URL',
					},
				},
			},
			'x-el': {
				props: {
					addLabel: '+ Add link',
				},
			},
		},
	},
};

const adapterOptions = {
	fields: {
		team: {
			class: 'space-y-3 rounded-xl border border-border bg-secondary/25 p-4',
		},
		'team.size': {
			description: 'Integer constraints become number input props and validators.',
		},
	},
};
const formChildren = jsonSchemaToChildren(profileSchema, adapterOptions);

const result = ref(null);

async function validate() {
	const valid = await forms.jsonProfile?.validate();
	result.value = valid ? 'JSON Schema form is valid.' : 'JSON Schema form has errors.';
}
</script>

<template>
	<div class="w-full max-w-xl">
		<DomForm
			name="jsonProfile"
			v-model="profile"
			:children="formChildren"
			class="space-y-4"
		>
			<div class="border-t border-border pt-4">
				<DomButton type="button" @click="validate">Validate JSON Schema form</DomButton>
			</div>
		</DomForm>

		<p v-if="result" class="mt-4 text-sm text-muted-fg">{{ result }}</p>
		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ profile }}</pre>
	</div>
</template>
import { jsonSchemaToChildren } from '@getdom/studio/vue';

const profileSchema = {
	$schema: 'https://json-schema.org/draft/2020-12/schema',
	type: 'object',
	required: ['name', 'email', 'role'],
	properties: {
		name: { type: 'string', title: 'Full name', minLength: 2 },
		email: { type: 'string', format: 'email', title: 'Email address' },
		active: {
			type: 'boolean',
			title: 'Active account',
			'x-el': {
				component: 'DomToggle',
				description: 'Rendered as a switch through the JSON Schema vendor extension.',
			},
		},
		role: {
			type: 'string',
			title: 'Role',
			enum: ['viewer', 'editor', 'admin'],
			'x-el': {
				description: 'Enum values render as a native select.',
				props: {
					options: [
						{ label: 'Viewer', value: 'viewer' },
						{ label: 'Editor', value: 'editor' },
						{ label: 'Admin', value: 'admin' },
					],
				},
			},
		},
	},
};

const children = jsonSchemaToChildren(profileSchema, {
	fields: {
		name: {
			props: { placeholder: 'Grace Hopper' },
		},
	},
});

<DomForm
	name="profile"
	v-model="profile"
	:children="children"
/>

Adapters

Bring your own schema

Adapters are just functions that return DomForm children. A custom adapter can read CMS fields, database metadata, OpenAPI request bodies, or anything else that can be mapped into component records.

const cmsFieldAdapter = {
	name: 'cms-fields',
	matches: (schema) => Array.isArray(schema?.fields),
	toChildren: (schema) => schema.fields.map((field) => ({
		id: field.id,
		component: field.component || 'DomTextInput',
		props: {
			name: field.key,
			label: field.label,
			required: field.required,
		},
	})),
};

const children = cmsFieldAdapter.toChildren(cmsSchema);

<DomForm v-model="entry" :children="children" />

Architecture

Schema and form data

The form definition describes data types, components, validation, and future storage metadata. The form model stores only the values users enter. Keeping those separate makes it possible to generate forms from database tables, or generate database metadata from a form builder.

Read schema and data notes

API

Programmatic updates

Named forms are available through forms[name]. Values can be updated externally and validation can be triggered from code.

{
  "name": "Grace Hopper",
  "email": "grace@example.com",
  "website": "https://example.com"
}
ProgrammaticForm.vuevue
<script setup>
import { ref } from 'vue';
import { DomButton, DomEmailInput, DomForm, DomTextInput, DomUrlInput, forms } from '../../../lib/vue';

const profile = ref({
	name: 'Grace Hopper',
	email: 'grace@example.com',
	website: 'https://example.com',
});

async function validateProfile() {
	await forms.profile.validate();
}

function setExternalValues() {
	forms.profile.setFieldValue('name', 'Katherine Johnson');
	forms.profile.setFieldValue('email', 'katherine@example.com');
	forms.profile.setFieldValue('website', 'not a url');
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm name="profile" v-model="profile" class="space-y-4">
			<DomTextInput name="name" label="Name" />
			<DomEmailInput name="email" label="Email" />
			<DomUrlInput name="website" label="Website" :validate-on-blur="false" />

			<div class="flex flex-wrap gap-2">
				<DomButton type="button" variant="secondary" @click="setExternalValues">Set externally</DomButton>
				<DomButton type="button" @click="validateProfile">Validate</DomButton>
			</div>
		</DomForm>

		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ profile }}</pre>
	</div>
</template>

API

Programmatic field errors

Runtime validation errors belong to the form state, not the authored errors prop. Use setFieldState(name, { errors }) when a server response or external process needs to mark a field invalid.

A field can receive errors from the form API without changing the field's authored props.

Click "Add username error" to add a runtime error to the field.
{}
ProgrammaticErrors.vuevue
<script setup>
import { computed, ref } from 'vue';
import { DomButton, DomForm, DomTextInput, forms } from '../../../lib/vue';

const account = ref({
	username: 'steve',
	displayName: 'Alex Morgan',
});

const lastAction = ref('Click "Add username error" to add a runtime error to the field.');
const runtimeErrors = computed(() => forms.accountErrors?.errors || {});

function formApi() {
	return forms.accountErrors;
}

function addUsernameError() {
	formApi().setFieldState('username', {
		invalid: true,
		errors: {
			unique: 'That username is already taken.',
		},
	});
	lastAction.value = 'form.setFieldState("username", { errors: { unique: "That username is already taken." } })';
}

function addDisplayNameError() {
	formApi().getField('displayName').setState({
		invalid: true,
		errors: [
			{ name: 'format', message: 'Use a space between first and last name.' },
		],
	});
	lastAction.value = 'form.getField("displayName").setState({ errors: [{ name: "format", message: "Use a space between first and last name." }] })';
}

function clearErrors() {
	formApi().setFieldState('username', { invalid: false, errors: [] });
	formApi().setFieldState('displayName', { invalid: false, errors: [] });
	lastAction.value = 'form.setFieldState(name, { invalid: false, errors: [] })';
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm name="accountErrors" v-model="account" class="space-y-4">
			<DomTextInput name="username" label="Username" />
			<DomTextInput name="displayName" label="Display name" />

			<div class="flex flex-wrap gap-2">
				<DomButton type="button" variant="secondary" @click="addUsernameError">Add username error</DomButton>
				<DomButton type="button" variant="secondary" @click="addDisplayNameError">Add name error</DomButton>
				<DomButton type="button" @click="clearErrors">Clear errors</DomButton>
			</div>
		</DomForm>

		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ lastAction }}</pre>
		<pre class="mt-3 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ runtimeErrors }}</pre>
	</div>
</template>

Standalone

Fields still work without a form

Field components keep their normal modelValue/update:modelValue contract when they are not connected to DomForm.

{
  "label": "Inbox",
  "tone": "success",
  "limit": 10,
  "pinned": true
}
StandaloneFields.vuevue
<script setup>
import { ref } from 'vue';
import { DomCheckbox, DomNativeSelect, DomNumberInput, DomTextInput } from '../../../lib/vue';

const label = ref('Inbox');
const tone = ref('success');
const limit = ref(10);
const pinned = ref(true);

const toneOptions = [
	{ label: 'Default', value: 'default' },
	{ label: 'Success', value: 'success' },
	{ label: 'Danger', value: 'danger' },
];
</script>

<template>
	<div class="w-full max-w-md space-y-4">
		<DomTextInput v-model="label" label="Label" />
		<DomNativeSelect v-model="tone" label="Tone" :options="toneOptions" />
		<DomNumberInput v-model="limit" label="Result limit" :min="1" :max="50" />
		<DomCheckbox v-model="pinned" label="Pinned" description="This checkbox is not inside DomForm." />

		<pre class="overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ { label, tone, limit, pinned } }}</pre>
	</div>
</template>

Pinia

Use a store as the form model

When an app already uses Pinia, keep business data in the store and bind it to DomForm with v-model. The named form API can still be used for validation and programmatic field updates.

// stores/profileForm.js
import { defineStore } from 'pinia';
import { forms } from '@getdom/studio/vue';

export const useProfileFormStore = defineStore('profileForm', {
	state: () => ({
		values: {
			name: '',
			email: '',
		},
		errors: {},
	}),
	actions: {
		setFromServer(values) {
			this.values = { ...values };
		},
		async validate() {
			return forms.profile?.validate() ?? false;
		},
		onChange({ errors }) {
			this.errors = errors;
		},
	},
});

// ProfileForm.vue
import { storeToRefs } from 'pinia';

const store = useProfileFormStore();
const { values } = storeToRefs(store);

<DomForm name="profile" v-model="values" @change="store.onChange">
	<DomTextInput name="name" label="Name" />
	<DomEmailInput name="email" label="Email" />
</DomForm>

Nested data

Subforms and paths

Nested forms scope field names while the root form owns the full object. Subforms can still be addressed and validated.

First invitee

{
  "title": "Design review",
  "invitees": [
    {
      "name": "Sam",
      "email": "sam@example.com"
    }
  ]
}
NestedForm.vuevue
<script setup>
import { ref } from 'vue';
import { DomButton, DomEmailInput, DomForm, DomTextInput, forms } from '../../../lib/vue';

const event = ref({
	title: 'Design review',
	invitees: [
		{ name: 'Sam', email: 'sam@example.com' },
	],
});

async function validateInvitee() {
	await forms.event.getSubform('invitees.0').validate();
}

function updateInvitee() {
	forms.event.setFieldValue('invitees.0.email', 'not-an-email');
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm name="event" v-model="event" class="space-y-4">
			<DomTextInput name="title" label="Event title" />

			<DomForm name="invitees.0" class="space-y-3 rounded-xl border border-border bg-secondary/25 p-4">
				<p class="text-xs font-semibold uppercase tracking-wider text-muted-fg">First invitee</p>
				<DomTextInput name="name" label="Name" />
				<DomEmailInput name="email" label="Email" />
			</DomForm>

			<div class="flex flex-wrap gap-2">
				<DomButton type="button" variant="secondary" @click="updateInvitee">Set nested value</DomButton>
				<DomButton type="button" @click="validateInvitee">Validate subform</DomButton>
			</div>
		</DomForm>

		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ event }}</pre>
	</div>
</template>

Dynamic schema

Add subforms programmatically

The form definition can change at runtime. This example keeps top-level invitation settings beside a nested invitees array, then adds slot-provided actions after the generated fields.

Every invitee added below will receive this access level.

Top-level message stored beside the nested invitees array.

{
  "workspace": "DOM Studio Studio",
  "role": "viewer",
  "message": "You are invited to collaborate on the next release.",
  "invitees": []
}
[
  {
    "component": "DomTextInput",
    "props": {
      "name": "workspace",
      "label": "Workspace",
      "required": true
    }
  },
  {
    "component": "DomNativeSelect",
    "props": {
      "name": "role",
      "label": "Invitee role",
      "description": "Every invitee added below will receive this access level.",
      "required": true,
      "options": [
        {
          "label": "Owner",
          "value": "owner"
        },
        {
          "label": "Admin",
          "value": "admin"
        },
        {
          "label": "Viewer",
          "value": "viewer"
        }
      ]
    }
  },
  {
    "component": "DomTextareaInput",
    "props": {
      "name": "message",
      "label": "Invitation message",
      "description": "Top-level message stored beside the nested invitees array.",
      "rows": 3
    }
  }
]
DynamicInvitees.vuevue
<script setup>
import { ref } from 'vue';
import { DomButton, DomForm, forms } from '../../../lib/vue';

const invitation = ref({
	workspace: 'DOM Studio Studio',
	role: 'viewer',
	message: 'You are invited to collaborate on the next release.',
	invitees: [],
});
const submitResult = ref(null);
const children = ref([
	{
		component: 'DomTextInput',
		props: {
			name: 'workspace',
			label: 'Workspace',
			required: true,
		},
	},
	{
		component: 'DomNativeSelect',
		props: {
			name: 'role',
			label: 'Invitee role',
			description: 'Every invitee added below will receive this access level.',
			required: true,
			options: [
				{ label: 'Owner', value: 'owner' },
				{ label: 'Admin', value: 'admin' },
				{ label: 'Viewer', value: 'viewer' },
			],
		},
	},
	{
		component: 'DomTextareaInput',
		props: {
			name: 'message',
			label: 'Invitation message',
			description: 'Top-level message stored beside the nested invitees array.',
			rows: 3,
		},
	},
]);

function inviteeFields(index) {
	return [
		{
			component: 'p',
			props: { class: 'text-xs font-semibold uppercase tracking-wider text-muted-fg' },
			children: [{ text: `Invitee ${index + 1}` }],
		},
		{
			component: 'DomTextInput',
			props: {
				name: 'name',
				label: 'Name',
				required: true,
			},
		},
		{
			component: 'DomEmailInput',
			props: {
				name: 'email',
				label: 'Email',
				required: true,
			},
		},
	];
}

function addInvitee() {
	const index = invitation.value.invitees.length;
	forms.invites?.setFieldValue(`invitees.${index}`, { name: '', email: '' });
	forms.invites?.addSubform(`invitees.${index}`, inviteeFields(index), {
		id: `invitee-${index}`,
	});
}

async function validateInvitees() {
	const valid = await forms.invites?.validate();
	submitResult.value = {
		status: valid ? 'valid' : 'invalid',
		role: invitation.value.role,
		invitees: invitation.value.invitees.length,
	};
}

function onSubmit({ values }) {
	submitResult.value = {
		status: 'submitted',
		role: values.role,
		invitees: values.invitees.length,
	};
}

function onInvalid({ values, state }) {
	submitResult.value = {
		status: 'invalid',
		role: values.role,
		errorCount: state.errorCount,
	};
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm
			name="invites"
			v-model="invitation"
			v-model:children="children"
			class="space-y-4"
			@submit="onSubmit"
			@invalid="onInvalid"
		>
			<div class="flex flex-wrap gap-2 border-t border-border pt-4">
				<DomButton type="button" variant="secondary" @click="addInvitee">Add invitee</DomButton>
				<DomButton type="submit">Send invitations</DomButton>
				<DomButton type="button" variant="secondary" @click="validateInvitees">Validate only</DomButton>
			</div>
		</DomForm>

		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ invitation }}</pre>
		<pre class="mt-3 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ submitResult }}</pre>
		<pre class="mt-3 max-h-40 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ children }}</pre>
	</div>
</template>

Events

Listen to all child changes

The change event fires for child field updates with the field path, current value, values, errors, and form state.

Change log

  • Change a field to see events.
ChangeEvents.vuevue
<script setup>
import { ref } from 'vue';
import { DomEmailInput, DomForm, DomTextInput } from '../../../lib/vue';

const person = ref({
	name: '',
	email: '',
});
const log = ref([]);

function onChange(event) {
	log.value.unshift({
		field: event.name,
		value: event.value,
		at: new Date().toLocaleTimeString(),
	});
	log.value = log.value.slice(0, 5);
}
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm v-model="person" class="space-y-4" @change="onChange">
			<DomTextInput name="name" label="Name" />
			<DomEmailInput name="email" label="Email" />
		</DomForm>

		<div class="mt-4 rounded-lg border border-border bg-secondary/30 p-3">
			<p class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Change log</p>
			<ul class="mt-2 space-y-1 text-xs text-muted-fg">
				<li v-for="entry in log" :key="`${entry.field}-${entry.at}-${entry.value}`">
					<span class="text-fg">{{ entry.field }}</span>: {{ entry.value || 'empty' }} at {{ entry.at }}
				</li>
				<li v-if="!log.length">Change a field to see events.</li>
			</ul>
		</div>
	</div>
</template>

Methods

Control a form from code

This example exposes its form API as window.domStudioForm. Try window.domStudioForm.get('reviewer.email').setValue('you@example.com') in the console.

Use get() for convenient lookup, or getField()/getForm() when you need a precise return type.

Open the browser console and try window.domStudioForm.get("reviewer.email").
{
  "title": "Launch checklist",
  "status": "draft",
  "reviewer": {
    "name": "Mina Patel",
    "email": "mina@example.com"
  }
}
FormMethods.vuevue
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { DomButton, DomForm, forms } from '../../../lib/vue';

const project = ref({
	title: 'Launch checklist',
	status: 'draft',
	reviewer: {
		name: 'Mina Patel',
		email: 'mina@example.com',
	},
});
const lastAction = ref('Open the browser console and try window.domStudioForm.get("reviewer.email").');
const methodForm = ref(null);

const children = [
	{
		component: 'DomTextInput',
		props: {
			name: 'title',
			label: 'Title',
			required: true,
		},
	},
	{
		component: 'DomNativeSelect',
		props: {
			name: 'status',
			label: 'Status',
			options: [
				{ label: 'Draft', value: 'draft' },
				{ label: 'Ready', value: 'ready' },
				{ label: 'Archived', value: 'archived' },
			],
		},
	},
	{
		component: 'DomForm',
		props: {
			name: 'reviewer',
			class: 'space-y-3 rounded-xl border border-border bg-secondary/25 p-4',
		},
		children: [
			{
				component: 'p',
				props: { class: 'text-xs font-semibold uppercase tracking-wider text-muted-fg' },
				children: [{ text: 'Reviewer' }],
			},
			{
				component: 'DomTextInput',
				props: {
					name: 'name',
					label: 'Name',
					required: true,
				},
			},
			{
				component: 'DomEmailInput',
				props: {
					name: 'email',
					label: 'Email',
					required: true,
				},
			},
		],
	},
];

function formApi() {
	return methodForm.value || forms.methodControl;
}

function setReady() {
	formApi().get('status').setValue('ready');
	lastAction.value = 'form.get("status").setValue("ready")';
}

function changeReviewer() {
	formApi().get('reviewer.email').setValue('alex@example.com');
	lastAction.value = 'form.get("reviewer.email").setValue("alex@example.com")';
}

function inspectReviewer() {
	const reviewer = formApi().get('reviewer');
	lastAction.value = `form.isForm(form.get("reviewer")) === ${formApi().isForm(reviewer)}`;
}

async function validateForm() {
	const valid = await formApi().validate();
	lastAction.value = `await form.validate() -> ${valid}`;
}

onMounted(async () => {
	await nextTick();
	globalThis.domStudioForm = formApi();
	window.domStudioForm = formApi();
	window.top.domStudioForm = formApi();
});

onBeforeUnmount(() => {
	if (window.domStudioForm === formApi()) delete window.domStudioForm;
	if (window.top.domStudioForm === formApi()) delete window.top.domStudioForm;
	if (globalThis.domStudioForm === formApi()) delete globalThis.domStudioForm;
});
</script>

<template>
	<div class="w-full max-w-md">
		<DomForm
			ref="methodForm"
			name="methodControl"
			v-model="project"
			:children="children"
			class="space-y-4"
		>
			<div class="flex flex-wrap gap-2 border-t border-border pt-4">
				<DomButton type="button" variant="secondary" @click="setReady">Set ready</DomButton>
				<DomButton type="button" variant="secondary" @click="changeReviewer">Change reviewer</DomButton>
				<DomButton type="button" variant="secondary" @click="inspectReviewer">Inspect reviewer</DomButton>
				<DomButton type="button" @click="validateForm">Validate</DomButton>
			</div>
		</DomForm>

		<pre class="mt-4 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ lastAction }}</pre>
		<pre class="mt-3 overflow-auto rounded-lg border border-border bg-secondary/30 p-3 text-xs text-muted-fg">{{ project }}</pre>
	</div>
</template>

Reference

Props

Control props

NameTypeTSDefaultDescription
modelValuearray | objectArray<unknown>{}Current form value keyed by field path.
type'object' | 'array'string'object'Render a single object form or an array of form rows.
multiplebooleanbooleanfalseRender this form as a repeatable array of itself.
itemsarray | objectArray<unknown>Schema used for each row when type is array.
addLabelstringstring''Button label for adding a row in array mode.
compactbooleanbooleanfalseReduce spacing for inspectors and narrow layouts.
labelstringstring''Optional group label.
descriptionstringstring''Optional group description.
requiredbooleanbooleanfalseShow a required marker on the group label.
visiblebooleanbooleantrueShow or hide the rendered form.
tagstringstring''Override the rendered root element.
isolatedbooleanbooleanfalseCreate a standalone provider even when nested inside another form.
namestringstring''Root forms use name as the global registry key. Nested forms use name as their local path segment.
childrenarray | objectArray<unknown>[]Server/studio-style field definitions to render when slot children are not supplied. Accepts an array or keyed object.
validateOnSubmitbooleanbooleantrueRun all registered field validators before emitting submit.

Auto-generated from Form.props and inline _edit hints.

Slots

NameScopeDescription
#(default)Form fields. Slot props include values, errors, state, validate, and reset.

Events

NamePayloadDescription
@update:modelValueRecord<string, unknown>Fired when any child field updates the form value.
@update:childrenArrayFired when the programmatic schema children change.
@schema-change{ children, form }Fired when children are added, removed, or replaced through the form API.
@change{ name, value, values, errors, state }Fired whenever a child field changes.
@submit{ values, errors, state }Fired after successful validation when the form is submitted.
@invalid{ values, errors, state }Fired when submit validation fails.

Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.

Methods

MethodReturnsDescription
getState()Form state snapshotReturns aggregate form state, values, errors, fieldStates, and a complete fields object keyed by path.
get(name)Field API | Form API | nullReturns a field or nested form by local name or path. Use isField/isForm to branch safely.
getField(name)Field API | nullReturns only a field API by local name or nested path.
getForm(name)Form API | nullReturns this form when no name is passed, or a nested form by path.
isForm(value)booleanChecks whether a value returned from get is a form API.
isField(value)booleanChecks whether a value returned from get is a field API.
getValue(name)unknownAlias for getFieldValue. Reads a field value from this form scope.
setValue(name, value)voidAlias for setFieldValue. Writes a field value in this form scope and emits change events.
getFieldValue(name)unknownReads a value by local field name or nested path.
setFieldValue(name, value)voidWrites a value by local field name or nested path.
getFieldPath(name)stringReturns the derived dot path for a local field name inside this form scope.
getHtmlName(name)stringReturns the bracket-style native form name, such as invitees[0][email].
getHtmlId(name)stringReturns the default generated input ID, such as invitees_0_email.
getFieldState(name)objectReads the field state machine axes plus derived touched, dirty, focused, validating, invalid, valid, visible, disabled, and error state.
setFieldState(name, patch)voidApplies state to one field. Boolean shorthands normalize into the state axes; passing errors also updates the form error object.
validate()Promise<boolean>Runs validators for all fields in this form scope.
reset(nextValues)voidResets values, errors, and field state for this form scope.
getSubform(name)DomForm APIReturns a nested subform by local name or path.
getChildren()ArrayReturns the current normalized children definition.
setChildren(children)voidReplaces the dynamic children definition and emits update:children and schema-change.
addChild(child, index)objectInserts a schema node into the dynamic children list.
removeChild(match)object | nullRemoves a schema node by index, id, or name.
replaceChild(match, child)objectReplaces a schema node, or appends it when no match is found.
addSubform(path, children, options)objectAdds a nested DomForm definition node and optionally seeds its matching data path.