Forms
A form is a schema, sections of fields, and one action row. The schema (Zod) owns the rules and the messages; vee-validate runs it; Field binds each control to it by name and renders the label, hint and messages around it. The alternative — messages handed in as data — is for record forms validated outside the page. Both use the same parts.
ts
import { Field, FieldLabel, FieldHint, FieldMessages, FormSection } from '@scaler-tech/aurora/forms'Try it: submit empty, fix a field, tab away from a select. Live, built with the skeleton below.
The default: schema → useForm → Field name
Four things, in this order. Nothing else is needed for a working form.
ts
// 1. schema — rules, required-ness and messages live here, nowhere else
const schema = z.object({
email: z.string().min(1, 'Email is required').email('Enter a valid email address'),
country: z.string().min(1, 'Pick a country'),
terms: z.boolean().refine(Boolean, 'You must accept the terms'),
…
})
// 2. one useForm per form — always with initialValues
const { handleSubmit, resetForm, meta, isSubmitting } = useForm({
validationSchema: toTypedSchema(schema),
initialValues: { email: '', country: '', terms: false, … },
})
// 3. submit — validates everything, reveals every message, then calls you with typed values
const onSubmit = handleSubmit(values => api.save(values))html
<!-- 4. fields — name = schema key; the slot hands the control its value binding -->
<form @submit="onSubmit">
<FormSection title="…" field-layout="side">
<Field v-slot="{ componentField }" name="email">
<FieldLabel>Email</FieldLabel>
<FieldHint>…</FieldHint>
<TextInput v-bind="componentField" />
<FieldMessages />
</Field>
<Field v-slot="{ componentField }" name="country"> … <SimpleSelect v-bind="componentField" … /> … </Field>
<Field v-slot="{ componentField }" name="terms"> … <Toggle v-bind="componentField" /> … </Field>
</FormSection>
<div class="flex justify-end gap-2">
<Button type="button" variant="secondary" :disabled="!meta.dirty" @click="resetForm()">Reset</Button>
<Button type="submit" :loading="isSubmitting">Save changes</Button>
</div>
</form>componentField is { modelValue, 'onUpdate:modelValue' } — bind it to anything with a v-model, including page-level wrappers around Aurora controls.
What you get without writing it:
| required asterisk | from the schema — a key that is not .optional() / .nullable() marks its label |
| when messages show | after the control was blurred, or for every field at once on submit; they clear as soon as the value is valid |
| control wiring | id, label for, aria-invalid, aria-required, blur — through the Field context, for every Aurora control |
| submit | handleSubmit blocks invalid submits, marks every field touched, hands you the schema's output type |
| reset | resetForm() restores initialValues and clears every message |
Gotchas
initialValues | Always pass them. Without them an empty field fails Zod's type check before your rule runs and every message reads "Required" |
DatePicker | Its model is a DateValue by default: z.custom<DateValue>(Boolean, 'Pick a date').transform(v => v.toString()) → the handler gets 'YYYY-MM-DD'. With value-format="iso" the model already is that string (null when empty) and the schema takes z.string() |
| numbers | TextInput type="number" emits strings; NumberInput holds number | null, so z.number().nullable() binds without a transform |
| ids | SimpleSelect / SimpleCombobox item value may be a number and the model keeps it — no String(id) round-trip |
Combobox | Emits the option's value; pass the object as :value on ComboboxItem to get the whole option back |
| cross-field rules | .superRefine / .refine on the schema with path: ['fieldName']; the message lands on that field |
| server errors | setFieldError('email', 'Already taken') from useForm renders through the same FieldMessages |
name without a form | does nothing — the Field is then a plain field |
The alternative: messages handed to the page
When validation lives outside the page and arrives as data — a rule engine, stored validations, a 422 on a record saved field by field — skip the schema. Map whatever you have to { state, text }[] per field and pass :messages; values are the page's own v-models.
html
<Field field-id="country_id" required :messages="messagesFor('country_id')">
<FieldLabel>Country</FieldLabel>
<CountrySelector v-model="record.country_id" />
<FieldMessages />
</Field>This shape carries states Zod does not have — missing (required data absent, not an error yet), warning (accepted, worth a look), success (confirmed). Show them as soon as you have them, never only on save. Both shapes can meet: a Field with name and :messages shows both — the schema's error and the handed-in messages (a stored server warning under a schema-validated field).
Shapes
FormSection decides the layout of its fields; a field overrides with layout only when it genuinely differs. On a page, sections are separate cards (variant="card", the default) and the page supplies the gap between them (flex flex-col gap-6). Inside a modal or drawer — already a white surface — use variant="plain": no frame, and a separator closes every section but the last. An untitled section is fine for a leading group.
The gallery below is static — messages are handed in as data to show the shapes; the live Zod form is the one at the top.
Stacked — short flows
Sign in
Login, quick edit, modals. One column, label above the control. The default.
Two columns — dense sections
Address
Short stacked fields, rows aligned across the columns. Two is the maximum.
Side — long record forms
Location
Label, hint and messages on the left; the control centred on label + hint on the right, every control on one left edge. Hints worth reading, messages that arrive as data.
Message states
| Use when | |
|---|---|
stacked | short flows — login, quick edit, modals. Default |
columns="2" | dense sections of short stacked fields; rows align across the columns. Two is the maximum |
side | long record forms whose hints are worth reading; label, hint and messages left, control right, one left edge for every control |
Anatomy
| Piece | What it does |
|---|---|
FormSection | A titled card around a group of fields; sets their layout and column count |
Field | One field: owns id, required, disabled, messages and the layout; binds to the form by name; wires the control through a context |
FieldLabel, FieldHint, FieldControl, FieldMessages | The field's parts; read the Field context. FieldControl names the control area and holds what sits beside the control |
| the control | Any Aurora input — TextInput, Select, Combobox, DatePicker, Toggle, FormTextarea — or a page component wrapping one |
| action row | Button primary + secondary / tertiary |
FormSection — API
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Heading above the fields; omit it for an untitled group |
variant | 'card' | 'plain' | 'card' | card frames the fields in a bordered white card (pages); plain has no frame and ends in a separator unless it is the last section (modals, drawers) |
columns | 1 | 2 | 1 | Field grid columns; one column below md |
fieldLayout | 'stacked' | 'side' | 'none' | 'stacked' | Layout every Field inside uses unless it sets its own layout |
class | string | — | Merged onto the <section> |
| Exposes | Type | Description |
|---|---|---|
el | HTMLElement | null | The <section> element, for a parent that lists its sections — a PageMinimap item's el |
Actions
Bottom-right of the form on desktop, full-width stacked on mobile. Primary on the right, secondary left. The primary label names what the form does ("Save changes", "Create asset") — never "OK" or "Submit". Long forms pin the row to the bottom of the panel.
With useForm: type="submit" on the primary so Enter submits, :loading="isSubmitting", and gate reset on meta.dirty. Don't disable the primary on !meta.valid — an untouched form is invalid and a disabled button gives no hint why; let submit reveal the messages.
Rules
- Schema first. Rules and messages live in the Zod schema, not in the template.
- Label above or beside the field, never inside. Placeholder text disappears on focus.
- Mark required, not optional. The asterisk comes from the schema (or
Field required). If most fields are required, flip it: say "(optional)" in the hint instead. - Show messages as soon as you have them. On blur for schema validation; on load and on change for handed-in validations. Never only on submit.
- One primary button per form,
type="submit". - Two columns at most.
- Don't validate in the template or the page. Not
v-if="email && !email.includes('@')"; put it on the schema. - Don't skip
initialValues. - Don't put two controls in one
Field. See Field. - Don't add a wrapper card around a whole form. Sections are the cards — and inside a modal or drawer don't nest cards at all:
variant="plain". - Don't reach for the old
FormField/FormItem/FormLabel/FormMessageset for new forms — it predatesFieldand will be migrated onto it.
Related
- Field — the field, its parts, the context, and how a new control joins it
TextInput,Select,Combobox,DatePicker,Controls,Textarea- vee-validate · Zod