Skip to content

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'

Account

Used to sign in and for the audit trail on every change you make.
Sets the default emission factors for the portfolio.
First day of the reporting year.

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 asteriskfrom the schema — a key that is not .optional() / .nullable() marks its label
when messages showafter the control was blurred, or for every field at once on submit; they clear as soon as the value is valid
control wiringid, label for, aria-invalid, aria-required, blur — through the Field context, for every Aurora control
submithandleSubmit blocks invalid submits, marks every field touched, hands you the schema's output type
resetresetForm() restores initialValues and clears every message

Gotchas ​

initialValuesAlways pass them. Without them an empty field fails Zod's type check before your rule runs and every message reads "Required"
DatePickerIts 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()
numbersTextInput type="number" emits strings; NumberInput holds number | null, so z.number().nullable() binds without a transform
idsSimpleSelect / SimpleCombobox item value may be a number and the model keeps it — no String(id) round-trip
ComboboxEmits 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 errorssetFieldError('email', 'Already taken') from useForm renders through the same FieldMessages
name without a formdoes 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

At least 12 characters.

Login, quick edit, modals. One column, label above the control. The default.

Two columns — dense sections

Address

Format differs from the country default.
Resolved from the postal code when possible.

Short stacked fields, rows aligned across the columns. Two is the maximum.

Side — long record forms

Location

ISO 3166 country the asset is located in. Drives the default emission factors and the climate zone suggestions.
Country is required.
Decimal degrees, WGS 84.
Latitude must be between -90 and 90.
Looks unusual for this country.
Inactive assets are excluded from reports.
Free text, shown to reviewers.

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

Required data not provided. Not an error yet.
Country is required.
Value rejected; blocks save.
Latitude must be between -90 and 90.
Accepted, worth a look.
Format differs from the country default.
Confirmed by something the user cannot see.
Matched a registered meter.
The whole field, parts included.
A plain valid field shows no state at all.
Use when
stackedshort flows — login, quick edit, modals. Default
columns="2"dense sections of short stacked fields; rows align across the columns. Two is the maximum
sidelong record forms whose hints are worth reading; label, hint and messages left, control right, one left edge for every control

Anatomy ​

PieceWhat it does
FormSectionA titled card around a group of fields; sets their layout and column count
FieldOne field: owns id, required, disabled, messages and the layout; binds to the form by name; wires the control through a context
FieldLabel, FieldHint, FieldControl, FieldMessagesThe field's parts; read the Field context. FieldControl names the control area and holds what sits beside the control
the controlAny Aurora input — TextInput, Select, Combobox, DatePicker, Toggle, FormTextarea — or a page component wrapping one
action rowButton primary + secondary / tertiary

FormSection — API ​

PropTypeDefaultDescription
titlestring—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)
columns1 | 21Field grid columns; one column below md
fieldLayout'stacked' | 'side' | 'none''stacked'Layout every Field inside uses unless it sets its own layout
classstring—Merged onto the <section>
ExposesTypeDescription
elHTMLElement | nullThe <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/FormMessage set for new forms — it predates Field and will be migrated onto it.