Bulk action bar
While rows are selected, a 44px bar covers the toolbar: how many are selected, the actions that apply to all of them, then Clear.
Playground
Change the controls to try each prop. The preview gives the bar Archive and Merge; the code lists only the props you changed.
import { BulkActionBar } from '@/ui/bulk-action-bar';<BulkActionBar />In the table
The bar takes the toolbar's place and height, so the grid doesn't move, and the toolbar under it is inert. Merge shows only with exactly two rows picked. Archive acts at once with Undo.
| Candidate | Company | |
|---|---|---|
| Maya Chen | Halcyon Bank | |
| Priya Shah | Northbridge Talent | |
| Daniel Okafor | Halcyon Bank |
import { ArchiveIcon, FunnelSimpleIcon, GitMergeIcon, SlidersHorizontalIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button } from '@/ui/button';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { moveList } from '@/ui/motion';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
import { toast } from '@/ui/toast';
type Candidate = { id: string; name: string; company: string };
const start: Candidate[] = [
{ id: 'c1', name: 'Maya Chen', company: 'Halcyon Bank' },
{ id: 'c2', name: 'Priya Shah', company: 'Northbridge Talent' },
{ id: 'c3', name: 'Daniel Okafor', company: 'Halcyon Bank' },
];
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'company', label: 'Company', width: 200, cell: (row) => row.company },
];
// Pick rows: the bar covers the toolbar. Archive acts at once with Undo.
export default function BulkActionBarInTheTable() {
const [rows, setRows] = useState(start);
const [selected, setSelected] = useState<string[]>(['c1', 'c2']);
const archive = () => {
const gone = rows.filter((row) => selected.includes(row.id));
moveList(() => {
setRows(rows.filter((row) => !selected.includes(row.id)));
setSelected([]);
});
toast.add({
type: 'success',
title: `${gone.length} ${gone.length === 1 ? 'candidate' : 'candidates'} archived`,
actionProps: { children: 'Undo', onClick: () => moveList(() => setRows(start)) },
});
};
return (
<div className="flex w-full flex-col bg-surface">
<div className="relative">
<div inert={selected.length > 0 || undefined} className="flex h-11 items-center justify-between border-b border-border px-150">
<Button variant="ghost">
<FunnelSimpleIcon />
Filter
</Button>
<Button variant="ghost">
<SlidersHorizontalIcon />
Display
</Button>
</div>
{selected.length > 0 ? (
<BulkActionBar count={selected.length} onClear={() => setSelected([])}>
<BulkAction icon={<ArchiveIcon />} onClick={archive}>
Archive
</BulkAction>
{selected.length === 2 ? <BulkAction icon={<GitMergeIcon />}>Merge</BulkAction> : null}
</BulkActionBar>
) : null}
</div>
<Table label="Candidates" columns={columns} rows={rows} rowLabel={(row) => row.name} selectable selected={selected} onSelectedChange={setSelected} rowMotion />
</div>
);
}Actions by table
Each table offers only what applies to every selected row: Archive and Fill from website on Companies, Approve and Deny on changes from Flo, Restore on the Archived tab. Approve is the one primary. While an action runs, every action disables.
import { ArrowCounterClockwiseIcon, ArchiveIcon, CheckIcon, GlobeIcon, XIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
const clear = () => {};
// Each table offers only what applies to every selected row.
export default function BulkActionBarActionsByTable() {
return (
<div className="flex w-full flex-col gap-150">
<BulkActionBar overlay={false} count={3} onClear={clear}>
<BulkAction icon={<ArchiveIcon />}>Archive</BulkAction>
<BulkAction icon={<GlobeIcon />}>Fill from website</BulkAction>
</BulkActionBar>
<BulkActionBar overlay={false} count={3} onClear={clear}>
<BulkAction variant="primary" icon={<CheckIcon />}>Approve 3</BulkAction>
<BulkAction icon={<XIcon />}>Deny</BulkAction>
</BulkActionBar>
<BulkActionBar overlay={false} count={5} busy onClear={clear}>
<BulkAction icon={<ArrowCounterClockwiseIcon />}>Restore</BulkAction>
</BulkActionBar>
</div>
);
}Move to stage
Lists only the stages every selected application shares, each as its stage lozenge.
import { ArrowRightIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { Lozenge } from '@/ui/lozenge';
import { Popover, PopoverContent, PopoverTrigger } from '@/ui/popover';
const stages = [
{ label: 'Long list', phase: 'working' },
{ label: 'Short list', phase: 'working' },
{ label: 'CV sent', phase: 'client' },
{ label: 'First interview', phase: 'client' },
{ label: 'Offer', phase: 'offer' },
{ label: 'Placed', phase: 'placed' },
] as const;
// Move to stage lists only the stages every selected application shares.
export default function BulkActionBarMoveToStage() {
return (
<BulkActionBar overlay={false} count={4} onClear={() => {}} className="w-full">
<Popover>
<PopoverTrigger render={<BulkAction icon={<ArrowRightIcon />}>Move to stage</BulkAction>} />
<PopoverContent align="start" className="w-52 gap-0 p-050">
{stages.map((stage) => (
<button key={stage.label} type="button" className="flex h-7 w-full cursor-pointer items-center rounded-medium px-100 outline-none motion-state hover:bg-background-hovered focus-visible:bg-background-hovered">
<Lozenge phase={stage.phase}>{stage.label}</Lozenge>
</button>
))}
</PopoverContent>
</Popover>
</BulkActionBar>
);
}No shared stage
When the selected applications share no stage, Move to stage stays in its place, disabled, with the reason in its tooltip.
import { ArrowRightIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { Hint } from '@/ui/tooltip';
// Applications from Jobs with different rounds share no stage: the action stays, disabled, with the reason.
export default function BulkActionBarNoSharedStage() {
return (
<BulkActionBar overlay={false} count={2} onClear={() => {}} className="w-full">
<Hint label="These applications share no stage. Select applications on Jobs with the same rounds.">
<BulkAction icon={<ArrowRightIcon />} disabled>
Move to stage
</BulkAction>
</Hint>
</BulkActionBar>
);
}When an action partly fails
Press Archive: one of three can't be archived. The two that worked go, the one that failed stays selected, and an error toast says why and what to do. It stays until closed.
import { ArchiveIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { toast } from '@/ui/toast';
// Archive 3: one can't be archived. The two that worked go; the one that failed stays selected, and the toast says why.
export default function BulkActionBarActionFails() {
const [count, setCount] = useState(3);
const [busy, setBusy] = useState(false);
const archive = () => {
setBusy(true);
setTimeout(() => {
setBusy(false);
setCount(1);
toast.add({ type: 'error', title: "1 candidate wasn't archived", description: 'Daniel Okafor has an open placement. Close it first, then archive again.' });
}, 900);
};
return (
<BulkActionBar overlay={false} count={count} busy={busy} onClear={() => setCount(3)} className="w-full">
<BulkAction icon={<ArchiveIcon />} onClick={archive}>
Archive
</BulkAction>
</BulkActionBar>
);
}Add column and field editor
+ at the end of the header row creates a custom field and adds it to this View. Settings › Fields uses the same form.
Calculation footer
A sticky row under the grid: the record count first, then one calculation per column, picked from that column's menu and saved with the View.