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.
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>
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.
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.