Nested Form
stimeo--nested-form
Adds/removes fields_for rows from a template — the Headless cocoon.
The stimeo--nested-form controller is the Headless successor to the cocoon / nested_form gems for Rails fields_for + accepts_nested_attributes_for. Adding a row clones a <template>, replaces its __INDEX__ placeholder with a unique index, and appends it; removing a row drops an unsaved one from the DOM or, for a persisted row carrying a _destroy flag, sets the flag to 1 and hides it so Rails destroys it on submit. Row state lives only in the DOM (no module-scope counter), so connect recomputes idempotently after a Turbo swap, and remove buttons are handled by delegation so cloned rows work without per-row wiring. Adding moves focus to the new row's first control and removing returns focus to a neighbor (WCAG 2.2 2.4.3); count changes are announced through the shared stimeo--announcer when announce + countMessage are set (WCAG 2.2 4.1.3). It enforces min/max and dispatches stimeo--nested-form:add / :remove. Behavior only — the server-side nested attributes and all styling stay with the consumer.
<%# 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}`);
});
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--nested-form"
Targets
| Name | Description | Attribute |
|---|---|---|
list
required
|
The container new rows are appended to and counted from. | data-stimeo--nested-form-target="list" |
template
required
|
The <template> whose markup is cloned for each new row. |
data-stimeo--nested-form-target="template" |
add
|
The add button; disabled automatically at the max. | data-stimeo--nested-form-target="add" |
remove
|
A per-row remove button (handled via delegation). | data-stimeo--nested-form-target="remove" |
destroyFlag
|
A persisted row's hidden _destroy input, set to 1 on removal. |
data-stimeo--nested-form-target="destroyFlag" |
Values
| Name | Description | Attribute |
|---|---|---|
min
|
Minimum number of rows; removal stops here (default 0). | data-stimeo--nested-form-min-value |
max
|
Maximum number of rows; 0 means unlimited (default 0). | data-stimeo--nested-form-max-value |
indexPlaceholder
|
Placeholder in the template replaced per row (default __INDEX__). |
data-stimeo--nested-form-index-placeholder-value |
announce
|
Whether count changes are announced (default true). |
data-stimeo--nested-form-announce-value |
countMessage
|
Announcement template with a {count} token (default empty). |
data-stimeo--nested-form-count-message-value |
Actions
| Name | Description | Action |
|---|---|---|
add
|
Clones the template, renumbers it, appends and focuses the new row. | stimeo--nested-form#add |
Events
| Name | Description | Event |
|---|---|---|
add
|
Fires after a row is added; detail carries the index and element. |
stimeo--nested-form:add |
remove
|
Fires after a row is removed; detail carries the element and persisted. |
stimeo--nested-form:remove |
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 |
|---|---|---|
data-nested-count |
Root element | The current number of effective (non-destroyed) rows. |
data-nested-at-min / data-nested-at-max |
Root element | "true" when the row count reaches the min / max bound. |
hidden |
A persisted row | Added when its _destroy flag is set, so it is removed on submit. |