Field
Groups a label, a control, a description and an error message, and owns the validity state they all read.
Wrap a control in Field and it picks up a label, a description, an error
message and the validity state behind them. Reach for it whenever a control
needs a name, a hint, or something to say when the value is wrong.
Three characters or more. Validated when you leave the field.
<template>
<Field.Root
class="group flex w-[17rem] flex-col gap-1.5"
:options="{ name: 'username', validate: validateUsername }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="username"
:class="control"
placeholder=" "
autocomplete="off"
/>
</span>
<Label :class="label">Username</Label>
</div>
</div>
<Field.Description :class="hint">
Three characters or more. Validated when you leave the field.
</Field.Description>
<Field.Error v-slot="{ messages }" :class="error">
{{ messages.join(' ') }}
</Field.Error>
</Field.Root>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Field, Input, Label } from '@maas/mirror/vue'
const username = ref('')
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',
'group-data-[disabled=true]:border-disabled-subtle group-data-[disabled=true]:cursor-not-allowed',
].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',
'data-[disabled=true]:text-disabled-solid',
].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',
'group-data-[disabled=true]:text-disabled-muted',
].join(' ')
const hint = 'type-component-2xs text-surface-muted'
const error = 'type-component-2xs text-danger-muted'
function validateUsername(value: unknown) {
const entry = String(value ?? '')
if (entry.length === 0) {
return 'Enter a username.'
}
return entry.length < 3 ? 'Use at least three characters.' : null
}
</script>
Usage guidelines
- Only one control may register per field, since it owns
data-focusedand thearia-describedbychain, and a second one throwsduplicate_field_control. - A control reports its initial value as it registers, which counts as a change,
so
onChangevalidation leaves an empty required field invalid before the user has touched it. We default toonBlurfor that reason. - Give a second
Field.DescriptionorField.Errorits ownid, or both take the field’s generated ID andaria-describedbylists it once. - In
onSubmitmode the browser blocks its ownsubmitevent while a native control fails a constraint, so the field never hears it. Wrap the fields inForm, which setsnovalidatefor you, or setnovalidateon your own<form>.
Anatomy
Assemble the parts around one control.
| Name | Required | Description |
|---|---|---|
| Field.Root | true | Renders a <div>. Owns the validity state every other part reads. |
| Field.Label | Label, re-exported. Renders a <label> pointed at the control. | |
| Field.Control | Renders an <input>. Only for native or third-party controls. | |
| Field.Description | Renders a <p>, registered into the control’s aria-describedby. | |
| Field.Error | Renders a <div role="alert">. Only while the field is invalid. | |
| Field.Validity | Renders nothing. Hands the validity flags and the values to its slot. |
<script setup lang="ts">
import { Field, Input } from '@maas/mirror/vue'
</script>
<template>
<Field.Root :options="{ name: 'email', validate: validateEmail }">
<Field.Label>Email</Field.Label>
<Input v-model="email" type="email" />
<Field.Description>We only use this to send receipts.</Field.Description>
<Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
</Field.Root>
</template>
Examples
Wiring a control
A Mirror control joins the surrounding field on its own: it takes the field’s
controlId, inherits name, disabled, readOnly and required, and writes
the field state set onto its own element.
<template>
<Field.Root :options="{ name: 'terms', disabled: true, required: true }">
<Field.Label>Accept the terms</Field.Label>
<Checkbox.Root v-model="accepted">
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
</Field.Root>
</template>
A prop on the control wins over the field, so one control can opt out.
<template>
<Field.Root :options="{ name: 'notes', required: true }">
<Field.Label>Notes</Field.Label>
<Textarea v-model="notes" :required="false" />
</Field.Root>
</template>
For a native or third-party control, Field.Control hands the association to
its child under asChild. It follows what was typed whether or not you bind
v-model, so data-dirty, data-filled and validate see the value either
way. Wiring up a control of your own is covered in
Form integration.
<template>
<Field.Root :options="{ name: 'colour' }">
<Field.Label>Colour</Field.Label>
<Field.Control as-child>
<input v-model="colour" type="color" />
</Field.Control>
</Field.Root>
</template>
Validation
validate receives the value the control last reported and the values of every
field registered with the surrounding Form, keyed by name.
It returns a message, an array of messages, or null for a pass, and it may be
async.
<template>
<Field.Root :options="{ name: 'handle', validate: checkHandle }">
<Field.Label>Handle</Field.Label>
<Input v-model="handle" />
<Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
</Field.Root>
</template>
<script setup lang="ts">
const handle = ref('')
async function checkHandle(value: unknown) {
const taken = await isTaken(String(value ?? ''))
return taken ? 'That handle is taken.' : null
}
</script>
validationMode decides when it runs, and validationDebounce puts a delay in
front of the onChange mode.
<template>
<Field.Root
:options="{
name: 'search',
validate: validateSearch,
validationMode: 'onChange',
validationDebounce: 300,
}"
>
<Field.Label>Search</Field.Label>
<Input v-model="query" />
</Field.Root>
</template>
The second argument is how one field reads another, and it is filled in by the
surrounding Form. Outside one it is an empty object.
<template>
<Form>
<Field.Root :options="{ name: 'password' }">
<Field.Label>Password</Field.Label>
<Input v-model="password" type="password" />
</Field.Root>
<Field.Root :options="{ name: 'confirm', validate: matchPassword }">
<Field.Label>Repeat password</Field.Label>
<Input v-model="confirm" type="password" />
</Field.Root>
</Form>
</template>
<script setup lang="ts">
function matchPassword(value: unknown, formValues: Record<string, unknown>) {
return value === formValues.password ? null : 'The two do not match.'
}
</script>
An async validate that resolves after a newer run has started is dropped, so
the slower answer never overwrites the newer one. While a run is in flight
the root publishes pending.
Every mode also validates when the closest <form> submits, though the field
never calls preventDefault, so blocking the submission stays with the form.
In onSubmit mode a field that has run once keeps running on every later
change, so a message the reader is looking at follows the edit that fixes it.
Each run reads native constraint validation too, prepending the control’s
validationMessage to whatever validate returned. To validate from the form
itself, keep a template ref on the root and call validate().
<template>
<form @submit.prevent="submit">
<Field.Root
ref="field"
:options="{
name: 'email',
validate: validateEmail,
validationMode: 'onSubmit',
}"
>
<Field.Label>Email</Field.Label>
<Input v-model="email" />
</Field.Root>
<Button type="submit">Save</Button>
</form>
</template>
<script setup lang="ts">
const field = useTemplateRef('field')
async function submit() {
const { valid } = await field.value.validate()
if (valid) {
await save()
}
}
</script>
A browser blocks its own submit event while a native control fails a
constraint, which is the one moment onSubmit mode needs it. Form renders
novalidate by default and validates every field it holds, so the field hears
the submission and the native ValidityState still reaches Field.Error. On a
plain <form>, set novalidate yourself.
<template>
<Form>
<Field.Root :options="{ name: 'email', validationMode: 'onSubmit' }">
<Field.Label>Email</Field.Label>
<Input v-model="email" type="email" required />
<Field.Error match="valueMissing">Enter an email address.</Field.Error>
</Field.Root>
</Form>
</template>
Error messages
Field.Error renders a <div role="alert"> once the field is invalid, so a
message appearing after blur is announced without moving focus.
<template>
<Field.Error v-slot="{ messages }">
<ul>
<li v-for="message in messages" :key="message">{{ message }}</li>
</ul>
</Field.Error>
</template>
match narrows an error to one native ValidityState flag, or to a predicate
over the whole snapshot the last run took. A message from validate sets
customError, so an error can separate your own rules from the browser’s.
<template>
<Field.Root :options="{ name: 'age', required: true }">
<Field.Label>Age</Field.Label>
<Input v-model="age" type="number" min="18" />
<Field.Error id="age-missing" match="valueMissing">
Enter your age.
</Field.Error>
<Field.Error id="age-range" match="rangeUnderflow">
You must be 18 or older.
</Field.Error>
</Field.Root>
</template>
If you want to transition the message yourself, forceMount keeps the node
mounted whatever the validity.
<template>
<Field.Error v-slot="{ invalid, messages }" force-mount>
<Transition name="message">
<p v-if="invalid">{{ messages.join(' ') }}</p>
</Transition>
</Field.Error>
</template>
Reading the validity
Field.Validity renders nothing at all. It hands the flags of the last run,
the messages, the current value and the value the field started with to its
slot, which is where a summary or a debug panel gets them from.
<template>
<Field.Root :options="{ name: 'age', validate: checkAge }">
<Field.Label>Age</Field.Label>
<Input v-model="age" type="number" min="18" required />
<Field.Validity v-slot="{ validity, errors, pending }">
<p v-if="pending">Checking…</p>
<p v-else-if="validity?.rangeUnderflow">Too young.</p>
<p v-else-if="errors.length">{{ errors.join(' ') }}</p>
</Field.Validity>
</Field.Root>
</template>
The flags are a snapshot rather than the live ValidityState of the element,
so every run publishes a new one and a template that reads them keeps up.
Forcing and observing state
invalid overrides the outcome of validation entirely, which is how a response
from the server gets in.
<template>
<Field.Root :invalid="!!serverError" :options="{ name: 'email' }">
<Field.Label>Email</Field.Label>
<Input v-model="email" />
<Field.Error>{{ serverError }}</Field.Error>
</Field.Root>
</template>
dirty and touched are overrides rather than two-way bindings, so passing one
stops the field deriving it.
<template>
<Field.Root
:options="{ name: 'email' }"
@validity-change="onValidityChange"
@update:touched="onTouched"
>
<Field.Label>Email</Field.Label>
<Input v-model="email" />
</Field.Root>
</template>
Styling from state
Every part writes the same field state set, so the label, the description and the control all react to the field’s validity without a wrapper class.
<template>
<Field.Root :options="{ name: 'email', validate: validateEmail }">
<Field.Label>Email</Field.Label>
<Input v-model="email" />
<Field.Description>We only use this to send receipts.</Field.Description>
<Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
</Field.Root>
</template>
<style>
.mirror-label[data-invalid='true'] {
color: var(--app-color-danger-fg-muted);
}
.mirror-input[data-invalid='true'] {
border-color: var(--app-color-danger-border-subtle);
}
.mirror-field-description[data-disabled='true'] {
color: var(--app-color-disabled-fg-muted);
}
</style>
The same values arrive as slot props where the template has to branch.
<template>
<Field.Root v-slot="{ invalid, touched }" :options="{ name: 'email' }">
<Field.Label>Email</Field.Label>
<Input v-model="email" />
<span v-if="touched && !invalid" aria-hidden="true">✓</span>
</Field.Root>
</template>
Reaching a field from elsewhere
Give a field an id and useMirrorField(id) reads and drives it from anywhere
in the app, with no template ref and no nesting.
<script setup lang="ts">
import { useMirrorField } from '@maas/mirror/vue'
const field = useMirrorField('signup-email')
async function submit() {
const { valid, messages } = await field.validate()
if (!valid) {
report(messages)
}
}
</script>
<template>
<Field.Root id="signup-email" :options="{ name: 'email', validate }">
<Field.Label>Email</Field.Label>
<Input v-model="email" />
</Field.Root>
<Button @click="submit">Save</Button>
</template>
API reference
Module. A bundled options object, and useMirrorField(id) as the
programmatic API. Field.Label is Label, re-exported
under this namespace.
Field.Root
Renders a <div>.
Props
Options
invalid, dirty and touched stay props because they are state a consumer
takes over rather than configuration: the field derives all three on its own
until one is passed.
Emits
Slot props
The field state set, plus
messages: string[] and pending: boolean.
Exposed
Field.Control
Renders an <input>, for native or third-party controls. name, disabled,
readOnly and required come from Field.Root as native attributes rather
than as props here.
Props
Emits
Slot props
The field state set.
Field.Description
Renders a <p> and registers its ID into the control’s aria-describedby.
Props
Slot props
The field state set.
Field.Error
Renders a <div role="alert"> while the field is invalid, registering its ID
into aria-describedby for as long as it is rendered.
Props
Slot props
The field state set, plus messages: string[].
Field.Validity
Renders nothing of its own, only its slot.
Props
None.
Slot props
Field.Label
Label, re-exported. Its props, slot props and errors are
documented there.
Composable
useMirrorField(id) reaches a field from anywhere in the app.
Data attributes
Every part writes the field state set. data-scope
tells them apart, and Field.Root gets data-field instead.
CSS variables
None. Field renders no functional CSS.
Errors
Accessibility
Field sets up relationships rather than keyboard behaviour. The label gets
for, the control gets aria-describedby, aria-invalid and aria-required,
and the error is a role="alert".
The aria-describedby list runs descriptions first and errors after, and an
error that is not rendered is not registered. aria-disabled appears only where
the rendered element has no native disabled to set, aria-readonly follows
the read-only state, and a control that cannot be the target of a <label for>,
or whose label rendered as a <span>, is named through aria-labelledby
instead.