0.1.0

Form

Forms coordinate validation, default values, and submission across RuiField children without external form libraries.

Try it

Usage

Wrap fields in RuiForm and handle validated values with onSubmit. Choose mode to control when validation runs.

import { RuiForm } from '@ecopages/radiant-ui/form';
import { RuiField } from '@ecopages/radiant-ui/field';
import { RuiLabel } from '@ecopages/radiant-ui/label';
import { RuiInput } from '@ecopages/radiant-ui/input';
import { RuiButton } from '@ecopages/radiant-ui/button';
 
<RuiForm mode="onSubmit" onSubmit={handleSubmit}>
  <RuiField name="name" rules={{ required: 'Name is required' }}>
    <RuiLabel>Full name</RuiLabel>
    <RuiInput />
  </RuiField>
  <RuiButton type="submit">Create account</RuiButton>
</RuiForm>

Custom markup

<rui-form> coordinates validation across rui-field connectors and a composed native form. The RuiForm helper stamps [data-ref="form"]; it is not required.

import '@ecopages/radiant-ui/form';
 
<rui-form mode="onSubmit">
  <form class="rui-form" data-ref="form" noValidate>
    <rui-field name="name">
      <div class="rui-field">
        <label class="rui-label" data-rui-field-label>Name</label>
        <input type="text" data-rui-control class="rui-input" />
      </div>
    </rui-field>
    <button type="submit">Submit</button>
  </form>
</rui-form>

Submit buttons outside the native <form> are associated via the form attribute. Fields register through context — the form does not query control targets directly.

Load @ecopages/radiant-ui/styles.css once in your app to style the form and every composed control. When bundling styles per component, import @ecopages/radiant-ui/form/styles.css plus the stylesheet for every component used inside the form, such as field/styles.css, textarea/styles.css, or number-field/styles.css.

Validation timing

onSubmit validates once on submit and is best for short forms. Use onBlur or onChange when immediate feedback helps data entry.

Compose with Radiant controls

Every RuiField child should be a Radiant control (RuiInput, RuiSelect, RuiDateInput, …). The form store discovers values through the Field control protocol; unmarked native inputs are not registered.

Native form submission

RuiForm always contains a native <form>. These are two channels:

  • Store (onSubmit / rui-submit): every RuiField value
  • Listed controls (new FormData(form) and action / method navigation): only what the browser lists

onSubmit skips native navigation. action / method without onSubmit POSTs listed controls only.

Listed:

  • Inner native controls: RuiInput, RuiTextarea, RuiCheckbox, RuiSwitch, RuiRadioGroup, RuiCheckboxGroup
  • Form-associated hosts (name on the host): rui-date-input, rui-number-field, rui-slider, rui-knob. An empty named date submits ''. A range slider also submits {name}-max. Number-field submits the raw number, not the formatted display
  • 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
  • RuiField copies its name onto that control. The field element itself is not listed

Store-only (not in FormData): RuiSelect, RuiCombobox, RuiListbox, RuiTagGroup.

Custom form-associated hosts

RuiField connects a control to the RuiForm store. Native forms use a separate channel: the browser lists a control in FormData only when a native form control has a name or a form-associated custom element supplies a value through ElementInternals. Register a third-party host before its field connects. Registration supplies store value access and name wiring; it does not make the host form-associated.

import { customElement, prop } from '@ecopages/radiant';
import { FormAssociatedElement, type FormValue } from '@ecopages/radiant/form-associated-element';
import { registerFieldControl } from '@ecopages/radiant-ui/form';
 
@customElement('x-tone')
class ToneControl extends FormAssociatedElement {
  @prop({ type: String, reflect: true, defaultValue: '' }) value!: string;
 
  protected override formValue(): FormValue {
    return this.value;
  }
 
  protected override restoreFormState(state: FormValue): void {
    this.value = typeof state === 'string' ? state : '';
  }
}
 
registerFieldControl('x-tone', {
  read: (host) => (host as ToneControl).value,
  write: (host, value) => { (host as ToneControl).value = String(value ?? ''); },
  submission: 'host',
});

