Pattern gallery

Bulk selection action bar

A soft-gray bar grows under the table once rows are picked, carrying a live count and the actions sized to it.

What this solves

Gestalt hierarchy for a selection-driven toolbar: bulk actions only exist once something is picked, so the bar itself is the affordance rather than a permanently-visible button that's disabled most of the time. The count lives in one place, the bar's own label, instead of a separate counter the reader has to cross-reference against the action.
Use it when
  • A table's select column turns real and a bulk action needs to act on the picked rowswalmart-mvp ErrorManagement.tsx: 'Skip selected (N)' replaces a static button once anything is checked
  • One operation has to apply to every checked row, and each outcome needs its own reportspeedway review.tsx: ReviewBulkBar, one op per checked row, bulk never guesses a value

Rendered

Fixture data; check both themes.

Flagged rows

5 rows, 0 selected
SKUItemFlag
BRK-4471Ceramic brake pad setMissing image
FLT-2290Cabin air filterPrice below floor
SPK-8812Iridium spark plugDuplicate SKU
OIL-1130Full-synthetic 5W-30Missing weight
WPR-6003All-season wiper bladeUnmapped category
patterns/bulk-selection-bar/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { Button, Card, SelectAllCell, SelectionCell, Table, Tooltip, pushAlert, useTableSelection } from "@versable-git/ui";
import type { TableColumn } from "@versable-git/ui";
import { useState } from "react";
// Both apps grow the same second toolbar row once a table's select column
// turns real: a live count, and an action whose own label carries the count
// rather than a second button appearing beside a static one.
type Row = { id: string; sku: string; name: string; issue: string };
const ROWS: Row[] = [
{ id: "r1", sku: "BRK-4471", name: "Ceramic brake pad set", issue: "Missing image" },
{ id: "r2", sku: "FLT-2290", name: "Cabin air filter", issue: "Price below floor" },
{ id: "r3", sku: "SPK-8812", name: "Iridium spark plug", issue: "Duplicate SKU" },
{ id: "r4", sku: "OIL-1130", name: "Full-synthetic 5W-30", issue: "Missing weight" },
{ id: "r5", sku: "WPR-6003", name: "All-season wiper blade", issue: "Unmapped category" },
];
export function BulkSelectionBar() {
const selection = useTableSelection<Row>({ items: ROWS });
const [busy, setBusy] = useState(false);
const columns: TableColumn<Row>[] = [
{
key: "__select",
rawHeader: true,
width: "40px",
noCopy: true,
// After walmart-mvp ErrorManagement.tsx:812: the header tooltip states
// exactly what's picked, not just how many.
header: (
<Tooltip content={selection.isAnySelected ? `${selection.selectedIds.size} of ${ROWS.length} selected` : `Select all ${ROWS.length} rows`}>
<span className="inline-flex">
<SelectAllCell selection={selection} />
</span>
</Tooltip>
),
render: (r) => <SelectionCell selection={selection} row={r} />,
},
{ key: "sku", header: "SKU", render: (r) => <span className="font-mono text-xs">{r.sku}</span> },
{ key: "name", header: "Item" },
{ key: "issue", header: "Flag", render: (r) => <span className="text-base-content/65">{r.issue}</span> },
];
const handleSkip = async () => {
setBusy(true);
// After speedway review.tsx:1490-1518 (ReviewBulkBar): one op for every
// checked row, reported through a toast, never a silent bulk write.
await pushAlert.promise(new Promise((resolve) => setTimeout(resolve, 900)), {
pending: `Skipping ${selection.selectedIds.size} row(s)...`,
success: "Skipped.",
error: "Could not skip.",
});
setBusy(false);
selection.clear();
};
return (
<Card title="Flagged rows" subtitle={`${ROWS.length} rows, ${selection.selectedIds.size} selected`} noAnimate>
<Table<Row> columns={columns} rows={ROWS} rowKey={(r) => r.id} bleed />
{selection.isAnySelected && (
<div className="bg-base-200 rounded-field mt-3 flex flex-wrap items-center gap-1 px-3 py-1.5">
<span className="text-sm font-medium">{selection.selectedRows.length} selected</span>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="text" content="Clear" onClick={selection.clear} />
<Button
size="sm"
variant="outline"
color="warning"
Icon="Ignore"
content={`Skip selected (${selection.selectedIds.size})`}
loading={busy}
disabled={busy}
onClick={handleSkip}
/>
</div>
</div>
)}
</Card>
);
}

Where it ships

  • walmart-mvp/frontend/src/pages/ErrorManagement.tsx:812the select-all header's tooltip states the live count, not just a checkbox with no context
  • walmart-mvp/frontend/src/pages/ErrorManagement.tsx:1054-1063the bulk button's own label carries the count, `Skip selected (${selection.selectedIds.size})`, instead of a second button appearing
  • speedway/app/routes/workspaces/review/review.tsx:1490-1518ReviewBulkBar: the soft-gray toolbar row this page's bar borrows verbatim, one op for every checked row

App-specific: Real callers also warn when the selection reaches past what's on screen (walmart's off-scope count behind the current filter) and route the bulk op through app-specific validation before opening a confirm dialog (speedway's requestSkip). This fixture keeps the wiring to selection state, the bar, and one promise-toasted action.

@versable-git/ui · composites proven in the apps