Form Validation
stimeo--form-validation
Puts timing around the browser's own validation: it blocks the submit and takes you to the first error.
Puts sensible timing around the validation the browser already does. The browser's own bubbles are suppressed and the message is shown inside your page instead. A field is checked once you have left it, so nothing turns red before you have even typed in it, and a field that has failed is re-checked as you correct it. On submit everything is checked, an invalid form is stopped, and focus is taken to the first field with a problem. Messages can be written per constraint and per language, and whitespace-only entries can be rejected. A custom widget can join in through a hidden field, with no extra script.
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the form-validation demo.
stimeo--form-validation orchestrates WHEN native constraint validation runs
(submit / blur / change / input) and routes each control's message into its
stimeo--form-field visual error — the ARIA wiring stays in form-field, and
the speech goes to the shared stimeo--announcer your app seats once, in its
layout (one assertive receiver serves every field, and it is already in the
accessibility tree before the first error, which is what makes the message
observable).
Rules are plain markup attributes (required, type="email", plus the
declarative disallow="whitespace"); the wording comes from localized
data-stimeo--form-validation-message-* overrides rather than the browser, so
the catalog reads the same in both locales. The plan listbox joins through a
validatable mirror: <input type="text" hidden required> — the hidden
ATTRIBUTE, not type="hidden", which is barred from constraint validation.
The authored novalidate keeps the no-JS fallback graceful (the controller
preserves it). Valid submissions are kept on the page by demo.js so the
catalog can show a success status. %>
<form class="form-validation-demo" action="#" method="get" novalidate
data-controller="stimeo--form-validation"
data-stimeo--form-validation-stimeo--form-field-outlet=".form-validation-demo__field">
<div class="form-validation-demo__field" data-controller="stimeo--form-field">
<label class="form-validation-demo__label" for="fv-email">
<%= t("components.form_validation.demo.email_label") %>
</label>
<input class="form-validation-demo__control" id="fv-email" name="email" type="email"
required placeholder="<%= t('components.form_validation.demo.email_placeholder') %>"
data-stimeo--form-validation-message-value-missing=
"<%= t('components.form_validation.demo.messages.email_required') %>"
data-stimeo--form-validation-message-type-mismatch=
"<%= t('components.form_validation.demo.messages.email_type') %>"
data-stimeo--form-field-target="control" />
<%# Visual error only. Speech goes through the shared stimeo--announcer. %>
<p class="form-validation-demo__error" hidden
data-stimeo--form-field-target="error"></p>
</div>
<div class="form-validation-demo__field" data-controller="stimeo--form-field">
<label class="form-validation-demo__label" for="fv-name">
<%= t("components.form_validation.demo.name_label") %>
</label>
<input class="form-validation-demo__control" id="fv-name" name="name" type="text"
required placeholder="<%= t('components.form_validation.demo.name_placeholder') %>"
data-stimeo--form-validation-disallow="whitespace"
data-stimeo--form-validation-message-value-missing=
"<%= t('components.form_validation.demo.messages.name_required') %>"
data-stimeo--form-validation-message-whitespace=
"<%= t('components.form_validation.demo.messages.name_whitespace') %>"
data-stimeo--form-field-target="control" />
<p class="form-validation-demo__error" hidden
data-stimeo--form-field-target="error"></p>
</div>
<%# The listbox keeps its committed value in the mirror below. The form-field
control is the visible trigger, so aria-invalid and invalid focus land there. %>
<div class="form-validation-demo__field listbox"
data-controller="stimeo--form-field stimeo--listbox">
<span id="fv-plan-label" class="form-validation-demo__label">
<%= t("components.form_validation.demo.plan_label") %>
</span>
<button
type="button"
class="demo-trigger listbox__trigger"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="fv-plan-options"
aria-labelledby="fv-plan-label fv-plan-value"
data-stimeo--listbox-target="trigger"
data-stimeo--form-field-target="control"
data-action="click->stimeo--listbox#toggle keydown->stimeo--listbox#onTriggerKeydown">
<span id="fv-plan-value" data-stimeo--listbox-target="value">
<%= t("components.form_validation.demo.plan_placeholder") %>
</span>
<span class="listbox__chevron" aria-hidden="true">▾</span>
</button>
<ul id="fv-plan-options" class="listbox__list" role="listbox"
aria-labelledby="fv-plan-label" hidden data-stimeo--listbox-target="list">
<% %w[free pro team].each_with_index do |plan, index| %>
<li id="fv-plan-opt-<%= index %>" class="listbox__option" role="option"
aria-selected="false" data-value="<%= plan %>"
data-stimeo--listbox-target="option"
data-action="click->stimeo--listbox#select">
<%= t("components.form_validation.demo.plans.#{plan}") %>
</li>
<% end %>
</ul>
<%# Validatable mirror: the hidden attribute participates in constraint
validation and submits with the form — type="hidden" would do neither. %>
<input type="text" hidden required name="plan"
data-stimeo--form-validation-message-value-missing=
"<%= t('components.form_validation.demo.messages.plan_required') %>"
data-stimeo--listbox-target="field" />
<p class="form-validation-demo__error" hidden
data-stimeo--form-field-target="error"></p>
</div>
<p class="form-validation-demo__note"><%= t("components.form_validation.demo.note") %></p>
<button type="submit" class="demo-trigger">
<%= t("components.form_validation.demo.submit") %>
</button>
<%# Status line updated by demo.js; templates ride data-* so the copy stays bilingual. %>
<p class="form-validation-demo__status" id="fv-status" aria-live="polite"
data-valid-text="<%= t('components.form_validation.demo.valid_status') %>"
data-invalid-template="<%= t('components.form_validation.demo.invalid_template') %>"></p>
</form>
/*
* Presentation-only styles for the form-validation demo.
* The invalid state is expressed by the library through aria-invalid on each
* field's control (the email input / the listbox trigger) and the form-field
* error region; this CSS only reacts to those hooks.
*/
.form-validation-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 22rem;
}
.form-validation-demo__field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-validation-demo__label {
font-weight: 600;
}
.form-validation-demo__control {
padding: 0.5rem 0.75rem;
font-size: 1rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
transition: border-color 0.15s ease;
}
.form-validation-demo__control:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The library's invalid hook — covers the email input and the listbox trigger. */
.form-validation-demo [aria-invalid="true"] {
border-color: var(--danger-500);
}
.form-validation-demo__error {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--danger-500);
}
/* Note conveying the demo's intent. Shown in a muted tone. */
.form-validation-demo__note {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-muted);
border-left: 2px solid var(--border-strong);
padding-left: 0.6rem;
}
.form-validation-demo .demo-trigger {
align-self: flex-start;
}
/* Outcome status line; data-state is set by demo.js from the dispatched events. */
.form-validation-demo__status {
margin: 0;
min-height: 1.25rem;
font-size: 0.85rem;
}
.form-validation-demo__status[data-state="valid"] {
color: var(--leaf-500);
}
.form-validation-demo__status[data-state="invalid"] {
color: var(--danger-500);
}
/* Listbox presentation (mirrors the listbox demo, scoped to this page). */
.listbox {
position: relative;
}
/* Only the select-style layout is the demo's own; the look comes from .demo-trigger. */
.listbox__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.listbox__chevron {
color: var(--color-text-muted);
}
.listbox__list {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 10;
margin: 0;
padding: 0.25rem;
list-style: none;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
.listbox__option {
padding: 0.45rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
}
.listbox__option[data-active] {
background: var(--color-primary-soft);
}
/* `--accent-800`, one step stronger than the plain accent text token: this text can
land on the tinted active/selected surface as well as the card, and on the tinted
one in dark `--accent-700` measures 4.44:1 — just under the floor. */
.listbox__option[aria-selected="true"] {
font-weight: 600;
color: var(--accent-800);
}
// Form-validation demo (consumer-side JS).
//
// The controller owns the validation behavior; this script only (1) keeps the
// catalog page in place by cancelling the real — i.e. valid — submission
// (invalid submissions never reach this listener: the controller cancels them
// in the capture phase) and (2) renders a status line for the outcome. Status
// text rides data-* attributes so the catalog stays bilingual; JS injects
// values only (the {count} token).
const form = document.querySelector(".form-validation-demo");
const status = document.getElementById("fv-status");
if (form) {
form.addEventListener("submit", (event) => {
// Target-phase listener: runs before Turbo's document-level handler.
event.preventDefault();
if (!status) return;
status.dataset.state = "valid";
status.textContent = status.dataset.validText || "";
});
form.addEventListener("stimeo--form-validation:invalid", (event) => {
if (!status) return;
const template = status.dataset.invalidTemplate || "{count}";
status.dataset.state = "invalid";
status.textContent = template.replace("{count}", String(event.detail.invalid.length));
});
}
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--form-validation"
Values
| Name | Description | Attribute |
|---|---|---|
validateOnBlur
|
Validate a field on blur (focusout), marking it touched (default true). |
data-stimeo--form-validation-validate-on-blur-value |
validateOnChange
|
Validate on change — a committed interaction such as a select choice or a widget writing its mirror (default true). |
data-stimeo--form-validation-validate-on-change-value |
revalidateOnInput
|
Re-validate an already-touched field on every input so a shown error clears immediately (default true). |
data-stimeo--form-validation-revalidate-on-input-value |
focusInvalid
|
Move focus to the first invalid field's visible control on a blocked submit (default true). |
data-stimeo--form-validation-focus-invalid-value |
Actions
| Name | Description | Action |
|---|---|---|
validate
|
Validates every control now and returns whether the whole form is valid. | stimeo--form-validation#validate |
Events
| Name | Description | Event |
|---|---|---|
valid
|
Fires when a submit passes validation; detail {}. |
stimeo--form-validation:valid |
invalid
|
Fires when a submit is blocked; detail { invalid: HTMLElement[] }, collapsed to one entry per field. |
stimeo--form-validation:invalid |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
novalidate |
Form | Added on connect to suppress the browser's own error bubbles; removed on disconnect only when the controller added it. |
data-stimeo--form-validation-novalidate |
Form | Marks that the controller (not the author) added novalidate, so an authored attribute is never removed. |