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.
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 entry
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.
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.
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
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.
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