Eunomia v1.0.0-beta.2
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.

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.

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.

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.

Hint, then error: never both

hint is static guidance shown at all times a field is empty or valid. The moment a field becomes invalid, its error 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.

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.

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.

See also