Components / forms
Field
The id is required rather than generated, because a consumer who forgets it gets a visibly labelled control with no accessible name at all — the worst combination, since it looks correct.
Summary
Wraps any single control with its label and one message.
| APG pattern | none |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
The label is part of the component, not a caption you remember to add.
<Field id="domain" label="Custom domain" description="Point a CNAME at studioedit.app">
<input type="text" placeholder="maya.studio" />
</Field>
Anatomy
A <div data-se="field"> holding a <label>, the caller's control, and one message.
| Part | Element | Required |
|---|---|---|
adornment | <span> | no |
control | the caller's | the caller's |
control-wrap | <div> | yes |
description | the caller's | the caller's |
error | the caller's | the caller's |
label | <label> | no |
The control is children, because nothing about it touches the field's contract.
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
id | string | required | Wired to the control's id, the label's htmlFor and aria-describedby. |
label | string | none | The visible label, and the accessible name. A field without one is a control a screen reader can find and cannot describe. |
description | string | none | Helper text. Replaced by error when that is set. |
error | string | none | Sets data-invalid, aria-invalid and shows the message in --se-negative. |
valid | boolean | none | Tints the adornment --se-positive. For confirmed values, not merely non-empty ones. |
disabled | boolean | none | Passed through to the control. Prefer read-only where the value still matters to the reader — a disabled field is skipped by the keyboard entirely. |
readOnly | boolean | none | Not editable, still focusable, full text contrast — not the same as disabled. May be set here or as readOnly on the control; either sets data-readonly on the wrapper so the label and message can respond. |
adornment | React.ReactNode | none | Inside the control's trailing edge. An <Icon> reports state; an icon-only Button (size="sm", emphasis="transparent") acts on the value — reveal, clear, copy — and takes the click. Anything else is decoration. |
children | React.ReactNode | required | The control: <input>, <select> or <textarea>. |
States
| State | Expressed as | |
|---|---|---|
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Disabled | data-disabled | |
| Invalid | data-invalid | |
| Valid | data-valid | |
| Read-only | readonly |
Behavior
An error replaces the description rather than joining it. Two messages under one control is a reader deciding which one is current.
Accessibility
aria-describedby for a description, aria-errormessage for an error. A describedby message is read on every visit; an error message is read as an error. Emitting only the first was a real defect found by the upstream port.
aria-invalid is set alongside, so the state and the message arrive together.
Textarea is the documented exemption from the control height token: it takes block-size: auto, because a fixed height is what a textarea exists not to have.
The message row keeps its height when empty, and that alone is what aligns a row of fields: every control shares a baseline and every field ends at the same depth, whether or not it has something to say.
Keyboard
None of its own. The control inside keeps every key it had.
Roles, states and properties
_None written. The elements carry their own._
Focus
The label is a real <label>, so clicking it moves focus to the control. The field itself is never a stop.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/date-field.test.jsxtest/apg/field-prop-merging.test.jsxtest/browser/upstream-forms-menubar.spec.mjs
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-accent | border-color |
--se-border | border-color |
--se-border-hover | border-color |
--se-border-strong | border-color |
--se-border-width | border-width |
--se-control-h | block-size |
--se-control-h-sm | padding-inline-end |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-width | outline, outline-offset |
--se-icon-sm | block-size, inline-size, padding-inline-end |
--se-leading-body | line-height |
--se-leading-ui | line-height |
--se-negative | border-color, color |
--se-positive | color |
--se-space-1 | gap, margin-block-start, padding |
--se-space-2 | inset-inline-end, padding-inline-end |
--se-space-3 | inset-inline-end, padding, padding-block, padding-inline, padding-inline-end |
--se-surface | background |
--se-surface-readonly | background |
--se-surface-sunk | background |
--se-target-min | block-size |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-placeholder | color |
--se-text-sm | font-size |
--se-weight-medium | font-weight |
An empty input on a card is identified by a 1.52:1 border alone, which is below the 3:1 WCAG floor. That waiver is on the record in docs/audit/DEFERRED.md; it needs either a darker --se-border-strong or a sunk fill, and both are the design owner's call.
Motion
Border color crosses --se-duration-instant on focus. The message does not animate in — an error that slides into place moves the control the reader is about to correct.
Content
The label names the value, not the act. Custom domain, not Enter your custom domain.
A description explains; a placeholder does not. A placeholder disappears the moment the reader starts typing, which is exactly when they need it.
Placeholder and description share one color, on purpose. --se-text-placeholder resolves to --se-text-muted: two text tiers is the contract's ceiling, and what tells a hint from a description is position and persistence — inside the control and gone at the first keystroke, or below it and permanent — never a third grey. The separate name is a seam for a skin that wants to split them, and check:contrast holds whatever value lands there to the same AA floor.
Errors say what happened and how to fix it. Domains cannot contain spaces — not Invalid input.
Usage
The trailing adornment is the only icon position that earns its place. It reports state the label cannot — valid, invalid, locked — or offers an action on the value itself: clear, reveal, copy. The control gains end padding so text never runs under it. An <Icon> there is pointer-events: none; a Button there takes the click, and the control makes room for it. Make it icon-only, size="sm", emphasis="transparent", with a label that names the act — "Show password", "Clear", "Copy".
Never put an icon in front of the text: it repeats the label, and the reserved empty slot it needs leaves an indent that reads as a mistake on every field without one.
One control per Field. A field wrapping two inputs has one label for two values, and a screen reader reads it for both.
When not to use it. For a group of related controls that share one question — that is RadioGroup or CheckboxGroup, which own a fieldset and a legend.
For a value picked from a list, use Combobox. That is this system's dropdown, and it is filed under selection as well as forms. There is no Select component and there will not be one: a native <select> cannot style or enrich its options, so ARCHITECTURE.md 2.17 records the gap rather than shipping something that looks styled and is not. Combobox with behavior="list" is the plain-dropdown shape.
Notes
The id is required rather than generated, because a consumer who forgets it gets a visibly labelled control with no accessible name at all — the worst combination, since it looks correct.