一括選択バー
stimeo--bulk-select
行のチェックと一括操作バーを連動させ、選んだ件数を読み上げます。
表の行を選ぶチェックと、下に貼りつく一括操作バーを連動させます。全選択のチェックは、一部だけ選ばれている状態も正しく示します。1 件でも選ぶとバーが現れ、選んだ件数を持ちます。ページをまたいで全部を選ぶ二段構えの操作も用意しています。選んだ状態は各チェックだけが持つので、Turbo でページが差し替わっても数え直すだけで元に戻ります。行はあとから増えても減っても、そのまま動きます。バーが出てもフォーカスは奪いません。バーが消えるときにその中を触っていたら、全選択のチェックへフォーカスを渡します。選んだ件数は共通の窓口から読み上げられます。一括操作の中身は利用側で書きます。
この表は全 42 人のうち 1 / 14 ページ目です。画面に出ているのはこのページの行だけです。
| 名前 | 役割 | |
|---|---|---|
| 山田太郎 | エンジニア | |
| 鈴木花子 | 研究員 | |
| 佐藤次郎 | エンジニア |
他のページの行も含めて、全 42 件を選択しています。
キーボード操作
このコンポーネント自体はキーボード操作を持ちません。
<%# 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}`);
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--bulk-select"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
all
|
全選択チェックボックス。各行へ反映し、行の状態を映す。 | data-stimeo--bulk-select-target="all" |
item
必須
|
行チェックボックス。選択状態はここに保持(変更は委譲で処理)。 | data-stimeo--bulk-select-target="item" |
bar
必須
|
一括操作バー。選択に応じて hidden を切り替える。role に合わせ stimeo--toolbar を併用する。 |
data-stimeo--bulk-select-target="bar" |
count
|
選択(または総)件数のテキストを設定する要素。 | data-stimeo--bulk-select-target="count" |
selectAllPages
|
全ページ選択モードに入る任意のコントロール。 | data-stimeo--bulk-select-target="selectAllPages" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
totalCount
|
全ページの総行数。全ページモードで表示する(既定 0)。 | data-stimeo--bulk-select-total-count-value |
announceText
|
告知の文言。{count} が表示中の件数に展開される。空なら告知しない(既定は空)。 |
data-stimeo--bulk-select-announce-text-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
clear
|
全行と全選択チェックを解除し、全ページモードを抜ける。 | stimeo--bulk-select#clear |
selectAllPages
|
全ページ選択モードに入る(件数は totalCount を表示)。 |
stimeo--bulk-select#selectAllPages |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
利用者が選択を動かしたときに発火。detail に count と allPages を伴う。 |
stimeo--bulk-select:change |
reconcile
|
コントローラ自身が件数を直したとき(ページ側の行増減・描画入力の変更)に発火。detail は同型。 |
stimeo--bulk-select:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
hidden |
アクションバー | 未選択のあいだ付与される(バー非表示)。 |
data-selected-count |
ルート要素 | 現在選択中の行数。 |
data-all-pages |
ルート要素 | 全ページ選択モードが有効なあいだ "true"。 |
indeterminate |
全選択チェックボックス | 一部だけ選択しているときに付与される。 |