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.

Rendered from the package · open on its own

Summary

Wraps any single control with its label and one message.

APG patternnone
Built onnative elements
Statusstable
Since0.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.

PartElementRequired
adornment<span>no
controlthe caller'sthe caller's
control-wrap<div>yes
descriptionthe caller'sthe caller's
errorthe caller'sthe caller's
label<label>no

The control is children, because nothing about it touches the field's contract.

Properties

PropertyValuesDefaultWhat it does
idstringrequiredWired to the control's id, the label's htmlFor and aria-describedby.
labelstringnoneThe visible label, and the accessible name. A field without one is a control a screen reader can find and cannot describe.
descriptionstringnoneHelper text. Replaced by error when that is set.
errorstringnoneSets data-invalid, aria-invalid and shows the message in --se-negative.
validbooleannoneTints the adornment --se-positive. For confirmed values, not merely non-empty ones.
disabledbooleannonePassed through to the control. Prefer read-only where the value still matters to the reader — a disabled field is skipped by the keyboard entirely.
readOnlybooleannoneNot 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.
adornmentReact.ReactNodenoneInside 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.
childrenReact.ReactNoderequiredThe control: <input>, <select> or <textarea>.

States

StateExpressed as
Disabled:disabled
Focused:focus-visible
Hover:hover
Disableddata-disabled
Invaliddata-invalid
Validdata-valid
Read-onlyreadonly

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.jsx
  • test/apg/field-prop-merging.test.jsx
  • test/browser/upstream-forms-menubar.spec.mjs

Known gaps

_No upstream requirement is recorded as not applicable._

Appearance

TokenUsed for
--se-accentborder-color
--se-borderborder-color
--se-border-hoverborder-color
--se-border-strongborder-color
--se-border-widthborder-width
--se-control-hblock-size
--se-control-h-smpadding-inline-end
--se-duration-instanttransition
--se-ease-standardtransition
--se-focus-coloroutline
--se-focus-widthoutline, outline-offset
--se-icon-smblock-size, inline-size, padding-inline-end
--se-leading-bodyline-height
--se-leading-uiline-height
--se-negativeborder-color, color
--se-positivecolor
--se-space-1gap, margin-block-start, padding
--se-space-2inset-inline-end, padding-inline-end
--se-space-3inset-inline-end, padding, padding-block, padding-inline, padding-inline-end
--se-surfacebackground
--se-surface-readonlybackground
--se-surface-sunkbackground
--se-target-minblock-size
--se-textcolor
--se-text-disabledcolor
--se-text-mutedcolor
--se-text-placeholdercolor
--se-text-smfont-size
--se-weight-mediumfont-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.