Skip to content

Forms ​

The form engine takes a field list and renders it. Everything below is about the container, not the fields.

Open in demo

SchemaForm ​

vue
<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 ​

PropTypeDefaultDescription
schemaFieldDefinition[]requiredThe field list.
modelValueRecord<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.
draftKeystring—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 ​

EventPayloadDescription
submitvalues: Record<string, unknown>Fired after validation passes.
cancel—Only from the footer this component draws on a flat surface.

Slots ​

SlotPropsDescription
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:

flatfooter
A bare SchemaFormfalsefalse
FormPageLayoutas configuredtrue
SchemaFormDialogas configuredfalse, 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:

vue
<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.

Open in demo

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 ​

PropTypeDefaultDescription
schemaFieldDefinition[]requiredThe field list.
stepsreadonly { title, group }[]requiredOne entry per step, in order.
modelValueRecord<string, unknown>{}Initial values, and v-model while the reader moves between steps.

Emits ​

EventPayload
submitvalues: Record<string, unknown>
update:stepindex: 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.

vue
<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 ​

vue
<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:

ts
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 ​

ts
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 ​