Forms & validation
Every Eunomia form field is a real
<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:
Input ,Textarea ,Input phone , andInput file : text and file entrySelect ,Date picker , andTime picker : pick from a set of choicesCheckbox andRadio , each with a companion list variant (`eun-checkbox-list`/`eun-radio-list`)Switch : checkable, on/off stateSlider andCounter : numeric entryRating : a score out of a few stars
Composing a label
A field's label property does two things: it's passed straight through
to an internal eun-labelaria-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
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.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
<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, thenvalidators) 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 fromhint, 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.
<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.
{}
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
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
See also
Input : the most complete single field page, with every state shown on its ownCheckbox andRadio : the checkable fields covered above, plus their list variantsLabel : the internal component every field composes for its own label, instructions, and descriptionStandard Schema : the spec that lets Zod, Valibot, and ArkType schemas work as validators directly