Nested Form
stimeo--nested-form
Adds and removes rows in a form, submitted the way Rails nested attributes expect.
Adds rows to a form by cloning your template, numbering them so their names never collide. Removing takes an unsaved row straight out, while a row that has already been saved is marked for deletion and hidden, so submitting lets Rails destroy it. The row state lives only in the page, so a Turbo swap simply counts them again. Adding moves focus to the start of the new row and removing moves it to the neighbouring one, so the keyboard never loses its place. How many rows there are can be announced, and you can set a minimum and a maximum. It ships no markup and no CSS.
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the nested / dynamic fields demo (invoice-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
hands the row count to the shared stimeo--announcer your app seats once, in its
layout (count changes are read out without moving focus away; the row list owns no
live region of its own). 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")
persisted_value = t("components.nested_form.demo.persisted_value")
%>
<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>
<%# A persisted row: its _destroy flag makes Remove hide it instead of deleting it. %>
<fieldset class="nested-demo__row">
<input type="hidden" name="order[items_attributes][1][_destroy]" value="0"
data-stimeo--nested-form-target="destroyFlag">
<label class="nested-demo__field">
<span><%= item_label %></span>
<input type="text" class="demo-input" value="<%= persisted_value %>"
name="order[items_attributes][1][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>
</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; auto-disabled at the max only while max is set, restoring the authored disabled on teardown. | 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 |
reconcile
|
Fires when external row changes or runtime min/max changes move the published state; detail carries count, atMin, and atMax. |
stimeo--nested-form:reconcile |
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. |