Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Forms & validation

Every Eunomia form field is a real form-associated custom element, so it participates in a native <form>'s submission, reset, and constraint validation exactly like a built-in <input> would.

This page covers the patterns every one of them shares: composing a label, choosing between native constraints and custom validators, telling a checkable field apart from a value-based one, checking and reporting validity from your own code, and reading a submitted form, rather than repeating any one field's own API, which lives on its own component page.

Building a form

Form components

That's a lot of fields today, from plain text entry to full checkable groups:

Composing a label

A field's label property does two things: it's passed straight through to an internal eun-label for the visible text, and it's read directly by the field itself to set its own aria-label (see the Accessibility tab on any field's page for why that's aria-label and not aria-labelledby). Always set one, or slot equivalent content into eun-label's label slot: an unlabeled field is one of the most disruptive, most common screen-reader failures, and unlike a placeholder, a label doesn't disappear once the user types.

<eun-input
  label="Email address"
  placeholder="jane.doe@email.com"
  instructions="We'll never share this"
></eun-input>

instructions is a separate, optional third piece of text, rendered by eun-label right below the label itself, for context that belongs with the label rather than the field ("We'll never share this" above). It's easy to confuse with hint (covered below), since both end up as a small line of grey text near the field, but they answer different questions and render in different places:

Property Renders Answers Ever replaced?
instructions Below the label "What is this field, and why am I asked?" No, always shown
hint Below the field "What should I type here?" Yes, by the error message once invalid

Reach for instructions for something true regardless of what's typed (a privacy note, a policy), and hint for guidance about the value itself (a format, a range), since that's exactly what an error message takes over from once the value is wrong.

Checkable fields

Checkbox, Radio, and Switch don't hold a value the way Input or Select do. Instead, they're checkable fields: what a consumer sets and reads is checked (a plain boolean), and default-checked (not default-value) is what a reset restores. validators for one of these still receives a value to check, but that value is the boolean checked state itself, not a string.

<eun-checkbox id="terms" label="I agree to the terms" required></eun-checkbox>
document.querySelector("#terms").addEventListener("eunchange", (event) => {
  console.log(event.target.checked); // true | false
});

A checkbox also accepts its own value string, exactly like a native <input type="checkbox" value="...">: what actually lands in FormData once checked, falling back to the string "on" if value is never set, matching the platform's own default. Unchecked, it contributes nothing to FormData at all, the same as a native checkbox. A reset restores checked to default-checked (or false, if that was never set either), the exact boolean counterpart to how a value-based field resets to default-value.

On a value-based field, required checks that the value isn't empty. A checkable field has no such "empty" state to test, so required is redefined to mean "must be checked": the "I agree to the terms" checkbox above is invalid until checked, not until some string is non-empty.

Font sizes

--field-font-size sets the size of the value in text fields, --field-label-font-size the label (and the text of a checkbox, radio or switch), and --field-message-font-size the hint, the errors and the instructions. Like every custom property, they inherit, so setting them once on the form styles every field inside it.

form {
  --field-font-size: 1rem;
  --field-label-font-size: 1rem;
  --field-message-font-size: 0.75rem;
}

Readonly, disabled and readonly-available

Three different states, easy to reach for interchangeably by mistake:

State Focusable Submits its value Meaning
readonly Yes Yes "You can't change this right now"
disabled No No "Not part of the current interaction at all"
readonly-available Yes Yes Starts as a static display, click/Enter/Space to edit

Prefer readonly over disabled whenever the value is still meaningful context (a computed total, a value set elsewhere in the flow), and reserve disabled for a field that's genuinely inapplicable right now (e.g. a dependent field with nothing selected yet in the field above it). readonly-available is for data that's normally just displayed but occasionally edited in place. It's currently available on Input, Textarea, and Select, not on every field. See Input's "Readonly available" example.

Validation

Native constraints vs. custom validators

Prefer a native HTML constraint attribute whenever an equivalent exists: required, minlength, maxlength, pattern, min, max, step, or simply type="email"/type="number". Each field's own checkLocalValidators() reads these straight from the underlying <input>, so they're validated by the browser itself: understood by autofill and assistive technology without any extra work, and free of custom JS to maintain.

Reach for the validators property only once no native constraint covers the rule. It accepts an array mixing three shapes, and every entry that fails contributes its own message, all shown together:

type Validators<T> =
  | { isValid: boolean | ((value: T) => boolean); message: string }
  | { isInvalid: ({ value: T }) => null | object; message: string }
  | StandardSchemaV1<unknown, T>; // Zod, Valibot, ArkType, …

A boolean check

The simplest shape: a function returning true once the value is fine. Reach for it whenever a single condition, with a single fixed message, is enough to describe the rule.

<eun-input
  id="username"
  label="Username"
  hint="Letters and underscores only"
