Form Field
stimeo--form-field
Wires a control to its description and error: aria-describedby, aria-invalid, and aria-errormessage.
The stimeo--form-field controller is the association substrate behind a form control. It assigns ids to the description and error regions and composes the control's aria-describedby, toggles aria-invalid, and points aria-errormessage at the live error region. setError (via an action param) and clearError drive the invalid state and dispatch stimeo--form-field:validate. It does no validation itself and ships no styling — the look is owned by this Playground.
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 announces the message via a role="alert" region; "Clear error" returns it to the valid state.
<%# 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 look (e.g. the invalid border) 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. On connect it's 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>
<%# Error region. role="alert" so it's announced without moving focus. Starts hidden. %>
<p class="form-field-demo__error" role="alert" 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 to show status (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 itself is consolidated in the role="alert" region; the lower status
// is shown only on success.
// 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
|
Live error region (ideally role=alert) shown/hidden and referenced by aria-errormessage. |
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 (from a string or the action's message param). |
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). |