Bulk Select
stimeo--bulk-select
Links row checkboxes to a batch action bar and announces how many rows are selected.
Links the checkboxes on a table's rows to a batch action bar that sticks to the screen. The select-all checkbox also shows the state where only some rows are chosen. Choosing one row brings the bar in, showing how many are selected. There is a two-step way to select everything across pages as well. The selection lives only in the checkboxes themselves, so a Turbo page swap only has to be counted again. Rows can appear and disappear later and still work. The bar never steals focus when it appears, and if you are inside it when it goes away, focus is handed to the select-all checkbox. The count is announced through the shared announcer. What the batch action does is up to you.
This table is page 1 of 14 — only this page's rows are on screen, out of 42 people in total.
| Name | Role | |
|---|---|---|
| Ada Lovelace | Engineer | |
| Alan Turing | Researcher | |
| Grace Hopper | Engineer |
All 42 are selected, including the rows on the other pages.
Keyboard
This component has no keyboard interactions of its own.
<%# 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 hands that
count to the shared stimeo--announcer your app seats once, in its layout, using the
wording in announce-text-value (the bar is revealed with its count already in place,
which a live region of its own could not announce). Row changes are handled by
delegation, so the "Add row" button (demo.js) works without per-row wiring, and rows
it appends are reconciled on their own. %>
<%# Sample rows come from the locale file so the table reads in the page language. %>
<%
rows = t("components.bulk_select.demo.rows")
announce_text = t("components.bulk_select.demo.announce_text")
%>
<%# The two-stage pattern only reads if the page says it is one page of many, so the
demo states where total-count-value's 42 comes from. Nothing here is required by
the controller — a single-page table simply omits the selectAllPages control. %>
<div class="bulk-demo" data-controller="stimeo--bulk-select"
data-stimeo--bulk-select-total-count-value="42"
data-stimeo--bulk-select-announce-text-value="<%= announce_text %>">
<p class="bulk-demo__context"><%= t("components.bulk_select.demo.page_context") %></p>
<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. It carries role="toolbar", so stimeo--toolbar is composed
onto it for the arrow-key movement and single tab stop that role calls for. %>
<div class="bulk-demo__bar" data-stimeo--bulk-select-target="bar" hidden
role="toolbar" data-controller="stimeo--toolbar"
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-stimeo--toolbar-target="control"
data-action="click->stimeo--bulk-select#selectAllPages">
<%= t("components.bulk_select.demo.select_all_pages") %>
</button>
<button class="demo-trigger" type="button"
data-stimeo--toolbar-target="control"
data-action="click->stimeo--bulk-select#clear">
<%= t("components.bulk_select.demo.clear") %>
</button>
</div>
<%# Shown only while the whole set is selected. CSS reads data-all-pages on the
root, so the demo needs no script to keep this in step with the mode. %>
<p class="bulk-demo__all-pages-note"><%= t("components.bulk_select.demo.all_pages_note") %></p>
</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;
}
.bulk-demo__context {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
/*
* The all-pages note belongs to the mode, not to the selection: the controller
* writes data-all-pages on the root, so the note follows it without any script.
*/
.bulk-demo__all-pages-note {
margin: 0;
padding: 0.5rem 0.7rem;
border-radius: 0.4rem;
background: var(--color-primary-soft);
color: var(--vital-800);
font-size: 0.9rem;
}
.bulk-demo:not([data-all-pages="true"]) .bulk-demo__all-pages-note {
display: none;
}
// 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.
// The marker is a property, not an attribute: Turbo copies attributes into its page
// snapshot, so an attribute one comes back set on a restored page whose elements carry
// no listeners.
if (tbody && addButton && !addButton.demoWired) {
addButton.demoWired = true;
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; compose with stimeo--toolbar for its role. |
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 |
announceText
|
Announcement wording; {count} expands to the figure shown. Empty stays silent (default empty). |
data-stimeo--bulk-select-announce-text-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 you move the selection; detail carries count and allPages. |
stimeo--bulk-select:change |
reconcile
|
Fires when the controller repairs the count itself (rows added or removed by the page, or a render input changing); same detail. |
stimeo--bulk-select: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 |
|---|---|---|
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. |