Field

Fields wrap a single form control with label, description, and error messaging in a consistent layout.

Try it

We will never share your email.

Usage

Set name on RuiField to register with RuiForm and to copy onto the listed control. Place a Radiant control (RuiInput, RuiSelect, RuiSwitch, …) as the child; Field discovers controls via data-rui-control and known host tags, not bare <input> elements. Use RuiFieldDescription and RuiFieldError for helper and error text.

import { RuiField, RuiFieldDescription, RuiFieldError } from '@ecopages/radiant-ui/field';
import { RuiLabel } from '@ecopages/radiant-ui/label';
import { RuiInput } from '@ecopages/radiant-ui/input';
 
<RuiField name="email" invalid={!!errors.email} error={errors.email?.message}>
  <RuiLabel>Email</RuiLabel>
  <RuiInput type="email" placeholder="[email protected]" />
  <RuiFieldDescription>We will never share your email.</RuiFieldDescription>
  <RuiFieldError />
</RuiField>

Custom markup

<rui-field> coordinates any light-DOM tree that matches its query contract. The RuiField helper stamps [data-ref="field"]; it is not required.

import '@ecopages/radiant-ui/field';
import '@ecopages/radiant-ui/input';
 
<rui-field name="email">
  <div class="rui-field" data-ref="field">
    <label class="rui-label" data-rui-field-label>Email</label>
    <input type="email" data-rui-control class="rui-input" />
    <p data-rui-field-description class="rui-field__description">We will never share your email.</p>
    <p data-rui-field-error class="rui-field__error" role="alert" hidden></p>
  </div>
</rui-field>

The field wires aria-describedby, aria-invalid, and error text on the control. Use a library control host or [data-rui-control] — bare native inputs without the marker are ignored.

Set name on the field (or on a form-associated host) so a native <form> can submit. RuiInput / RuiTextarea / checkbox / switch / radio / checkbox-group / radio-group submit through their inner input. rui-date-input, rui-number-field, rui-slider, and rui-knob submit through name on the host. RuiDateField is not listed; it copies name onto the nested rui-date-input. RuiDateRangePicker is not listed; start-name / end-name land on the nested rui-date-input hosts. RuiSelect and similar hosts are not form-associated; their values are available to RuiForm onSubmit, not to new FormData(form).

Use Radiant controls

RuiField only wires library controls: presentational ones marked with data-rui-control (RuiInput, RuiTextarea, select triggers) and host elements such as rui-select, rui-switch, and rui-date-input. An embedded rui-listbox is the parent select or combobox's option surface, not a separate field control. Bare <input> / <textarea> without the marker are ignored.

Surface errors inline

Set invalid and pass the error message so RuiFieldError renders below the control with aria-live semantics.

Errors that belong to a group

Pass error to RuiFieldError for a message that is not about one control, such as a group or a repeating array. The region is controlled: it renders error (with children as the fallback), is visible while it has content, works outside a RuiField and on the server, and carries data-rui-field-error-controlled so an enclosing field never overwrites or hides it. For example, a list with a minimum length:

import { RuiFieldError } from '@ecopages/radiant-ui/field';
 
<ul aria-describedby="items-error">{items}</ul>
<RuiFieldError id="items-error" error={items.length === 0 ? 'Add at least one item' : ''} />

An empty error keeps the region controlled and hidden; omit error to leave the region to the enclosing field. Give the region an id and point the group's aria-describedby or aria-errormessage at it. Inside a field, a controlled region without an id gets one and is added to the control's aria-describedby and stays listed while hidden, since an empty region adds nothing to the description.

Disabled fields

Set disabled on the field to dim the label and prevent interaction with nested controls.

Theming

Field surfaces map to semantic surface roles, never Tailwind palette steps:

PartCSS roles
Root (.rui-field)space-inline gap
Description (.rui-field__description)on-surface, text-body-sm
Error (.rui-field__error)error, text-body-sm

The nested control keeps its own control tokens (--size-control-*, --radius-control) so fields stack consistently. Override at the theme layer, not in component CSS.

API

RuiField is a custom element (<rui-field>) connecting a composed control to an ancestor <rui-form>. It is not listed in native FormData; the child control is.

Attributes

AttributeTypeDefaultDescription
namestring''Field name; registers with RuiForm and is copied onto the listed control.
errorstring''Standalone error message when not using a form provider.
invalidbooleanfalseStandalone invalid flag when not using a form provider.
disabledbooleanfalseDims the field and disables nested controls.
data-default-valuestringJSON-serialized default value for SSR hydration.