></eun-input>
document.querySelector("#username").validators = [
  {
    isValid: (value) => !value || /^[a-z_]+$/i.test(value),
    message: "Only letters and underscores are allowed",
  },
];

A richer check

The same idea, inverted: return null while the value is fine, or an object once it isn't. Reach for it over isValid whenever the rule itself needs to look outside its own value, at another field for instance, the exact case a plain boolean function can't express as cleanly. Confirming a password against a first one, live, is a typical example:

<eun-input id="password" label="Password" type="password"></eun-input>
<eun-input
  id="confirm-password"
  label="Confirm password"
  type="password"
></eun-input>
const password = document.querySelector("#password");
const confirmPassword = document.querySelector("#confirm-password");

confirmPassword.validators = [
  {
    isInvalid: ({ value }) =>
      value !== password.value ? { mismatch: true } : null,
    message: "Doesn't match the password above",
  },
];

password.addEventListener("eunchange", () =>
  confirmPassword.checkValidity(confirmPassword.value),
);
confirmPassword.addEventListener("eunchange", () =>
  confirmPassword.checkValidity(confirmPassword.value),
);

A schema library: Zod, Valibot, ArkType

Any library implementing the Standard Schema spec (Zod 3.24+, Valibot, ArkType, and others) works as a validator entry directly, with no adapter needed: its own ~standard.validate method is called with the field's value, and every issue it reports becomes one error message.

import { z } from "zod";
import type { EunomiaInput } from "@eunomia/elements";

const ageSchema = z.coerce
  .number()
  .int()
  .min(18, { message: "You must be at least 18" })
  .max(120, { message: "Enter a realistic age" });

const age = document.querySelector<EunomiaInput>("#age");
age.validators = [ageSchema];

The schema's own issue messages are what a screen reader announces and what renders in place of hint once the field is invalid, so write them the same way you'd write any other validator's message: short, and addressed to the person filling in the field.

A field re-validates on every eunchange, and once on connect, so listen for eunchange to re-run checkValidity() as the confirm-password example above does, whenever a rule depends on something the field itself can't observe.

Hint, then error: never both

hint is static guidance shown whenever the field shows no error. The moment a field shows its error (see When errors appear below), its message (native constraint message, or every failed validators entry joined) takes over that same spot instead, never alongside the hint, so a screen-reader user isn't read stale guidance and an active problem at once. Both are wired to the field via aria-describedby, only while one of them is actually being shown. Not to be confused with instructions (see Composing a label above), which stays about the label and never gets replaced by an error.

Tracking interaction: dirty, pristine, touched, untouched

Every field listed above exposes four read-only booleans that track how the user has interacted with it so far, independently of whether its current value is valid:

Property True once…
dirty The value has changed at least once
touched The field has lost focus at least once

pristine and untouched are simply their inverses (!dirty and !touched), for whichever reads more naturally in your own condition. All four reset to their initial state on form.reset(), exactly like a field's value itself.

dirty only reacts to a genuine user edit, the same interaction a field's own eunchange event already reports: setting .value (or .checked) from outside, the way a controlled React or Vue field does on every render, never flips it. touched reacts purely to focus leaving the field, regardless of whether its value ever changed.

When errors appear

A field validates itself all the time, but only shows its error once the user has had a chance to fill it: after it loses focus (touched), or once its form checks it, on a submit attempt or a reportValidity() call. A required field left empty is therefore invalid from the start, yet stays quiet until then. form.reset() makes it quiet again. showError tells whether a field currently shows its error.

Typing alone doesn't reveal the error, so an email address still being typed isn't reported as invalid. A field where each change is a complete choice (eun-checkbox, eun-checkbox-list, eun-radio, eun-radio-list, eun-switch, eun-select, eun-date-picker, eun-input-file) shows it as soon as its value changes instead. A date typed into eun-date-picker only counts as a change once complete. Once the error is shown, it follows the value and disappears as soon as the value is valid again.

On a failed submit, focus moves to the first invalid field of the form, and the browser's own bubble stays hidden since the message already sits under the field. The messages of native constraints (required, type="email", pattern, minlength, min, max) follow the locale of the library rather than the language of the browser.

<eun-input
  label="Email address"
  type="email"
  required
  hint="We never share it"
></eun-input>

Focus the field and tab away: the error replaces the hint.

hide-error hides the errors entirely, while the hint stays visible. Set it on a <fieldset> to hide the errors of every field inside, without repeating it on each one.

<fieldset hide-error>
  <eun-input name="firstName" label="First name" required></eun-input>
  <eun-input name="lastName" label="Last name" required></eun-input>
</fieldset>
// React, with an explicit value since hide-error isn't a native attribute
<fieldset hide-error="">…</fieldset>

Errors from the form or the server

