フォームバリデーション
stimeo--form-validation
ネイティブ制約検証のオーケストレーション。検証タイミング・送信ブロック・フォーカス・メッセージ配線を統率します。
stimeo--form-validation は、ブラウザ標準の Constraint Validation API のタイミング層です。標準のエラー吹き出しを novalidate で抑止し、submit 時に全コントロールを検証して、不正なら他の submit ハンドラが反応する前に送信をキャンセルします。フィールドは blur で touched となって検証され、input で再検証、change は確定操作として即検証。不正時は最初の不正フィールドの可視コントロールへフォーカスを移します。validationMessage は各フィールドの stimeo--form-field(outlet)経由で表示するため、ARIA 配線の実装は 1 箇所に保たれます。ルールも文言もネイティブ(required / type / pattern / setCustomValidity)のままで、コントローラはどちらも発明しません。リッチウィジェットは検証可能ミラー(type="hidden" ではなく hidden 属性の input)で追加 JS なしで参加します。
<%# 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));
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--form-validation"
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
validateOnBlur
|
blur(focusout)でそのフィールドを touched にして検証するか(既定 true)。 |
data-stimeo--form-validation-validate-on-blur-value |
validateOnChange
|
change(select の選択やウィジェットのミラー書き込みなど確定操作)で即検証するか(既定 true)。 |
data-stimeo--form-validation-validate-on-change-value |
revalidateOnInput
|
touched 済みフィールドを input のたびに再検証し、エラー解消を即時反映するか(既定 true)。 |
data-stimeo--form-validation-revalidate-on-input-value |
focusInvalid
|
送信ブロック時に最初の不正フィールドの可視コントロールへフォーカスするか(既定 true)。 |
data-stimeo--form-validation-focus-invalid-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
validate
|
全コントロールを今すぐ検証し、フォーム全体が有効かを返します。 | stimeo--form-validation#validate |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
valid
|
検証を通過した submit 時に発火。detail は {}。 |
stimeo--form-validation:valid |
invalid
|
submit をブロックした時に発火。detail は { invalid: HTMLElement[] }(フィールド単位に集約)。 |
stimeo--form-validation:invalid |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
novalidate |
フォーム | 接続時に付与してブラウザ標準の吹き出しを抑止。切断時、コントローラが付けた場合のみ除去します。 |
data-stimeo--form-validation-novalidate |
フォーム | novalidate を(作者ではなく)コントローラが付けた印。作者由来の属性を誤って消さないためのマーカーです。 |