Form Validation
stimeo--form-validation
Orchestrates native constraint validation: when to check, blocking submit, focus, and routing messages into form fields.
The stimeo--form-validation controller is the timing layer over the browser's native Constraint Validation API. It suppresses the native error bubbles (novalidate), checks every control on submit — cancelling an invalid submission before any other submit handler reacts — validates a field on blur once touched, re-validates it on input, treats change as a committed interaction, and moves focus to the first invalid field's visible control. Each control's validationMessage is rendered through its stimeo--form-field outlet, so the ARIA wiring lives in exactly one place. Rules and messages stay native (required, type, pattern, setCustomValidity); the controller invents neither. Rich widgets join through a validatable mirror — an input with the hidden attribute, not type="hidden" — with no extra JavaScript.
<%# Markup for the form-validation demo.
stimeo--form-validation orchestrates WHEN native constraint validation runs
(submit / blur / change / input) and routes each control's validationMessage
into its stimeo--form-field error region — the ARIA wiring stays in form-field.
Rules are plain markup attributes (required, type="email"); message text comes
from the browser. 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-field-target="control" />
<p class="form-validation-demo__error" role="alert" 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="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--listbox-target="field" />
<p class="form-validation-demo__error" role="alert" 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;
}
.listbox__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
color: var(--fg, var(--color-text));
font: inherit;
cursor: pointer;
}
.listbox__trigger[aria-expanded="true"] {
border-color: var(--accent, var(--color-primary));
}
.listbox__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.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);
}
.listbox__option[aria-selected="true"] {
font-weight: 600;
color: var(--accent, var(--color-primary));
}
// 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. |