FormAssociatedElement extends RadiantElement. Each concrete subclass has its own constructor, so several controls can inherit the base and register under different tags. The base owns the single ElementInternals, name, disabled, effective fieldset disability, submission syncing, and reset. Its inherited static formAssociated getter is read when the concrete class is registered. Keep super.updated(changed) if you override updated(); that call syncs the form value after reactive writes. Likewise, call super.formDisabledCallback(disabled) or super.formResetCallback() when overriding those browser callbacks.

The first form value sync records the reset state, normally during the first connect update cycle. By default, that state comes from formValue(). Override formState() when formValue() can be null for an unnamed host or returns a multi-entry FormData; return a value that restoreFormState() can read even if the name changes later. For visual disability caused by a parent <fieldset disabled>, read effectiveDisabled and watch @onUpdated('effectiveDisabled'). The catalog hosts support native submission, fieldset disability, and reset. Implement browser constraint validity through this.internals?.setValidity(...) and browser state restoration through formStateRestoreCallback() when the control needs them; RuiForm rules validate through its store.

For other hosts, submission is native by default (name the native ARIA target), or none for a store-only control. Set data-rui-aria-target on the host when that native input is inside it.

Default values

Pass defaultValues to pre-populate edit forms. Fields read their initial state from the form store. Multi-value controls (RuiSelect, RuiCombobox, RuiListbox, RuiCheckboxGroup, RuiTagGroup) store string[] — seed them with an array ({ language: ['ts'] }). A CSV string still writes because the host parses it; after that the stored value is an array. RuiSlider stores number[].

Theming

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

PartCSS roles
Root (.rui-form)space-stack gap

The form itself carries no color roles; fields, labels, and controls inside keep their own surface / control tokens. Override at the theme layer, not in component CSS.

API

RuiForm renders a native <form> inside the coordinating <rui-form> custom element. The view maps defaultValues / resolver to prop: bindings and keeps authored children inside that native form.

Attributes

AttributeTypeDefaultDescription
modeonSubmit · onBlur · onChange · onTouched · allonSubmitWhen validation runs.
revalidate-modeonSubmit · onBlur · onChange · onTouched · allonChangeWhen fields re-validate after a failed submit.
data-default-valuesstringJSON-serialized default values for SSR / JSX attribute channel.
actionstringNative form destination when onSubmit is not provided.
methodstringNative form HTTP method when onSubmit is not provided.

Props

PropTypeDescription
defaultValuesPartial<FieldValues>Initial values; fields read their state from the form store.
resolverResolver<T>Custom validation resolver (e.g. from createRulesResolver).
onSubmit(values: T) => void | Promise<void>Receives the store. Skips native navigation. When omitted, action or method POSTs listed controls after validation passes.

Light-DOM contract

TargetRequiredHost writesAuthor owns
[data-ref="form"]yesid when wiring orphan submitsnative <form noValidate> and field children
button[type="submit"] outside formnoform attributesubmit buttons on the host
rui-field descendantsyes (for registration)—each field's control tree

Nested hosts: rui-field and each field's control host.

Context

formContext exposes ready, revision, fields, errors, actions, and the live store once the form is ready. Scoped consumers resolve the nearest provider (@consumeContext / ContextSubscriptionRequestEvent) and use context.store for value reads/writes, reset, subscribe, and validation. Nested forms still resolve the nearest provider. Hydration payloads contain presentation only (ready, revision, fields, errors); they never include the store or its actions. RuiField reads the matching field state and applies it to its control as aria-invalid; use fieldContext for a control's local error and invalid values.

Events

EventDetailDescription
rui-submit{ values: T }Emitted when validation passes.
rui-invalid{ errors: Record<string, { message?: string }> }Emitted when validation fails on submit.

View helpers

ComponentTarget stampedNotes
RuiForm[data-ref="form"] on the native formPasses defaultValues, resolver, onSubmit via prop: bindings.

CSS classes

Public BEM classes (documented via @cssclass):

ClassDescription
.rui-formRoot form surface (<form noValidate>).

Theme roles

PartCSS variables consumed
Root--space-stack

Accessibility

  • Submit buttons should use type="submit" inside the form for keyboard submission.
  • Announce form-level errors at the top when multiple fields fail validation.
  • Focus the first invalid field after a failed submit attempt.