Bulk selection action bar
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| SKU | Item | Flag | |
|---|---|---|---|
| BRK-4471 | Ceramic brake pad set | Missing image | |
| FLT-2290 | Cabin air filter | Price below floor | |
| SPK-8812 | Iridium spark plug | Duplicate SKU | |
| OIL-1130 | Full-synthetic 5W-30 | Missing weight | |
| WPR-6003 | All-season wiper blade | Unmapped 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 contextwalmart-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 appearingspeedway/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.