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:
| Part | CSS 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
| Attribute | Type | Default | Description |
|---|---|---|---|
name | string | '' | Field name; registers with RuiForm and is copied onto the listed control. |
error | string | '' | Standalone error message when not using a form provider. |
invalid | boolean | false | Standalone invalid flag when not using a form provider. |
disabled | boolean | false | Dims the field and disables nested controls. |
data-default-value | string | JSON-serialized default value for SSR hydration. |
Props
| Prop | Type | Description |
|---|---|---|
rules | FieldRules | Validation rules (required, min/max, pattern, validate…). |
defaultValue | unknown | Default value when uncontrolled. |
defaultValueData | string | JSON default value; falls back to data-default-value. |
Light-DOM contract
| Target | Required | Host writes | Author owns |
|---|---|---|---|
[data-rui-control] or control host tag | yes | id, data-rui-field-managed, aria-invalid, aria-required, aria-describedby, aria-disabled on ARIA targets | control element; see each host's inner contract |
[data-rui-field-label] | no | htmlFor; id when the control is a registered custom host | label text |
[data-rui-field-description] | no | id | description content |
[data-rui-field-error] | no | id, 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
| Component | Target stamped | Notes |
|---|---|---|
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):
| Class | Description |
|---|---|
.rui-field | Root column (authored by the element). |
.rui-field__description | Helper text below the control. |
.rui-field__error | Error text; hidden until a message is set. |
Theme roles
| Part | CSS 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.