Form Field
stimeo--form-field
Ties a control to its description and its error, so a screen reader hears them in the right order.
The groundwork that ties a control to its description and its error message. Both are given references automatically and reach a screen reader in the order they appear on screen. A control in error is reported as invalid as well. Setting and clearing an error can be driven from markup, and each change is reported. When an error is raised it is announced through the shared announcer. It performs no validation of its own and brings no styling.
We'll send a confirmation message.
This demo toggles the field's error state — the ARIA wiring this controller owns. It does not validate input or clear the value. "Show error" marks the field invalid and sends the message to the shared assertive announcer; "Clear error" returns it to the valid state.
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the form-field demo.
Associates label / description / error with the control via aria-describedby /
aria-invalid / aria-errormessage. Showing/clearing the error uses the action
parameter (message) and #clearError. The error text is spoken assertively by the
shared stimeo--announcer your app seats once, in its layout — a region that stands
in the accessibility tree ahead of the first error, which is what makes the message
observable. The look switches on data-stimeo--form-field-invalid and aria-invalid. %>
<div class="form-field-demo" data-controller="stimeo--form-field"
data-stimeo--form-field-focus-on-error-value="true">
<label class="form-field-demo__label" for="ff-email">
<%= t("components.form_field.demo.label") %>
</label>
<input class="form-field-demo__control" id="ff-email" type="email" aria-invalid="false"
placeholder="<%= t('components.form_field.demo.placeholder') %>"
data-stimeo--form-field-target="control" />
<%# Helper description. It is assigned an id and linked into the control's
aria-describedby. %>
<p class="form-field-demo__description" data-stimeo--form-field-target="description">
<%= t("components.form_field.demo.hint") %>
</p>
<%# Note clarifying the demo's intent (this part only wires up the error state;
it doesn't validate or clear the value). %>
<p class="form-field-demo__note"><%= t("components.form_field.demo.note") %></p>
<%# Visual error region. Starts hidden; speech is delegated to the shared announcer. %>
<p class="form-field-demo__error" hidden
data-stimeo--form-field-target="error"></p>
<div class="form-field-demo__actions">
<%# setError reads the message from the action parameter (no consumer JS needed). %>
<button type="button" class="demo-trigger"
data-stimeo--form-field-message-param="<%= t('components.form_field.demo.error') %>"
data-action="click->stimeo--form-field#setError">
<%= t("components.form_field.demo.validate") %>
</button>
<button type="button" class="demo-trigger"
data-action="click->stimeo--form-field#clearError">
<%= t("components.form_field.demo.clear") %>
</button>
</div>
<%# Helper region that subscribes to the validate event and shows the valid
state only (updated by demo.js). %>
<p class="form-field-demo__status" id="ff-status" aria-live="polite"
data-valid-text="<%= t('components.form_field.demo.valid') %>"></p>
</div>
/*
* Presentation-only styles for the form-field demo.
* The invalid state is expressed via data-stimeo--form-field-invalid (root) and
* aria-invalid (control).
*/
.form-field-demo {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-width: 22rem;
}
.form-field-demo__label {
font-weight: 600;
}
.form-field-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-field-demo__control:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Show the invalid state (aria-invalid) with an error-colored border. */
.form-field-demo__control[aria-invalid="true"] {
border-color: var(--danger-500);
}
.form-field-demo__description {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
/* Note conveying the demo's intent. Shown in a more muted tone than the description. */
.form-field-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-field-demo__error {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--danger-500);
}
.form-field-demo__actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.form-field-demo__status {
margin: 0;
min-height: 1.25rem;
font-size: 0.85rem;
color: var(--leaf-500);
}
// Demo script that subscribes to the form-field validate event and updates the status text.
// The error message is read by the shared announcer, so this region shows the valid state
// only and never duplicates that wording.
// Listen on the demo root (the controller element the event is dispatched on / bubbles
// through), not document: a document listener survives Turbo body swaps and stacks up on
// every navigate-away→back, whereas a root-scoped listener is torn down with the body.
const root = document.querySelector('.form-field-demo');
root?.addEventListener('stimeo--form-field:validate', function (event) {
const status = document.getElementById('ff-status');
if (!status) return;
if (event.detail.valid) {
status.textContent = status.dataset.validText || '';
status.style.color = '#16a34a';
} else {
status.textContent = '';
}
});
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-field"
Targets
| Name | Description | Attribute |
|---|---|---|
control
required
|
The form control whose aria-invalid, aria-describedby, and aria-errormessage the controller wires. |
data-stimeo--form-field-target="control" |
description
|
Helper-text element auto-assigned an id and composed into the control's aria-describedby. |
data-stimeo--form-field-target="description" |
error
|
Visual error shown/hidden and referenced by aria-errormessage; speech is delegated to the shared announcer. |
data-stimeo--form-field-target="error" |
Values
| Name | Description | Attribute |
|---|---|---|
focusOnError
|
Whether to move focus to the control when an error is set (default false). | data-stimeo--form-field-focus-on-error-value |
Actions
| Name | Description | Action |
|---|---|---|
clearError
|
Empties and hides every error region and marks the field valid. | stimeo--form-field#clearError |
setError
|
Marks the field invalid and shows the error message; programmatic calls can override focusOnError for that call with { focus }. |
stimeo--form-field#setError |
Events
| Name | Description | Event |
|---|---|---|
validate
|
Fires on setError/clearError; detail { valid, message }. |
stimeo--form-field:validate |
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 |
|---|---|---|
aria-describedby |
Control | Composed from the description and any shown error ids. |
aria-invalid |
Control | "true" while an error is shown, otherwise "false". |
aria-errormessage |
Control | Points at the shown error region; removed when valid. |
hidden |
Error | Present when there is no error, removed when one is shown. |
data-stimeo--form-field-invalid |
Root | Present while the field is invalid (a CSS hook). |