Skip to content

Field

Groups a label, a control, a description and an error message, and owns the validity state they all read.

View source View as Markdown

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.

App.vue
<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-focused and the aria-describedby chain, and a second one throws duplicate_field_control.
  • A control reports its initial value as it registers, which counts as a change, so onChange validation leaves an empty required field invalid before the user has touched it. We default to onBlur for that reason.
  • Give a second Field.Description or Field.Error its own id, or both take the field’s generated ID and aria-describedby lists it once.
  • In onSubmit mode the browser blocks its own submit event while a native control fails a constraint, so the field never hears it. Wrap the fields in Form, which sets novalidate for you, or set novalidate on your own <form>.

Anatomy

Assemble the parts around one control.

NameRequiredDescription
Field.Root true Renders a <div>. Owns the validity state every other part reads.
Field.LabelLabel, re-exported. Renders a <label> pointed at the control.
Field.ControlRenders an <input>. Only for native or third-party controls.
Field.DescriptionRenders a <p>, registered into the control’s aria-describedby.
Field.ErrorRenders a <div role="alert">. Only while the field is invalid.
Field.ValidityRenders 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

PropTypeDefault
id
stringgenerated
options
FieldOptionssee below
invalid
booleanundefined
dirty
booleanundefined
touched
booleanundefined

Options

OptionTypeDefault
name
stringundefined
validate
(value: unknown, formValues: Record<string, unknown>) => string | string[] | null | undefined | Promise<…>undefined
validationMode
'onSubmit' | 'onBlur' | 'onChange''onBlur'
validationDebounce
number0
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse

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

EmitPayload
validityChange
{ valid: boolean; messages: string[] }
update:dirty
boolean
update:touched
boolean

Slot props

The field state set, plus messages: string[] and pending: boolean.

Exposed

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

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

PropTypeDefault
id
stringthe field’s controlId
modelValue
string | numberundefined
defaultValue
string | numberundefined

Emits

EmitPayload
update:modelValue
string
focus
FocusEvent
blur
FocusEvent

Slot props

The field state set.

Field.Description

Renders a <p> and registers its ID into the control’s aria-describedby.

Props

PropTypeDefault
id
stringthe field’s descriptionId

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

PropTypeDefault
id
stringthe field’s errorId
match
keyof ValidityState | ((state: FieldValidityFlags) => boolean)undefined
forceMount
booleanfalse

Slot props

The field state set, plus messages: string[].

Field.Validity

Renders nothing of its own, only its slot.

Props

None.

Slot props

PropType
validity
FieldValidityFlags | null
errors
string[]
value
unknown
initialValue
unknown
pending
boolean

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.

KeyType
state
ComputedRef<FieldControlState>
messages
ComputedRef<Array<string>>
value
ComputedRef<unknown>
initialValue
ComputedRef<unknown>
validity
ComputedRef<FieldValidityFlags | null>
pending
ComputedRef<boolean>
validate
() => Promise<FieldValidityResult>
reset
() => void

Data attributes

Every part writes the field state set. data-scope tells them apart, and Field.Root gets data-field instead.

PartAttributeValue
all
data-disabled
true
all
data-readonly
true
all
data-required
true
all
data-valid
true
all
data-invalid
true
all
data-dirty
true
all
data-touched
true
all
data-filled
true
all
data-focused
true
all but Root
data-scope
label | control | description | error
Field.Root
data-field
the instance ID

CSS variables

None. Field renders no functional CSS.

Errors

Code
missing_field_context
duplicate_field_control
invalid_validation_mode

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.