Bulk Select
stimeo--bulk-select
Links row checkboxes to a sticky batch action bar with a live count.
The stimeo--bulk-select controller links a select-all checkbox to the row checkboxes (with the indeterminate middle state), reveals a sticky action bar once one or more rows are selected, keeps the selected count, and offers a two-step "select all across pages" mode. Data Grid owns per-row selection; this is the contextual action-bar layer on top. Selection lives only in each checkbox's checked state (no module-scope set), so connect recomputes idempotently after a Turbo swap. Row changes are handled by delegation, so dynamically-added rows work without per-row wiring. Showing the bar never steals focus (WCAG 2.2 2.4.3); the count rides the bar's own aria-live region (WCAG 2.2 4.1.3). It dispatches stimeo--bulk-select:change, and the delegated listener is removed on disconnect (Turbo included). Behavior only — running the batch action and all styling belong to this Playground.
| Name | Role | |
|---|---|---|
| Ada Lovelace | Engineer | |
| Alan Turing | Researcher | |
| Grace Hopper | Engineer |
<%# Markup for the bulk-select / batch action bar demo.
The controller links the select-all box to the rows (with indeterminate), reveals
the sticky bar once one or more rows are selected, keeps the count, and announces
it through the bar's own aria-live region. Row changes are handled by delegation,
so the "Add row" button (demo.js) works without per-row wiring. %>
<%# Sample rows come from the locale file so the table reads in the page language. %>
<%
rows = t("components.bulk_select.demo.rows")
%>
<div class="bulk-demo" data-controller="stimeo--bulk-select"
data-stimeo--bulk-select-total-count-value="42">
<table class="bulk-demo__table">
<thead>
<tr>
<th scope="col">
<input type="checkbox" data-stimeo--bulk-select-target="all"
aria-label="<%= t("components.bulk_select.demo.select_all") %>">
</th>
<th scope="col"><%= t("components.bulk_select.demo.col_name") %></th>
<th scope="col"><%= t("components.bulk_select.demo.col_role") %></th>
</tr>
</thead>
<tbody data-bulk-demo="rows">
<% rows.each do |row| %>
<% row_label = t("components.bulk_select.demo.select_row", name: row[:name]) %>
<tr>
<td>
<input type="checkbox" data-stimeo--bulk-select-target="item"
aria-label="<%= row_label %>">
</td>
<td><%= row[:name] %></td>
<td><%= row[:role] %></td>
</tr>
<% end %>
</tbody>
</table>
<%# Localized templates for demo.js's appended rows ({n} = row number, {name} = the
generated name), so dynamically-added rows match the page locale too. %>
<button class="demo-trigger" type="button" data-bulk-demo="add"
data-bulk-add-name="<%= t("components.bulk_select.demo.add_name_template") %>"
data-bulk-add-role="<%= t("components.bulk_select.demo.add_role") %>"
data-bulk-add-label="<%= t("components.bulk_select.demo.add_row_label") %>">
<%= t("components.bulk_select.demo.add_row") %>
</button>
<%# Sticky batch action bar: its aria-live region announces the count. %>
<div class="bulk-demo__bar" data-stimeo--bulk-select-target="bar" hidden
role="toolbar" aria-live="polite"
aria-label="<%= t("components.bulk_select.demo.bar_label") %>">
<span class="bulk-demo__count">
<span data-stimeo--bulk-select-target="count">0</span>
<%= t("components.bulk_select.demo.selected_suffix") %>
</span>
<button class="demo-trigger" type="button"
data-stimeo--bulk-select-target="selectAllPages"
data-action="click->stimeo--bulk-select#selectAllPages">
<%= t("components.bulk_select.demo.select_all_pages") %>
</button>
<button class="demo-trigger" type="button"
data-action="click->stimeo--bulk-select#clear">
<%= t("components.bulk_select.demo.clear") %>
</button>
</div>
</div>
/*
* Presentation-only styles for the bulk-select demo.
* The library toggles the bar's hidden attribute, the select-all indeterminate
* state, and data-selected-count; this CSS owns the table and sticky bar styling.
*/
.bulk-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 36rem;
}
.bulk-demo__table {
width: 100%;
border-collapse: collapse;
}
.bulk-demo__table th,
.bulk-demo__table td {
padding: 0.5rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.bulk-demo__bar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
border: 1px solid var(--color-primary);
border-radius: 0.5rem;
background: var(--color-primary-soft);
color: var(--vital-800);
}
.bulk-demo__bar[hidden] {
display: none;
}
.bulk-demo__count {
font-weight: 600;
margin-right: auto;
}
// Consumer-side JS for the bulk-select demo (demo-only).
// Demonstrates that rows added after connect are handled by the controller's
// delegated change listener — no per-row wiring needed. The "Add row" button
// appends a new row; checking it updates the count and bar just like the rest.
const tbody = document.querySelector("[data-bulk-demo='rows']");
const addButton = document.querySelector("[data-bulk-demo='add']");
// Idempotent: Turbo can re-run this inline module on navigation, so wire once (keyed on
// the Add-row button). Both listeners attach to elements inside the demo that are
// discarded with the <body> on a Turbo visit, so they are torn down rather than stacked.
if (tbody && addButton && !addButton.dataset.demoWired) {
addButton.dataset.demoWired = "1";
let nextId = tbody.querySelectorAll("tr").length + 1;
// Localized templates passed from the ERB via data attributes ({n} = row number,
// {name} = the generated name), so appended rows match the page locale just like
// the server-rendered ones.
const nameTemplate = addButton.dataset.bulkAddName ?? "New person {n}";
const role = addButton.dataset.bulkAddRole ?? "Member";
const labelTemplate = addButton.dataset.bulkAddLabel ?? "Select {name}";
addButton.addEventListener("click", () => {
const name = nameTemplate.replace("{n}", String(nextId));
const label = labelTemplate.replace("{name}", name);
const tr = document.createElement("tr");
tr.innerHTML =
`<td><input type="checkbox" data-stimeo--bulk-select-target="item"` +
` aria-label="${label}"></td>` +
`<td>${name}</td><td>${role}</td>`;
tbody.appendChild(tr);
nextId += 1;
});
// Example of reacting to the selection-change event (analytics, enabling a button…).
// Listen on the demo root (the controller element the event is dispatched on), not
// document: a document listener would survive Turbo body swaps and stack up on every
// navigate-away→back, whereas this is torn down with the body.
addButton.closest(".bulk-demo")?.addEventListener("stimeo--bulk-select:change", (event) => {
console.log(`[bulk-select] count=${event.detail.count} allPages=${event.detail.allPages}`);
});
}
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--bulk-select"
Targets
| Name | Description | Attribute |
|---|---|---|
all
|
The select-all checkbox; mirrors to every row and reflects their state. | data-stimeo--bulk-select-target="all" |
item
required
|
A row checkbox; selection state lives here (changes handled by delegation). | data-stimeo--bulk-select-target="item" |
bar
required
|
The batch action bar, toggled hidden by selection; carries the live count. |
data-stimeo--bulk-select-target="bar" |
count
|
The element whose text is set to the selected (or total) count. | data-stimeo--bulk-select-target="count" |
selectAllPages
|
Optional control that enters select-all-across-pages mode. | data-stimeo--bulk-select-target="selectAllPages" |
Values
| Name | Description | Attribute |
|---|---|---|
totalCount
|
Total rows across all pages, shown in all-pages mode (default 0). | data-stimeo--bulk-select-total-count-value |
announce
|
Whether the bar announces count changes via aria-live (default true). |
data-stimeo--bulk-select-announce-value |
Actions
| Name | Description | Action |
|---|---|---|
clear
|
Unchecks every row and the select-all box, and exits all-pages mode. | stimeo--bulk-select#clear |
selectAllPages
|
Enters select-all-across-pages mode (count shows totalCount). |
stimeo--bulk-select#selectAllPages |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the selection changes; detail carries count and allPages. |
stimeo--bulk-select:change |
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 |
|---|---|---|
hidden |
Action bar | Present (bar hidden) while nothing is selected. |
data-selected-count |
Root element | The number of currently-selected rows. |
data-all-pages |
Root element | "true" while the select-all-pages mode is active. |
indeterminate |
Select-all checkbox | Set when some, but not all, rows are selected. |