Form
A form that validates every field it holds before it lets a submit through, and shows the errors your server sends back.
Form wraps a set of Fields and takes care of the two things a plain <form>
leaves to you: it runs every field’s validation before the submit goes
anywhere, and it hands the errors your server sends back to the field each one
belongs to. Reach for it whenever a form has more than one field, or talks to a
server that can disagree with it.
<template>
<Form
v-model:errors="errors"
class="flex w-[17rem] flex-col gap-5"
@submit="onSubmit"
>
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'email', validate: validateEmail }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="email"
:class="control"
type="email"
placeholder=" "
autocomplete="email"
/>
</span>
<Label :class="label">Email</Label>
</div>
</div>
<Field.Error v-slot="{ messages }" :class="error">
{{ messages.join(' ') }}
</Field.Error>
</Field.Root>
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'password', validate: validatePassword }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="password"
:class="control"
type="password"
placeholder=" "
autocomplete="new-password"
/>
</span>
<Label :class="label">Password</Label>
</div>
</div>
<Field.Error v-slot="{ messages }" :class="error">
{{ messages.join(' ') }}
</Field.Error>
</Field.Root>
<Button :class="button" type="submit" :loading="pending">
{{ pending ? 'Creating account…' : 'Create account' }}
</Button>
<p :class="hint">
Sign up with [email protected] to see what a server error looks like.
</p>
</Form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Field, Form, Input, Label } from '@maas/mirror/vue'
import type { FormErrors } from '@maas/mirror/vue'
const email = ref('')
const password = ref('')
const pending = ref(false)
const errors = ref<FormErrors>({})
async function onSubmit(event: SubmitEvent) {
const data = new FormData(event.target as HTMLFormElement)
pending.value = true
errors.value = await signUp(String(data.get('email') ?? ''))
pending.value = false
}
function signUp(address: string): Promise<FormErrors> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(
address === '[email protected]'
? { email: 'That email is already taken.' }
: {}
)
}, 600)
})
}
function validateEmail(value: unknown) {
const entry = String(value ?? '')
if (entry.length === 0) {
return 'Enter an email address.'
}
return entry.includes('@') ? null : 'Enter a valid email address.'
}
function validatePassword(value: unknown) {
return String(value ?? '').length >= 8
? null
: 'Use eight characters or more.'
}
const box = [
'relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5',
'rounded-component-lg border-surface border-2 px-[0.875rem]',
'transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
'outline-4 outline-transparent focus-within:focus-ring',
'group-[[data-invalid=true]:not([data-focused=true])]:border-danger-subtle',
'group-[[data-invalid=true]:not([data-focused=true])]:bg-danger-subtle',
].join(' ')
const stack = [
'group/stack flex h-full max-h-full w-full flex-col-reverse',
'items-center justify-center gap-0 px-1',
'focus-within:gap-1 has-[input:not(:placeholder-shown)]:gap-1',
].join(' ')
const line = [
'relative flex h-0 w-full items-end',
'group-focus-within/stack:h-[0.9375rem]',
'has-[input:not(:placeholder-shown)]:h-[0.9375rem]',
].join(' ')
const control = [
'type-component-lg leading-[normal]! text-surface',
'block box-content h-[1lh] w-full py-1.25 -my-1.25 appearance-none bg-transparent outline-none',
'placeholder:text-transparent',
'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-on-muted',
].join(' ')
const label = [
'type-component-lg leading-[normal]! text-surface-muted',
'flex h-full w-full items-center [--mirror-label-cursor:text]',
"before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem] group-focus-within/stack:[--mirror-label-cursor:auto]',
'group-has-[input:not(:placeholder-shown)]/stack:h-auto group-has-[input:not(:placeholder-shown)]/stack:text-[0.6875rem] group-has-[input:not(:placeholder-shown)]/stack:[--mirror-label-cursor:auto]',
'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-muted',
].join(' ')
const button = [
'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
'rounded-component-lg border-2 border-[transparent] type-component-lg',
'bg-primary-solid text-primary-on-solid',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:bg-primary-solid-hover active:bg-primary-solid-active',
'data-[loading=true]:bg-disabled-solid data-[loading=true]:text-disabled-on-solid',
].join(' ')
const hint = 'type-component-2xs text-surface-muted'
const error = 'type-component-2xs text-danger-muted'
</script>
Signing up with [email protected] fails on the server, and the message clears
again as soon as the email changes.
Usage guidelines
- Errors are keyed by the
nameonField.Root, so a field without a name cannot be told anything. Name every field that talks to a server. - The form always calls
preventDefault(), because validation can be asynchronous and the browser would otherwise navigate before the answer arrives. Read the values off the event in your handler, or off your own model. - Both
FieldandFormvalidate on submit. Inside aFormthe field steps back and lets the form drive, so a validator never runs twice for one submit. - A field that fails through the server still counts as valid to the form, since the message did not come from a validator. Submit to the server again rather than reading the form’s own validity.
Anatomy
One form around any number of fields.
| Name | Required | Description |
|---|---|---|
| Form | true | Renders a <form novalidate>. Owns the fields and the server errors. |
| Field.Root | Registers itself with the form around it. Any number of them. |
<script setup lang="ts">
import { Button, Field, Form, Input, Label } from '@maas/mirror/vue'
</script>
<template>
<Form v-model:errors="errors" @submit="onSubmit">
<Field.Root :options="{ name: 'email', validate: validateEmail }">
<Label>Email</Label>
<Input v-model="email" type="email" />
<Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
</Field.Root>
<Button type="submit">Create account</Button>
</Form>
</template>
Examples
What a submit does
A submit runs every field’s validation, waits for all of them, and only emits
submit once they all pass. If any of them fails, the form sends focus to the
first one that did and the event never leaves the component.
<template>
<Form @submit="onSubmit">
<Field.Root :options="{ name: 'email', validate: validateEmail }">
<Label>Email</Label>
<Input v-model="email" type="email" />
</Field.Root>
<Button type="submit">Create account</Button>
</Form>
</template>
<script setup lang="ts">
function onSubmit(event: SubmitEvent) {
const data = new FormData(event.target as HTMLFormElement)
console.log(data.get('email'))
}
</script>
Fields answer in the order they appear in the document, whatever order they mount in, so the focus always lands on the first problem the reader can see.
Errors from the server
errors is an object keyed by field name, and each value is one message or
several. The matching Field.Error renders it exactly as if a validator had
returned it.
<template>
<Form v-model:errors="errors" @submit="onSubmit">
<Field.Root :options="{ name: 'email' }">
<Label>Email</Label>
<Input v-model="email" type="email" />
<Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
</Field.Root>
</Form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { FormErrors } from '@maas/mirror/vue'
const email = ref('')
const errors = ref<FormErrors>({})
async function onSubmit() {
errors.value = await signUp(email.value)
}
</script>
The message belongs to the value that produced it, so the first keystroke in
that field takes it off again. That is what v-model:errors is for: the form
hands back the object with the key removed. If you would rather keep the object
yourself, listen for clearErrors, which carries the same payload.
An error keyed to a name no field claims stays in the object and renders nowhere, so a server that reports on fields the current step does not show breaks nothing.
Validating without submitting
useMirrorForm(id) reaches the form from anywhere, which is what a wizard needs
when the button that moves to the next step is not a submit button.
<script setup lang="ts">
import { useMirrorForm } from '@maas/mirror/vue'
const { validate, invalid, submitting } = useMirrorForm('sign-up')
async function next() {
const { valid } = await validate()
if (valid) {
step.value++
}
}
</script>
<template>
<Form id="sign-up">
<Field.Root :options="{ name: 'email', validate: validateEmail }">
<Label>Email</Label>
<Input v-model="email" type="email" />
</Field.Root>
</Form>
<Button :disabled="invalid" :loading="submitting" @click="next">Next</Button>
</template>
validate() reports which fields failed by name, so a summary above the form
can list them.
const { valid, invalid } = await validate()
// { valid: false, invalid: ['email', 'password'] }
Starting over
reset() clears the messages, the run history and the server errors, and a
native <button type="reset"> does the same thing. Values belong to your model,
so neither of them touches those.
<template>
<Form @submit="onSubmit">
<Field.Root :options="{ name: 'email', validate: validateEmail }">
<Label>Email</Label>
<Input v-model="email" type="email" />
</Field.Root>
<Button type="submit">Create account</Button>
<Button type="reset" @click="email = ''">Start over</Button>
</Form>
</template>
Showing that something is happening
submitting is on while the form validates, and it is yours to hold on for as
long as the request takes.
<script setup lang="ts">
import { useMirrorForm } from '@maas/mirror/vue'
const { submitting } = useMirrorForm('sign-up')
async function onSubmit() {
submitting.value = true
errors.value = await signUp(email.value)
submitting.value = false
}
</script>
<template>
<Form id="sign-up" v-model:errors="errors" @submit="onSubmit">
<Button type="submit" :loading="submitting">Create account</Button>
</Form>
</template>
The same state is on the element as data-submitting and in the default slot,
so a spinner somewhere inside the form needs neither the composable nor an ID.
<template>
<Form v-slot="{ submitting, invalid }">
<span v-if="submitting">Checking…</span>
</Form>
</template>
API reference
Standalone in shape, module underneath. One part, a bundled options
object, and useMirrorForm(id) as the programmatic API. Field registers with
the form it finds around it and needs no wiring of its own.
Form
Renders a <form novalidate>.
Props
Options
Emits
Slot props
Exposed
Composable
useMirrorForm(id) reaches a form from anywhere in the app.
Data attributes
CSS variables
None. Form renders no functional CSS.
Errors
None of its own. Form has one part and asserts nothing, so the only codes you
can see from it are the rack’s.
Accessibility
Form renders a native <form> and adds no roles. novalidate turns off the
browser’s own validation bubbles, which cannot be styled and disappear on the
next keystroke, and hands the job to Field.Error, which is a role="alert"
and is announced where it sits.
On a failed submit the form sends focus to the first control that failed, in
document order, so the reader lands on the problem rather than at the top of the
page. focusOnError turns that off if you would rather move focus somewhere
else yourself.