Props

PropTypeDescription
rulesFieldRulesValidation rules (required, min/max, pattern, validate…).
defaultValueunknownDefault value when uncontrolled.
defaultValueDatastringJSON default value; falls back to data-default-value.

Light-DOM contract

TargetRequiredHost writesAuthor owns
[data-rui-control] or control host tagyesid, data-rui-field-managed, aria-invalid, aria-required, aria-describedby, aria-disabled on ARIA targetscontrol element; see each host's inner contract
[data-rui-field-label]nohtmlFor; id when the control is a registered custom hostlabel text
[data-rui-field-description]noiddescription content
[data-rui-field-error]noid, textContent, hidden (skipped when data-rui-field-error-controlled; only a missing id is written)error region presence
[data-ref="field"]no—presentation column

Nested hosts: registered control hosts inside the field (rui-switch, rui-slider, …).

Custom control hosts

A host registered with registerFieldControl that declares data-rui-aria-target or data-rui-aria-targets renders its target on its own schedule, often after the field connected. The field therefore writes no id, data-rui-field-managed or aria-* for it. It publishes the state on fieldContext (controlId, labelId, describedBy, invalid, required, disabled) and the host binds it onto its target declaratively. Read the context into a @state field with @contextSelector, then:

A host that owns a render() tree spreads fieldAria(field) on the target, so the attributes are repainted whenever the target renders or the field changes:

import { RadiantElement, customElement, state } from '@ecopages/radiant';
import { contextSelector } from '@ecopages/radiant/context';
import { fieldAria, fieldContext, type FieldContextValue } from '@ecopages/radiant-ui/field';
 
@customElement('x-code')
class XCode extends RadiantElement {
  @contextSelector({ context: fieldContext })
  @state
  field?: FieldContextValue = undefined;
 
  override render() {
    return <input {...fieldAria(this.field)} />;
  }
}

Register the tag before the field connects and declare the target on the host element in the field's markup:

import { registerFieldControl } from '@ecopages/radiant-ui/form';
 
registerFieldControl('x-code', {
  read: (host) => host.querySelector('input')?.value ?? '',
  write: (host, value) => {
    const input = host.querySelector('input');
    if (input) input.value = String(value ?? '');
  },
});
 
// <RuiField name="code">
//   <RuiLabel>Code</RuiLabel>
//   <x-code data-rui-aria-target="input" />
//   <RuiFieldError />
// </RuiField>

A host that keeps authored DOM (no render()) decorates the same field with @bindTo(fieldAriaBindings(selector)):

@contextSelector({ context: fieldContext })
@bindTo(fieldAriaBindings('input'))
@state
field?: FieldContextValue = undefined;

@bindTo copies on connect and when the field changes; it does not repaint a target inserted afterwards. A target the host creates after connecting must come from render(). Both forms bind id (the controlId the label's htmlFor points at, so clicking the label focuses the target) along with aria-labelledby, aria-describedby, aria-invalid, aria-required and aria-disabled. Native disabled stays with the author: bind it yourself, for example <input disabled={this.field?.disabled} {...fieldAria(this.field)} />. Painted hosts support a single ARIA target, since both helpers yield one id; a host with several targets (data-rui-aria-targets) must assign its own ids. A host that declares its target only when it connects is switched over on the field's next sync, which removes what the field wrote on the host before (an authored id stays); the view needs no declaration helper. Catalog hosts and native inputs keep the field's direct writes.

View helpers

ComponentTarget stampedNotes
RuiField[data-ref="field"]Passes rules / defaultValue via prop: bindings.
RuiFieldDescription[data-rui-field-description]Wired into aria-describedby.
RuiFieldError[data-rui-field-error]Text populated by the field; role="alert". error makes it controlled (data-rui-field-error-controlled).

CSS classes

Public BEM classes (documented via @cssclass):

ClassDescription
.rui-fieldRoot column (authored by the element).
.rui-field__descriptionHelper text below the control.
.rui-field__errorError text; hidden until a message is set.

Theme roles

PartCSS variables consumed
Root--space-inline
Description--on-surface, --text-body-sm
Error--error, --text-body-sm

Accessibility

  • Associate labels with controls using RuiLabel; do not rely on placeholder text alone.
  • Error messages are linked to the input via aria-describedby.
  • Descriptions provide supplementary context without replacing the label.