Skip to content

Form integration

Field owns a control’s relationship to the form. Read this for the validation modes, and for how a control of your own joins in.

View source View as Markdown

Field.Root owns one control’s relationship to the form: its name, its validity, and the label, description and error around it.

<script setup lang="ts">
import { Field, Input } from '@maas/mirror/vue'

const email = ref('')
</script>

<template>
  <Field.Root :options="{ name: 'email' }">
    <Field.Label>Email</Field.Label>
    <Field.Description>We only use it to sign you in.</Field.Description>
    <Input v-model="email" type="email" required />
    <Field.Error>Enter a valid email address.</Field.Error>
  </Field.Root>
</template>

Field.Label is the Label component re-exported, so a field’s anatomy reads in one piece.

Validation modes

Validation is configured through the root’s bundled options object.

<template>
  <Field.Root
    :options="{
      name: 'email',
      validate: validateEmail,
      validationMode: 'onChange',
      validationDebounce: 200,
    }"
  />
</template>
OptionDefault
validate
none
validationMode
'onBlur'
validationDebounce
0

Submitting a surrounding <form> always runs validation, whatever the mode is set to, since the mode only decides what happens before submission. Once an 'onSubmit' field has run, it keeps running on every later change, so a message already on screen updates as the reader fixes the value.

validate receives a second argument: the values of every field registered with the surrounding Form, keyed by name. Outside a Form it is an empty object.

<script setup lang="ts">
function matchPassword(value: unknown, formValues: Record<string, unknown>) {
  return value === formValues.password ? null : 'The two do not match.'
}
</script>

Native constraint validation

Field finds the native control inside itself and reads its ValidityState, so required, type="email", min, maxlength and the rest keep working with no configuration.

A browser refuses to fire submit while one of those constraints fails, and 'onSubmit' mode depends on that event. Form renders novalidate and validates its fields itself, so the run happens and the native flags reach Field.Error. On a plain <form>, set novalidate yourself, or the field never receives the submit event.

<template>
  <form novalidate @submit.prevent="submit">
    <Field.Root :options="{ name: 'email', validationMode: 'onSubmit' }">
      <Label>Email</Label>
      <Input v-model="email" type="email" required />
      <Field.Error match="valueMissing">Enter an email address.</Field.Error>
    </Field.Root>
  </form>
</template>

Native messages come first and whatever validate returns follows, both in the same array.

<template>
  <Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
</template>

To render one error per failure reason, match a single constraint with the match prop, which takes a ValidityState key or a predicate over the snapshot the last run took. A message from validate sets customError.

<template>
  <Field.Error match="valueMissing">Enter an email address.</Field.Error>
  <Field.Error match="typeMismatch">That is not an email address.</Field.Error>
</template>

Styling from field state

Every control and every Field part writes the same state attributes, so a label can be styled from the control’s validity with no wrapper class.

[data-scope='label'][data-invalid='true'] {
  color: var(--app-color-danger-fg-muted);
}

The full table is in Styling.

Form participation

Controls that are not a native input render a visually hidden input, so submission, reset and FormData work with no wiring from you.

ComponentHidden input
CheckboxOne checkbox, plus a hidden input carrying uncheckedValue when set.
RadioOne radio per registered item.
SwitchOne checkbox.
ToggleOne checkbox, only with a name.
Select, ComboboxOne hidden input per selected value, only with a name.
SliderOne range input per thumb. A range slider submits two values under one name.

Input, Textarea and Field.Control render the native element directly and need none of this, while Progress, Avatar and Label take no part in forms.

readOnly is a state rather than a submission rule, so the hidden input gets no readonly attribute and a read-only control still submits. Only disabled takes a control out of the payload.

Joining your own control

Wrap your control in Field.Control with asChild, so the part renders no element of its own and merges onto the single child you give it instead. The field passes down id, name, disabled, readonly, required and value, binds focus, blur and input, and adds the state and aria-* attributes.

<template>
  <Field.Root :options="{ name: 'colour' }">
    <Field.Label>Colour</Field.Label>

    <Field.Control as-child>
      <app-colour-picker v-model="colour" />
    </Field.Control>

    <Field.Error />
  </Field.Root>
</template>

Only one control registers per field, and a second one raises duplicate_field_control rather than quietly taking over.

To drive a field from outside its subtree, give the root an id and read it back with useMirrorField.

<script setup lang="ts">
import { useMirrorField } from '@maas/mirror/vue'

const { state, messages, value, validity, pending, validate, reset } =
  useMirrorField('email')
</script>

Field.Validity does the same job inside the field’s own subtree: it renders nothing and hands the flags, the messages and the values to its slot.

Further reading

  • Field: the full anatomy, props and errors.
  • Styling: the four places every part leaves the appearance to you.