Field
One field of a form: parts around one control.
html
<Field field-id="latitude" required :messages="messagesFor('latitude')">
<FieldLabel>Latitude</FieldLabel>
<FieldHint>Decimal degrees, WGS 84.</FieldHint>
<TextInput v-model="record.latitude" />
<FieldMessages />
</Field>Field owns the field's state (id, required, disabled, messages) and the layout; the parts and the control read that state from a context, so they know nothing about each other or about the layout. Where the messages come from and how fields are grouped is on the Forms page.
ts
import { Field, FieldLabel, FieldHint, FieldMessages } from '@scaler-tech/aurora/forms'
import type { FormFieldMessage } from '@scaler-tech/aurora/forms'Layout
Field is a CSS grid with named areas; every part places itself by name (label, hint, control, messages). The control area is FieldControl — the control plus whatever sits beside it that is not a control, such as an action button — or, without one, the one child that is not a part. Write the parts in any order; omit any you do not need (an empty area has no height).
html
<Field v-slot="{ componentField }" name="gross_floor_area" :messages="messagesFor('gross_floor_area')">
<FieldLabel>Gross floor area</FieldLabel>
<FieldControl>
<NumberInput v-bind="componentField" suffix="m²" />
<IconButton icon="clock" variant="tertiary" aria-label="History" @click="openHistory('gross_floor_area')" />
</FieldControl>
<FieldMessages />
</Field>| Layout | Areas |
|---|---|
stacked (default) | label / hint / control / messages, one column. A control with no label or hint above it sits flush at the top |
side (≥ md) | label / hint / messages stacked on the left, control on the right spanning the label and hint rows and vertically centred on them (no hint → centred on the label line). Messages are their own row, so they never move the control. One shared control column (minmax(0,16rem)), controls left-aligned, so consecutive fields share a left edge whatever the control is; the label column never shrinks below 8rem. Text-like controls fill the column (they are w-full), a toggle or a w-fit wrapper keeps its own width. Stacks below md |
none | a plain block — no grid. Arrange the parts in your own markup; the context still wires everything |
Inside a two-column FormSection every field shares the section's row tracks (subgrid), so label, control and messages rows line up across the two columns. A field without a hint next to one with a hint keeps its label right above its control (the label takes the hint row and sits at its bottom).
Classes passed through class are merged last: a class that changes display or the columns replaces the layout — use layout="none" for a custom arrangement instead.
How Field reaches the control
Field provides a context (id, messages and the most severe state, required, disabled, the message-list id, focusControl, touch). Every Aurora control reads it through useFieldControl and puts id, aria-invalid (errors only), aria-describedby, aria-required and the blur handler on its focusable element, and lights the matching state (missing, error or warning). Nothing is cloned, so the control can sit at any depth — a page-level CountrySelector wrapping SimpleSelect needs no passthrough props.
Precedence: a control's own state props add to the field's (an error prop inside a clean field lights the error; :error="false" cannot clear the field's); disabled and readonly outrank every validation state, so a disabled or read-only control keeps its own look and only the message shows. A control that sets its own id keeps it — and detaches from the label.
FieldLabel is the <label for=id>; FieldHint focuses the control on click as well. When the id landed on a wrapper element (a combobox, a radio group) the first focusable descendant is focused.
Making a control Field-aware
ts
const props = defineProps<FormStateProps & { /* yours */ }>() // error / warning / missing / success / disabled / readonly
const { rootClass, validationMarker, disabled, controlAttrs } = useFieldControl(props)| Return | Bind it where |
|---|---|
rootClass | the bordered box (border colour per state, focus, disabled background) |
validationMarker | <FormFieldValidationIcon :state="validationMarker" /> inside the box |
controlAttrs | v-bind on the focusable element (input, trigger button, switch), before $attrs so a consumer's explicit id wins. Carries id, aria-* and onBlur |
disabled | instead of props.disabled — it is the control's own prop OR the field's |
Rules: one root element per control (a fragment puts both roots in the control area); don't put data-field-part on a control; useFieldControl outside a Field is harmless — only the control's own props apply. Something that is not a control (a label with a tooltip, a counter) reads useFieldContext() directly. Radios are a group: put the id on the group wrapper, not on each input.
Binding to a vee-validate form
With name, Field binds itself to the surrounding useForm: the schema error becomes the message once the control was blurred (or the form submitted), the schema decides required, and the default slot hands the control its value binding:
html
<Field v-slot="{ componentField }" name="email">
<FieldLabel>Email</FieldLabel>
<TextInput v-bind="componentField" />
<FieldMessages />
</Field>Explicit messages / required add to the form's: both lists show, deduplicated by text. A handed-in message describes the value the form was reset to (the saved record): while the field is dirty it steps aside, and it returns when the value returns. A rejected submission goes through the form's setErrors, not messages. FieldMessages animates a change only while the form is dirty, so messages that arrive with the record settle in place without motion; outside a form every change animates. Without a form above it, name does nothing and the messages show as given. Blur is reported by the control through the context, so selects, comboboxes and date pickers validate on blur like text inputs. See Forms for the useForm setup.
Messages and state
messages is a list of { state, text }. Every message renders under the input, coloured by its state (FormMessage) by FieldMessages; children given to FieldMessages replace that default rendering. The control's own state (border + marker icon) follows the most severe one by this precedence: missing > error > warning > success. An empty list renders nothing. Aurora only knows state and text — where a message came from (rule engine, server, stored validation) is the page's concern, mapped before it reaches the field.
| State | Meaning |
|---|---|
missing | required data not provided |
error | value rejected; blocks save |
warning | value accepted, worth a look |
success | value confirmed by something the user cannot see (matched a registry, verified, saved) — use it sparingly; a plain valid field shows no state |
Field — API
| Prop | Type | Default | Description |
|---|---|---|---|
fieldId | string | generated | DOM id the control gets; FieldLabel's for points at it |
name | string | — | Binds to the vee-validate form above (messages, required, componentField slot prop). Also rendered as data-field-name on the root, so a page can find which fields a section holds |
required | boolean | false | FieldLabel renders the red asterisk; aria-required on the control |
disabled | boolean | false | Greys the parts, cursor-not-allowed, disables the control |
messages | FormFieldMessage[] | [] | { state, text } list; drives the control state and what FieldMessages renders. With name, the list shows only while the field is not dirty |
layout | 'stacked' | 'side' | 'none' | from FormSection, else 'stacked' | See Layout |
class | string | — | Merged onto the root with twMerge |
| Slot | Props | Description |
|---|---|---|
default | componentField, dirty (only with name) | The parts and exactly one control. dirty: the value differs from the one the form was reset to |
Parts
| Part | Renders | Props |
|---|---|---|
FieldLabel | <label for=id> with the required asterisk; default slot is the text | class |
FieldHint | description text, clicking it focuses the control | class |
FieldControl | the control area: a flex row holding the control and what sits beside it (an action button, a unit) | class |
FieldMessages | the message list (aria-describedby target); empty = one row per Field message, children replace them | class |
Incorrect / Correct
- Don't compute validation inside the page.
Fieldonly renders; the list comes from a validation layer. - Don't put two controls in one
Field. Both would answer to the one id and state.FieldControlholds one control plus things that are not controls (a button that opens history, a unit label) — see "Two controls that belong together" below. - Don't forget
<FieldMessages />. Without it the field's messages render nowhere (the dev build warns). - Don't set
idon a control inside aField. The control's ownidreplaces the field's, the label'sforthen points at nothing and clicking it does nothing. Set the id on the field withfieldId.
- Pass a stable
fieldIdwhen something needs to address the field (deep links, tests) — the db column name works; otherwise let it generate one.
Two controls that belong together
An amount with a unit, a range with from/to, a phone with a country code. The field context carries one id, one state and one message list, so two controls inside one Field both answer to the label and both light up. Two ways out:
- One composite control — when the pair is a single value to the user (amount + unit, range). Build a component that renders both inputs, calls
useFieldControlonce and bindscontrolAttrson the primary input; the secondary one gets its ownaria-label. The field sees one control, one message list, one state. - Two fields in a row — when each part needs its own label and messages (from / to dates). Put two
Fields in a flex row (or a two-columnFormSection); each owns its label, state and messages, and vee-validate gets two names.
Related
- Forms — sections, message sources, action row, the rules
TextInput,FormTextarea,Combobox— inputs that accept the state flags