A floating toolbar for acting on the rows a user has selected. It hangs at the bottom centre of the list or table it belongs to, over the rows, and shows while something is selected: a count chip that clears the selection, the actions that fit, and a More menu that lists every action.
The bar never reads a collection. It takes a selected snapshot (how many
rows are selected, how many are on the page, the collection total, and how to
clear or select all) and an actions array, and reports what the user picked
through one callback, onAction(actionId, optionId?). Adapters in
@pindoba/core-bulk-bar build the snapshot: tableSelection from a
Table, idSelection from a plain set of ids, fixedSelection for a
static preview.
Actions are data. One with options is a picker: it sets a field, so its
button opens a Menu of the values and never runs straight away. One
without is a command. group files an action under a heading in More,
tone: "danger" marks a destructive one, and inline says how it shows in
the bar: "label" (icon and text, the default), "icon" (icon only, named by
a tooltip) or "none" (only in More).
Every control is md, and every action’s icon is a component — a Lucide
icon fits as is (@lucide/svelte, lucide-react, @lucide/astro, …). This
demo uses a fixed selection, so clearing it does nothing; the
row list below is live.
---
// GENERATED from packages/demos/bulk-bar/default.demo.tsx — edit the source, not this file.
import BulkBar from "@pindoba/astro-bulk-bar";
import StatusIcon from "./_status-icon.astro";
import OwnerIcon from "./_owner-icon.astro";
import TagIcon from "./_tag-icon.astro";
import MoveIcon from "./_move-icon.astro";
import CopyIcon from "./_copy-icon.astro";
import ArchiveIcon from "./_archive-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { fixedSelection } from "@pindoba/core-bulk-bar";
import { css } from "@pindoba/styled-system/css";
const pane = css({
position: "relative",
width: "100%",
height: "120px",
borderWidth: "1px",
borderStyle: "dashed",
borderColor: "neutral.border.muted",
borderRadius: "md",
});
const actions = [
{
id: "status",
label: "Status",
icon: StatusIcon,
group: "Change",
options: [
{ id: "draft", label: "Draft" },
{ id: "review", label: "In review" },
{ id: "published", label: "Published" },
],
},
{
id: "owner",
label: "Owner",
icon: OwnerIcon,
group: "Change",
options: [
{ id: "ines", label: "Inês Duarte" },
{ id: "kofi", label: "Kofi Mensah" },
{ id: "yuna", label: "Yuna Choi" },
],
},
{
id: "tags",
label: "Tags",
icon: TagIcon,
group: "Change",
options: [
{ id: "finance", label: "Finance" },
{ id: "legal", label: "Legal" },
{ id: "travel", label: "Travel" },
],
},
{
id: "move",
label: "Move to folder",
icon: MoveIcon,
group: "Apply",
options: [
{ id: "shared", label: "Shared" },
{ id: "personal", label: "Personal" },
{ id: "old", label: "Old drafts" },
],
},
{ id: "duplicate", label: "Duplicate", icon: CopyIcon, group: "Apply" },
{
id: "archive",
label: "Archive",
icon: ArchiveIcon,
group: "Apply",
inline: "icon" as const,
},
{
id: "delete",
label: "Delete",
icon: TrashIcon,
group: "Apply",
tone: "danger" as const,
inline: "icon" as const,
},
];
---
<div class={pane}>
<BulkBar selected={fixedSelection(3, 12, 48)} actions={actions} />
</div>More is a regular searchable Menu with submenuMode="drill" and
columns={2}: every action, laid out in its groups, with pickers as submenus
that drill in place. Because it always lists every action, a user who learned
where an action lives still finds it there after the bar narrows. Its search
looks inside the level on screen (the placeholder names it, as in “Search
Status…”), and Backspace in an empty search goes back up a level.
The bar measures itself. Array order is inline priority: as the container
narrows, labelled actions move into More from the end of the list, while
icon-only actions and the count stay. The arithmetic is the pure
fitInline(widths, fixed, available, gap) helper, so a custom layout can reuse
it.
---
// GENERATED from packages/demos/bulk-bar/overflow.demo.tsx — edit the source, not this file.
import BulkBar from "@pindoba/astro-bulk-bar";
import StatusIcon from "./_status-icon.astro";
import OwnerIcon from "./_owner-icon.astro";
import MoveIcon from "./_move-icon.astro";
import CopyIcon from "./_copy-icon.astro";
import ArchiveIcon from "./_archive-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { fixedSelection } from "@pindoba/core-bulk-bar";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const pane = css({
position: "relative",
width: "100%",
height: "120px",
borderWidth: "1px",
borderStyle: "dashed",
borderColor: "neutral.border.muted",
borderRadius: "md",
});
const wide = css({ width: "100%" });
const medium = css({ width: "100%", maxWidth: "460px" });
const narrow = css({ width: "100%", maxWidth: "320px" });
const actions = [
{
id: "status",
label: "Status",
icon: StatusIcon,
group: "Change",
options: [
{ id: "draft", label: "Draft" },
{ id: "review", label: "In review" },
{ id: "published", label: "Published" },
],
},
{
id: "owner",
label: "Owner",
icon: OwnerIcon,
group: "Change",
options: [
{ id: "ines", label: "Inês Duarte" },
{ id: "kofi", label: "Kofi Mensah" },
],
},
{
id: "move",
label: "Move to folder",
icon: MoveIcon,
group: "Apply",
options: [
{ id: "shared", label: "Shared" },
{ id: "personal", label: "Personal" },
],
},
{ id: "duplicate", label: "Duplicate", icon: CopyIcon, group: "Apply" },
{
id: "archive",
label: "Archive",
icon: ArchiveIcon,
group: "Apply",
inline: "icon" as const,
},
{
id: "delete",
label: "Delete",
icon: TrashIcon,
group: "Apply",
tone: "danger" as const,
inline: "icon" as const,
},
];
---
<div class={stack({ gap: "md", width: "100%" })}>
<div class={wide}>
<div class={pane}>
<BulkBar selected={fixedSelection(5, 12, 48)} actions={actions} />
</div>
</div>
<div class={medium}>
<div class={pane}>
<BulkBar selected={fixedSelection(5, 12, 48)} actions={actions} />
</div>
</div>
<div class={narrow}>
<div class={pane}>
<BulkBar selected={fixedSelection(5, 12, 48)} actions={actions} />
</div>
</div>
</div>While some rows are selected and the collection holds more, a Select all
N button sits next to the count. Once every row is selected it goes away and
the count says so (“All 24 selected”). It needs selected.total and
selected.selectAll; leave them out when the total is unknown (a server-side
or streaming collection) and the button never shows.
---
// GENERATED from packages/demos/bulk-bar/select-all.demo.tsx — edit the source, not this file.
import BulkBar from "@pindoba/astro-bulk-bar";
import CopyIcon from "./_copy-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { fixedSelection } from "@pindoba/core-bulk-bar";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const pane = css({
position: "relative",
width: "100%",
height: "120px",
borderWidth: "1px",
borderStyle: "dashed",
borderColor: "neutral.border.muted",
borderRadius: "md",
});
const actions = [
{ id: "duplicate", label: "Duplicate", icon: CopyIcon },
{
id: "delete",
label: "Delete",
icon: TrashIcon,
tone: "danger" as const,
inline: "icon" as const,
},
];
---
<div class={stack({ gap: "md", width: "100%" })}>
<div class={pane}>
<BulkBar selected={fixedSelection(3, 10, 64)} actions={actions} />
</div>
<div class={pane}>
<BulkBar selected={fixedSelection(64, 10, 64)} actions={actions} />
</div>
</div>The count chip sits one surface step down from the bar (countTone="neutral",
the default), so it reads as part of the bar. countTone="highlight" paints it
with the selection color the selected rows wear, to tie the bar to them.
---
// GENERATED from packages/demos/bulk-bar/count-tone.demo.tsx — edit the source, not this file.
import BulkBar from "@pindoba/astro-bulk-bar";
import StatusIcon from "./_status-icon.astro";
import CopyIcon from "./_copy-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { fixedSelection } from "@pindoba/core-bulk-bar";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const pane = css({
position: "relative",
width: "100%",
height: "120px",
borderWidth: "1px",
borderStyle: "dashed",
borderColor: "neutral.border.muted",
borderRadius: "md",
});
const actions = [
{
id: "status",
label: "Status",
icon: StatusIcon,
options: [
{ id: "draft", label: "Draft" },
{ id: "published", label: "Published" },
],
},
{ id: "duplicate", label: "Duplicate", icon: CopyIcon },
{
id: "delete",
label: "Delete",
icon: TrashIcon,
tone: "danger" as const,
inline: "icon" as const,
},
];
---
<div class={stack({ gap: "md", width: "100%" })}>
<div class={pane}>
<BulkBar
selected={fixedSelection(4, 12, 48)}
actions={actions}
countTone="neutral"
/>
</div>
<div class={pane}>
<BulkBar
selected={fixedSelection(4, 12, 48)}
actions={actions}
countTone="highlight"
/>
</div>
</div>surface="inverse" gives the bar the opposite color mode (a dark bar on a
light page), while its menus open in the page’s own mode. The bar follows the
page’s mode from <html data-theme-mode>; pass colorMode to set it yourself.
---
// GENERATED from packages/demos/bulk-bar/inverse.demo.tsx — edit the source, not this file.
import BulkBar from "@pindoba/astro-bulk-bar";
import StatusIcon from "./_status-icon.astro";
import CopyIcon from "./_copy-icon.astro";
import ArchiveIcon from "./_archive-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { fixedSelection } from "@pindoba/core-bulk-bar";
import { css } from "@pindoba/styled-system/css";
const pane = css({
position: "relative",
width: "100%",
height: "120px",
borderWidth: "1px",
borderStyle: "dashed",
borderColor: "neutral.border.muted",
borderRadius: "md",
});
const actions = [
{
id: "status",
label: "Status",
icon: StatusIcon,
options: [
{ id: "draft", label: "Draft" },
{ id: "published", label: "Published" },
],
},
{ id: "duplicate", label: "Duplicate", icon: CopyIcon },
{
id: "archive",
label: "Archive",
icon: ArchiveIcon,
inline: "icon" as const,
},
{
id: "delete",
label: "Delete",
icon: TrashIcon,
tone: "danger" as const,
inline: "icon" as const,
},
];
---
<div class={pane}>
<BulkBar
selected={fixedSelection(2, 12, 48)}
actions={actions}
surface="inverse"
/>
</div>With anchor="container" (the default) the bar floats at the bottom centre of
its nearest positioned ancestor, so put it inside the pane that holds the rows
(position: relative) and leave some bottom padding for the last rows to
scroll out from under it. anchor="viewport" fixes it to the bottom of the
window instead, for a page whose whole content is the collection.
The bar stays mounted while nothing is selected (it is hidden, not removed), so
its announcements and focus handoff survive. persistent keeps it visible
with its controls disabled.
idSelection(selected, pageIds, allIds, set) turns a set of ids into the
snapshot: the ids on screen, the whole collection (omit it when unknown) and a
setter for the next selection. Here the rows are a checkbox
Choice; Archive and Delete remove the selected rows.
Last action: —
---
// Hand-written: `packages/demos/bulk-bar/row-list.demo.tsx` skips Astro (see
// its `skip`). The Choice and the BulkBar render on the server; the script
// below hands the checked ids to the bar through its handle.
import BulkBar from "@pindoba/astro-bulk-bar";
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import MoveIcon from "./_move-icon.astro";
import ArchiveIcon from "./_archive-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { css } from "@pindoba/styled-system/css";
const initialRows = [
{ id: "m1", title: "Venue shortlist for the offsite" },
{ id: "m2", title: "Receipts from the March trip" },
{ id: "m3", title: "Draft agenda, second pass" },
{ id: "m4", title: "Catering quotes" },
{ id: "m5", title: "Speaker bios" },
];
const actions = [
{
id: "move",
label: "Move to",
icon: MoveIcon,
options: [
{ id: "events", label: "Events" },
{ id: "finance", label: "Finance" },
],
},
{ id: "read", label: "Mark as read" },
{
id: "archive",
label: "Archive",
icon: ArchiveIcon,
inline: "icon" as const,
},
{
id: "delete",
label: "Delete",
icon: TrashIcon,
tone: "danger" as const,
inline: "icon" as const,
},
];
// The rows scroll out from under the floating bar.
const pane = css({
position: "relative",
width: "100%",
minHeight: "320px",
paddingBottom: "80px",
});
const log = css({ margin: "0", marginTop: "md", textStyle: "body.sm" });
const selected = ["m1", "m3"];
const ids = initialRows.map((row) => row.id);
---
<div
class={pane}
role="group"
aria-label="Messages and their bulk actions"
data-demo-root="bulk-bar/row-list"
>
<Choice
type="checkbox"
appearance="checkbox"
orientation="vertical"
aria-label="Messages"
value={selected}
>
{initialRows.map((row) => (
<ChoiceItem
value={row.id}
label={row.title}
type="checkbox"
appearance="checkbox"
/>
))}
</Choice>
<p class={log}>
Last action: <span data-demo-last>—</span>
</p>
<BulkBar
selected={{
count: selected.length,
pageCount: ids.length,
pageSelected: selected.length,
total: ids.length,
}}
actions={actions}
/>
</div>
<script>
import { idSelection } from "@pindoba/core-bulk-bar";
import {
bulkBarHydration,
getBulkBar,
type BulkBarActionDetail,
type BulkBarRootElement,
} from "@pindoba/astro-bulk-bar/runtime";
import {
choiceHydration,
choiceItemHydration,
} from "@pindoba/astro-choice/runtime";
function wire(scope: HTMLElement): void {
// Hydrate now (a no-op for what the components' own scripts already
// bound), so the handles exist before this wires them together.
choiceHydration.boot(scope);
choiceItemHydration.boot(scope);
bulkBarHydration.boot(scope);
const choiceEl = scope.querySelector("[data-choice-config]");
const barEl = scope.querySelector<BulkBarRootElement>(
"[data-pindoba-bulk-bar]",
);
const choice = choiceEl ? choiceHydration.get(choiceEl) : undefined;
const bar = getBulkBar(barEl);
const last = scope.querySelector("[data-demo-last]");
if (!choiceEl || !choice || !barEl || !bar) return;
// The rows on screen, by id (each item's hydrated `value`).
const rows = (): { id: string; el: Element }[] =>
[...scope.querySelectorAll("[data-choice-item-config]")].map((el) => ({
id: String(choiceItemHydration.get(el)?.props().value ?? ""),
el,
}));
// Seeded from what the server checked.
let selected = rows()
.filter((row) => row.el.querySelector("input")?.checked)
.map((row) => row.id);
// Choice → bar: the checked ids become the snapshot. Its `clear` /
// `selectAll` write back to the Choice (the chip, Esc).
const sync = (): void => {
const ids = rows().map((row) => row.id);
bar.setSelection(idSelection(new Set(selected), ids, ids, select));
};
function select(next: Iterable<string>): void {
selected = [...next];
choice?.update({ value: selected });
sync();
}
choiceEl.addEventListener("choice:change", (event) => {
const values: unknown =
event instanceof CustomEvent ? event.detail?.values : undefined;
if (!Array.isArray(values)) return;
selected = values.map(String);
sync();
});
sync();
bar.bindScope(scope);
barEl.addEventListener("bulkbar:action", (event) => {
if (!(event instanceof CustomEvent)) return;
const { actionId, optionId }: BulkBarActionDetail = event.detail;
const count = selected.length;
if (last)
last.textContent = optionId
? `${actionId} → ${optionId} on ${count} rows`
: `${actionId} on ${count} rows`;
if (actionId === "delete" || actionId === "archive") {
for (const row of rows())
if (selected.includes(row.id)) {
choiceItemHydration.get(row.el)?.destroy();
row.el.remove();
}
select([]);
}
});
}
document
.querySelectorAll<HTMLElement>('[data-demo-root="bulk-bar/row-list"]')
.forEach(wire);
</script>tableSelection(instance, rowSelection) reads a live Table
instance. The instance is a stable object, so pass the controlled
rowSelection (and, for the page counts, the controlled pagination) that the
table is driven with: reading them is what makes the snapshot recompute. The
snapshot’s clear resets the table’s selection and selectAll selects every
selectable row across pages. Select a few rows to see Select all 24.
Last action: —
---
// Hand-written: `packages/demos/bulk-bar/table.demo.tsx` skips Astro (see its
// `skip`). The Table and the BulkBar render on the server; the script below
// hands the table's live selection to the bar through its handle.
import BulkBar from "@pindoba/astro-bulk-bar";
import Table from "@pindoba/astro-table";
import StatusIcon from "./_status-icon.astro";
import CopyIcon from "./_copy-icon.astro";
import TrashIcon from "./_trash-icon.astro";
import { css } from "@pindoba/styled-system/css";
import type { TableColumnDef } from "@pindoba/core-table";
interface Invoice {
id: string;
client: string;
amount: string;
due: string;
}
const clients = ["Lumen Labs", "Northwind Bakery", "Orbit Cycles", "Pine & Co"];
const invoices = Array.from({ length: 24 }, (_, i) => ({
id: `inv-${101 + i}`,
client: clients[i % clients.length] ?? "",
amount: `$${(120 + i * 45).toLocaleString("en-US")}.00`,
due: `Nov ${(i % 28) + 1}`,
})) as Invoice[];
const columns = [
{ accessorKey: "id", header: "Invoice", enableSorting: false },
{ accessorKey: "client", header: "Client", enableSorting: false },
{ accessorKey: "amount", header: "Amount", enableSorting: false },
{ accessorKey: "due", header: "Due", enableSorting: false },
] as TableColumnDef<Invoice>[];
const actions = [
{
id: "status",
label: "Mark as",
icon: StatusIcon,
options: [
{ id: "sent", label: "Sent" },
{ id: "paid", label: "Paid" },
{ id: "overdue", label: "Overdue" },
],
},
{ id: "duplicate", label: "Duplicate", icon: CopyIcon },
{
id: "delete",
label: "Delete",
icon: TrashIcon,
tone: "danger" as const,
inline: "icon" as const,
},
];
// The bar docks at the bottom of this box, over the table.
const pane = css({
position: "relative",
width: "100%",
paddingBottom: "72px",
});
const log = css({ margin: "0", marginTop: "sm", textStyle: "body.sm" });
---
<div
class={pane}
role="group"
aria-label="Invoices and their bulk actions"
data-demo-root="bulk-bar/table"
>
<Table
data={invoices}
columns={columns}
enableRowSelection
enableSorting={false}
pageSize={8}
caption="Invoices"
selectionColumn={{ rowLabel: "id" }}
/>
<p class={log}>
Last action: <span data-demo-last>—</span>
</p>
<BulkBar actions={actions} />
</div>
<script>
import { tableSelection } from "@pindoba/core-bulk-bar";
import {
bulkBarHydration,
getBulkBar,
type BulkBarActionDetail,
type BulkBarRootElement,
} from "@pindoba/astro-bulk-bar/runtime";
import { tableHydration } from "@pindoba/astro-table/runtime";
function wire(scope: HTMLElement): void {
// Hydrate both now (a no-op for one its own script already bound), so
// their handles exist before this wires them together.
tableHydration.boot(scope);
bulkBarHydration.boot(scope);
const tableEl = scope.querySelector("[data-table-config]");
const barEl = scope.querySelector<BulkBarRootElement>(
"[data-pindoba-bulk-bar]",
);
const handle = tableEl ? tableHydration.get(tableEl) : undefined;
const table = handle?.table;
const bar = getBulkBar(barEl);
const last = scope.querySelector("[data-demo-last]");
if (!table || !barEl || !bar) return;
// Table → bar: every store change (a row ticked, the page turned)
// becomes a new snapshot. Its `clear` / `selectAll` drive the table, so
// the chip, "Select all 24" and Esc write back through the same store.
const sync = (): void =>
bar.setSelection(tableSelection(table, table.store.state.rowSelection));
table.store.subscribe(sync);
sync();
bar.bindScope(scope);
barEl.addEventListener("bulkbar:action", (event) => {
if (!(event instanceof CustomEvent)) return;
const { actionId, optionId }: BulkBarActionDetail = event.detail;
const count = bar.getSelection()?.count ?? 0;
if (last)
last.textContent = optionId
? `${actionId} → ${optionId} on ${count} invoices`
: `${actionId} on ${count} invoices`;
if (actionId === "delete") {
// Drop the selected rows (by identity, across every page), then
// clear the selection and hand the table the rest.
const gone = new Set<unknown>(
table.getSelectedRowModel().rows.map((row) => row.original),
);
table.resetRowSelection(true);
handle?.update({
data: table.options.data.filter((row) => !gone.has(row)),
});
}
});
}
document
.querySelectorAll<HTMLElement>('[data-demo-root="bulk-bar/table"]')
.forEach(wire);
</script>The Astro bar renders on the server and hydrates itself. Its selected is a
snapshot of counts (count, pageCount, pageSelected, total), and its
labels are string templates ("{count} selected"). From then on the client
owns the selection: update it with getBulkBar(el)?.setSelection(snapshot)
(from @pindoba/astro-bulk-bar), and listen on the bar’s root for
bulkbar:clear, bulkbar:selectall and bulkbar:action
(detail: { actionId, optionId }). handle.bindScope(el) wires Esc on a
scope element.
role="toolbar" named by labels.toolbar (“Bulk actions”).aria-keyshortcuts="Escape". Icon-only actions are named by their label
(and show it in a tooltip); picker buttons carry aria-haspopup="menu".role="status" region (aria-live="polite")
sits outside the bar and is always mounted. It speaks when the count changes
(“3 selected”, “All 64 selected”), never on load, and says
labels.cleared (“Selection cleared”) after the selection empties — after
the bar has hidden.bulkScopeKeydown(event, selection) on the element that holds both the
collection and the bar (Astro: handle.bindScope(el)). Escape then clears
a non-empty selection, unless a text field or an open menu has the key or
something already handled it.returnFocus (Svelte, React, Vue,
Solid) names where focus goes instead — usually the checkbox or row the user
came from — so it never falls back to <body>.labels, for
translation.