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>
<Field.Label :class="label">Email</Field.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>
<Field.Label :class="label">Password</Field.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 } from '@maas/mirror/vue/Button'
import { Field } from '@maas/mirror/vue/Field'
import { Form } from '@maas/mirror/vue/Form'
import { Input } from '@maas/mirror/vue/Input'
import type { FormErrors } from '@maas/mirror/vue/Form'
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.must - 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.should - Both
FieldandFormvalidate on submit. Inside aFormthe field steps back and lets the form drive, so a validator never runs twice for one submit.may - 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.should