Forms
The form engine takes a field list and renders it. Everything below is about the container, not the fields.
SchemaForm
<script setup lang="ts">
import SchemaForm from '@core/schema/SchemaForm.vue'
import { productFormSchema } from '../types/product.schemas'
function save(values: Record<string, unknown>) {
console.log(values)
}
</script>
<template>
<SchemaForm :schema="productFormSchema" @submit="save" />
</template>Props
| Prop | Type | Default | Description |
|---|---|---|---|
schema | FieldDefinition[] | required | The field list. |
modelValue | Record<string, unknown> | {} | Initial values. |
layout | 'standard' | 'columns' | 'wizard' | 'tabs' | 'standard' | Only standard and columns change this component: columns makes the default span six of twelve instead of twelve. Wizard and tabs are separate components, below. |
draftKey | string | — | Auto-saves to sa-draft:<draftKey> and clears on a successful submit. |
serverErrors | { field, message }[] | — | Applied over the Zod result, so a server rule lands under its own input. |
Emits
| Event | Payload | Description |
|---|---|---|
submit | values: Record<string, unknown> | Fired after validation passes. |
cancel | — | Only from the footer this component draws on a flat surface. |
Slots
| Slot | Props | Description |
|---|---|---|
submit | { submitting: false } | Replaces the submit button. |
The surface decides the shape
A form looks different inside a page and inside a dialog, and the container is what declares that, not the form:
flat | footer | |
|---|---|---|
A bare SchemaForm | false | false |
FormPageLayout | as configured | true |
SchemaFormDialog | as configured | false, because the dialog owns its own footer |
On a flat surface the group headings disappear, the fields are reordered by type, and uploads and long text take a whole row. That is what keeps every form page in the template to one shape.
A bare SchemaForm draws no footer, so it renders a single Submit button through the submit slot's fallback.
Validation
Zod, generated from the schema, with novalidate on the <form> so the browser's own bubbles never pre-empt the app's messages. On a failed submit, focus moves to the first invalid field in render order, not schema order, and scrolls it into view — on a flat surface those are two different things.
Full reference for the rules and messages: Schema.
Server errors
A 422 from your API comes back as { errors: [{ field, message }] }. Hand that list to the form:
<script setup lang="ts">
import type { ValidationError } from '@shared/types/api.types'
import SchemaForm from '@core/schema/SchemaForm.vue'
import { useApiError } from '@shared/composables/useApiError'
import { ref } from 'vue'
import { productsApi } from '../api/products.api'
import { productFormSchema } from '../types/product.schemas'
const { handleError } = useApiError()
const serverErrors = ref<ValidationError[]>([])
async function save(values: Record<string, unknown>) {
serverErrors.value = []
try {
await productsApi.create(values)
}
catch (error) {
const fieldErrors = handleError(error)
if (fieldErrors)
serverErrors.value = fieldErrors
}
}
</script>
<template>
<SchemaForm :schema="productFormSchema" :server-errors="serverErrors" @submit="save" />
</template>They are applied over the Zod result, so a uniqueness rule the client cannot know lands under the input it belongs to rather than in a generic toast.
A wizard
One form underneath, split into steps. Values persist across steps, Next validates only the step you are on, and the final submit validates everything.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import FormWizard from '@core/schema/layouts/FormWizard.vue'
import { ref } from 'vue'
const schema: FieldDefinition[] = [
{ key: 'company', type: 'text', label: 'Company', required: true, group: 'Company', grid: 6 },
{ key: 'website', type: 'text', label: 'Website', group: 'Company', grid: 6 },
{ key: 'contact', type: 'text', label: 'Contact name', required: true, group: 'Contact', grid: 6 },
{ key: 'email', type: 'email', label: 'Email', required: true, group: 'Contact', grid: 6 },
{
key: 'plan',
type: 'select',
label: 'Plan',
required: true,
group: 'Billing',
grid: 6,
options: [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
],
},
{ key: 'poNumber', type: 'text', label: 'PO number', group: 'Billing', grid: 6 },
]
const STEPS = [
{ title: 'Company', group: 'Company' },
{ title: 'Contact', group: 'Contact' },
{ title: 'Billing', group: 'Billing' },
] as const
const values = ref<Record<string, unknown>>({})
const step = ref(0)
const submitted = ref<Record<string, unknown> | null>(null)
</script>
<template>
<div class="flex flex-col gap-4">
<FormWizard
v-model="values"
:schema="schema"
:steps="STEPS"
@submit="submitted = $event"
@update:step="step = $event"
/>
<p class="text-sm text-muted-foreground">
Step {{ step + 1 }} of {{ STEPS.length }}
</p>
<pre v-if="submitted" class="rounded-lg border border-border p-3 text-xs">{{ submitted }}</pre>
</div>
</template>FormWizard is its own component, and steps is explicit: each entry maps a title to a group in the schema. Order matters, and a step usually wants a shorter name than its group.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
schema | FieldDefinition[] | required | The field list. |
steps | readonly { title, group }[] | required | One entry per step, in order. |
modelValue | Record<string, unknown> | {} | Initial values, and v-model while the reader moves between steps. |
Emits
| Event | Payload |
|---|---|
submit | values: Record<string, unknown> |
update:step | index: number |
Tabs
FormTabs takes schema and modelValue and buckets the fields by group, one tab per group. It emits submit. There is no steps prop: a tab set has no order to enforce.
<script setup lang="ts">
import FormTabs from '@core/schema/layouts/FormTabs.vue'
import { userSchema } from './userSchema'
</script>
<template>
<FormTabs :schema="userSchema" @submit="save" />
</template>Drafts
<template>
<SchemaForm :schema="productFormSchema" draft-key="product:new" @submit="save" />
</template>Values are written to localStorage under sa-draft:product:new every thirty seconds and restored when the form mounts, before useForm runs, so the draft seeds the initial values at first paint. A successful submit clears it, so a refresh cannot restore what was already saved.
The composable underneath is usable on its own:
import { useFormDraft } from '@core/schema/useFormDraft'
const draft = useFormDraft('product:new', () => values.value, { intervalMs: 15_000, enabled: () => !saving.value })
const restored = draft.restore()
draft.save()
draft.clearDraft()restore is synchronous and returns null when there is nothing saved or the stored value is corrupt.
Unsaved changes
import { useFormDirty } from '@core/schema/useFormDirty'
const { isDirty, markPristine } = useFormDirty(initialValues, values)It snapshots initial once, compares it to current deeply, and while the two differ it does two things: leaving the route asks through the app's own confirm dialog, and closing the tab raises the browser's own unsaved-changes warning.
The snapshot is taken at the call, so after a successful save either remount the form or call markPristine() to move the baseline. confirmMessage and confirmDescription override the dialog's wording.
Where to go next
- Schema — the field list and its rules.
- Connect your API — the 422 shape this page expects.