When validation runs outside the field, in a shared form schema or in a server action, pass the message through error. The field turns invalid (customError) and shows it right away, whether or not the user reached it, alongside any message from its own validators. Clear it (undefined or an empty string) once the problem is gone.

<eun-input
  label="Username"
  value="jane"
  error="This username is already taken"
></eun-input>
// React, with the errors returned by a server action
<eun-input name="email" label="Email" error={state.errors?.email} />

Invalid without a message

When one message covers several fields, such as a failed sign in reported in an alert above the form, set invalid on each field instead. They take the error styling and aria-invalid="true" without repeating the message below them, and their own validity is left unchanged, so the form can be submitted again. Remove it as soon as the user edits the fields, and keep the message in an element they can reach, like an Alert receiving focus.

Incorrect email or password
<eun-alert severity="critical" tabindex="-1"
  >Incorrect email or password</eun-alert
>
<eun-input name="email" label="Email" type="email" invalid></eun-input>
<eun-input name="password" label="Password" type="password" invalid></eun-input>

Working with the form from script

Checking validity programmatically

A native <form> already blocks its own submit event on its own, the moment any participating field (Eunomia's own, or a plain <input required> alongside them) reports itself invalid, exactly as if every field were a built-in control. Nothing to wire up for that part. Reach for a field's own checkValidity()/reportValidity() methods only when you need to trigger, or react to, that same check from your own code, outside a real submit:

  • checkValidity() re-runs every rule (native constraints, then validators) and returns whether the field is currently valid, silently: no error message shown, no focus moved. It's what the examples above call after a related field changes, to re-evaluate a rule that depends on it.
  • reportValidity() does the same check, but also shows the field's own invalid state the way a failed form submission would (the error message takes over from hint, and the field receives focus), even outside of an actual submit. Reach for it on something like a multi-step form's "Next" button, which needs to block progress on an invalid step without a real <form> submission to do it automatically.
Next step
<eun-input
  id="step-email"
  label="Email address"
  type="email"
  required
></eun-input>
<eun-button id="step-next">Next step</eun-button>
document.querySelector("#step-next").addEventListener("click", () => {
  const email = document.querySelector("#step-email");
  if (email.reportValidity(email.value)) {
    goToNextStep();
  }
  // Invalid: reportValidity already moved focus to #step-email and
  // shows its error message, so there's nothing else to do here.
});

Reading a submitted form

Fields participate in FormData under their name, exactly like a built-in <input>, with no custom serialization needed. eunchange fires on every keystroke, useful for live previews or cross-field validation, while submit/reset are the form's own native events.

Submit Reset
{}
const form = document.querySelector("#my-form");

form.addEventListener("submit", (event) => {
  event.preventDefault(); // replace with a real submission
  console.log(Object.fromEntries(new FormData(form)));
});

Clicking "Reset" reverts every field to its initial value, including clearing the fields that had none, not just the ones with a starting value, and the JSON panel above updates on every change, submit, and reset.

Guarding a form against an unwanted reset

Calling form.reset(), whether from a user clicking a type="reset" button or from your own code, fires a cancelable reset event on the form first. The actual reset (every field reverting to its defaultValue, exactly like the demo above) only happens if nothing calls preventDefault() on that event.

React 19's <form action={fn}> pattern (the useActionState example in the React and Next.js Server Actions docs) calls that same native reset() unconditionally, on every action, including a client-side validation failure, before any server round-trip. Every field's current value is wiped, not just the ones with no defaultValue. This isn't specific to Eunomia's fields: a plain <input> only survives it because React force-resyncs its .value from its own controlled prop on every render, a special case it reserves for native elements and never extends to a custom one.

If a field's value is already driven as a controlled prop (a value/onChange or value/eunchange pair kept in your own state), set controlled on it instead of fighting the reset itself:

<eun-input controlled name="email" .value="${email}"></eun-input>

With controlled set, formResetCallback leaves that field's value (and a checkable field's checked) untouched on any native reset, whether triggered by useActionState, a type="reset" button, or your own form.reset() call. Your own state stays the single source of truth for what the field displays, the same way it already is for a controlled <input>; clearing it is then just a matter of updating that state, exactly as it would be without a native reset involved at all. dirty and touched still reset normally, since those track interaction history rather than value ownership.

For a native field mixed into the same form, or when you'd rather cancel the reset outright, guard the same reset event from your own code instead:

form.addEventListener("reset", (event) => event.preventDefault());

This cancels every reset on that form, including a genuine one from a Reset button. Gate the listener behind a ref/flag you control if the form should sometimes still reset for real, for example after a successful submission.

Angular forms

To bind fields with ngModel, formControl or formControlName, import EunomiaValueAccessor from @eunomia/elements/angular. See Angular forms for the setup, the value each field holds, and how validation fits with Angular's own.

See also