動的フィールド行
stimeo--nested-form
fields_for の明細行をテンプレートから追加・削除する Headless 版 cocoon。
stimeo--nested-form コントローラは、Rails の fields_for + accepts_nested_attributes_for における cocoon / nested_form gem の Headless 後継です。追加時は <template> を複製して __INDEX__ プレースホルダを一意の連番へ置換して挿入し、削除時は未保存行を DOM から除去、 _destroy フラグを持つ既存行はフラグを 1 にして hidden 化(送信時に Rails が破棄)します。行の状態は DOM のみに保持し(モジュールスコープのカウンタを持たない)、connect は Turbo 遷移後も冪等に再計算、削除ボタンはイベント委譲で扱うため複製行も個別結線なしで動作します。追加時は新規行の先頭コントロールへ、削除時は隣接行へフォーカスを移し(WCAG 2.2 2.4.3)、announce と countMessage を設定すると件数変化を共有の stimeo--announcer で告知します(WCAG 2.2 4.1.3)。 min/max を制御し、stimeo--nested-form:add / :remove を発火します。ライブラリは挙動のみを提供し、サーバ側の nested 属性と見た目は利用側が持ちます。
<%# Markup for the nested / dynamic fields demo (cocoon-style line items).
The controller clones the <template> row (renumbering __INDEX__), removes rows by
dropping unsaved ones / flagging _destroy on persisted ones, enforces min/max, and
announces the count through a shared stimeo--announcer. Remove buttons are handled
by delegation, so cloned rows need no per-row wiring. %>
<%
item_label = t("components.nested_form.demo.item_label")
remove_label = t("components.nested_form.demo.remove")
count_message = t("components.nested_form.demo.count_message")
%>
<div class="nested-demo" data-controller="stimeo--nested-form"
data-stimeo--nested-form-min-value="1"
data-stimeo--nested-form-max-value="5"
data-stimeo--nested-form-count-message-value="<%= count_message %>">
<div class="nested-demo__list" data-stimeo--nested-form-target="list">
<fieldset class="nested-demo__row">
<label class="nested-demo__field">
<span><%= item_label %></span>
<input type="text" class="demo-input" name="order[items_attributes][0][name]">
</label>
<button type="button" class="demo-trigger"
data-stimeo--nested-form-target="remove"><%= remove_label %></button>
</fieldset>
</div>
<template data-stimeo--nested-form-target="template">
<fieldset class="nested-demo__row">
<label class="nested-demo__field">
<span><%= item_label %></span>
<input type="text" class="demo-input" name="order[items_attributes][__INDEX__][name]">
</label>
<button type="button" class="demo-trigger"
data-stimeo--nested-form-target="remove"><%= remove_label %></button>
</fieldset>
</template>
<button type="button" class="demo-trigger" data-stimeo--nested-form-target="add"
data-action="click->stimeo--nested-form#add">
<%= t("components.nested_form.demo.add") %>
</button>
<%# Shared announcer: row count changes are read out without moving focus away. %>
<div data-controller="stimeo--announcer">
<div class="visually-hidden" data-stimeo--announcer-target="polite"
aria-live="polite" aria-atomic="true"></div>
</div>
</div>
/*
* Presentation-only styles for the nested / dynamic fields demo.
* The library clones rows, flags/hides destroyed ones, and reflects
* data-nested-count / data-nested-at-min / data-nested-at-max; this CSS owns the
* row layout and the disabled add-button affordance.
*/
.nested-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 32rem;
}
.nested-demo__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nested-demo__row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
margin: 0;
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.nested-demo__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
font-size: 0.85rem;
}
/* The library disables the add button once data-nested-at-max is reached. */
.nested-demo .demo-trigger[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
// Consumer-side JS for the nested-form demo (demo-only).
// The controller handles add/remove, focus, and the count announcement on its own;
// this only logs the add/remove events to show the event contract consumers can
// hook for analytics, totals, or enabling a submit button.
document.addEventListener("stimeo--nested-form:add", (event) => {
console.log(`[nested-form] added row index=${event.detail.index}`);
});
document.addEventListener("stimeo--nested-form:remove", (event) => {
console.log(`[nested-form] removed row persisted=${event.detail.persisted}`);
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--nested-form"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
list
必須
|
新規行を追加し、件数を数える対象のコンテナ。 | data-stimeo--nested-form-target="list" |
template
必須
|
各新規行の複製元となる <template>。 |
data-stimeo--nested-form-target="template" |
add
|
追加ボタン。上限到達時に自動で無効化される。 | data-stimeo--nested-form-target="add" |
remove
|
行ごとの削除ボタン(委譲で処理)。 | data-stimeo--nested-form-target="remove" |
destroyFlag
|
既存行の hidden な _destroy。削除時に 1 を設定する。 |
data-stimeo--nested-form-target="destroyFlag" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
min
|
最小行数。ここで削除が止まる(既定 0)。 | data-stimeo--nested-form-min-value |
max
|
最大行数。0 は無制限(既定 0)。 | data-stimeo--nested-form-max-value |
indexPlaceholder
|
テンプレ内で行ごとに置換するプレースホルダ(既定 __INDEX__)。 |
data-stimeo--nested-form-index-placeholder-value |
announce
|
件数変化を告知するか(既定 true)。 |
data-stimeo--nested-form-announce-value |
countMessage
|
{count} トークンを含む読み上げテンプレート(既定は空)。 |
data-stimeo--nested-form-count-message-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
add
|
テンプレートを複製・採番して挿入し、新規行へフォーカスする。 | stimeo--nested-form#add |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
add
|
行追加後に発火。detail に index と element を伴う。 |
stimeo--nested-form:add |
remove
|
行削除後に発火。detail に element と persisted を伴う。 |
stimeo--nested-form:remove |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-nested-count |
ルート要素 | 現在の有効(破棄されていない)行数。 |
data-nested-at-min / data-nested-at-max |
ルート要素 | 行数が下限 / 上限に達したとき "true"。 |
hidden |
既存行 | _destroy を立てたとき付与され、送信時に削除される。 |