Skip to content

Form

A form that validates every field it holds before it lets a submit through, and shows the errors your server sends back.

View source View as Markdown

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.

Sign up with [email protected] to see what a server error looks like.

App.vue
<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 name on Field.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 Field and Form validate on submit. Inside a Form the 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.

NameRequiredDescription
Form true Renders a <form novalidate>. Owns the fields and the server errors.
Field.RootRegisters 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

PropTypeDefault
id
stringgenerated
options
FormOptions{}
errors
Record<string, string | string[]>undefined

Options

OptionTypeDefault
validateOnSubmit
booleantrue
focusOnError
booleantrue
novalidate
booleantrue

Emits

EmitPayload
submit
SubmitEvent
update:errors
Record<string, string | string[]>
clearErrors
Record<string, string | string[]>

Slot props

PropType
invalid
boolean
submitting
boolean
errors
Record<string, string | string[]>

Exposed

MethodReturns
validate()
Promise<{ valid: boolean; invalid: string[] }>
reset()
void

Composable

useMirrorForm(id) reaches a form from anywhere in the app.

KeyType
errors
ComputedRef<FormErrors>
invalid
ComputedRef<boolean>
submitting
WritableComputedRef<boolean>
validate
() => Promise<FormValidity>
reset
() => void

Data attributes

PartAttributeValue
Form
data-form
the instance ID
Form
data-invalid
true
Form
data-submitting
true

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.

Keyboard

KeyBehaviour
EnterSubmits the form from any single-line control inside it, as a native form does.