フォームバリデーション
stimeo--form-validation
ブラウザ標準の検証を、いつ出すかまで含めて整えます。送信も止め、最初のエラーへ案内します。
ブラウザが元から持っている入力の検証を、出すタイミングまで含めて整えます。ブラウザ標準の吹き出しは抑え、代わりに画面の中へエラーを出します。検証するのは、入力欄をいったん離れたあとです。まだ触っていない欄に先回りして赤を出すことはありません。一度エラーになった欄は、打ち直すたびに見直します。送信のときは全部を見て、問題があれば送信を止め、最初のエラーの欄までフォーカスを運びます。エラーの文言は制約ごとに言語別へ差し替えられます。空白だけの入力を弾くこともできます。自作の入力部品も、隠した入力欄を通して同じ仕組みに参加できます。
キーボード操作
このコンポーネント自体はキーボード操作を持ちません。
<%# 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));
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の 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 を(作者ではなく)コントローラが付けた印。作者由来の属性を誤って消さないためのマーカーです。 |