ui
Bulk action bar
Floating selection bar with count, actions, and an honest select-all-across-pages escalation.
npx shadcn@latest add @hextor/bulk-action-barSource
"use client";
import * as React from "react";
import { useEffect } from "react";
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
import { cn } from "@/registry/hextor/lib/utils";
import { Button, type buttonVariants } from "@/components/ui/button";
import type { VariantProps } from "class-variance-authority";
import {
resolveLabels,
type BulkActionBarLabels,
} from "@/registry/hextor/ui/bulk-action-bar-labels";
export interface BulkAction {
id: string;
label: string;
icon?: LucideIcon;
onAction: () => void;
variant?: VariantProps<typeof buttonVariants>["variant"];
disabled?: boolean;
}
export interface BulkActionBarProps {
/** Rows selected right now — on the current page if paging is in effect. */
selectedCount: number;
/** How many selectable rows are visible on the current page. */
pageCount: number;
/**
* Total selectable rows across every page, when it's known and cheap to
* know. Omit it (rather than guessing) if the true total requires an
* extra query — the "select all across pages" prompt only appears when
* this is present, and an honest omission beats a wrong number.
*/
totalCount?: number;
/** True once the user has explicitly extended selection to every page. */
allPagesSelected?: boolean;
onSelectAllPages?: () => void;
onSelectPageOnly?: () => void;
onClear: () => void;
actions: BulkAction[];
labels?: Partial<BulkActionBarLabels>;
className?: string;
}
function fill(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? `{${key}}`));
}
function isTextInput(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return true;
return (el as HTMLElement).isContentEditable === true;
}
/**
* The bar that appears once a selection exists. Floats above content rather
* than living in a header, because a header-bound count silently scrolls out
* of view during exactly the scroll a big selection invites.
*
* The failure mode this is built against: a bulk bar that says "12
* selected" when the user meant "every row matching this filter," because
* it only ever knew about the page it could see. Once `selectedCount`
* equals `pageCount` and a larger `totalCount` is known, the bar surfaces
* the "select all N instead" escalation explicitly rather than silently
* treating "select all on this page" as if it meant everything — and once
* `allPagesSelected` is true, it says so in words instead of a number that
* happens to match.
*/
export function BulkActionBar({
selectedCount,
pageCount,
totalCount,
allPagesSelected = false,
onSelectAllPages,
onSelectPageOnly,
onClear,
actions,
labels: labelsProp,
className,
}: BulkActionBarProps) {
const labels = resolveLabels(labelsProp);
const visible = selectedCount > 0;
useEffect(() => {
if (!visible) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (isTextInput(document.activeElement)) return;
onClear();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [visible, onClear]);
if (!visible) return null;
const showEscalation =
!allPagesSelected &&
typeof totalCount === "number" &&
totalCount > pageCount &&
selectedCount >= pageCount &&
!!onSelectAllPages;
return (
<div
role="status"
aria-live="polite"
data-slot="bulk-action-bar"
className={cn(
"animate-in fade-in-0 slide-in-from-bottom-4 fixed inset-x-0 bottom-6 z-40 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-col gap-1.5 rounded-xl border border-border bg-popover px-3 py-2 text-popover-foreground shadow-lg duration-150",
className,
)}
>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium tnum">
{allPagesSelected
? fill(labels.allSelected, { total: totalCount ?? selectedCount })
: fill(labels.selected, { count: selectedCount })}
</span>
<div className="h-4 w-px bg-border" aria-hidden />
<div className="flex flex-wrap items-center gap-1.5">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
size="sm"
variant={action.variant ?? "outline"}
disabled={action.disabled}
onClick={action.onAction}
>
{Icon && <Icon aria-hidden />}
{action.label}
</Button>
);
})}
</div>
<div className="h-4 w-px bg-border" aria-hidden />
<Button
size="sm"
variant="ghost"
onClick={onClear}
aria-label={labels.clearAriaLabel}
>
<X aria-hidden />
{labels.clear}
</Button>
</div>
{showEscalation && (
<div className="flex flex-wrap items-center gap-2 px-0.5 text-xs text-muted-foreground">
<span>{fill(labels.pageFullyCounted, { count: pageCount })}</span>
<button
type="button"
onClick={onSelectAllPages}
className="font-medium text-brand underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-ring"
>
{fill(labels.selectAllAcrossPages, { total: totalCount ?? 0 })}
</button>
</div>
)}
{allPagesSelected && onSelectPageOnly && (
<div className="px-0.5 text-xs text-muted-foreground">
<button
type="button"
onClick={onSelectPageOnly}
className="font-medium text-brand underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-ring"
>
{labels.selectThisPageOnly}
</button>
</div>
)}
</div>
);
}
Docs
The escalation from "all 25 on this page" to "all 1,240 across pages" only appears when a total is explicitly supplied, never inferred. Most implementations quietly let "select all" mean the page and then act as if it meant everything — which is how a bulk delete removes the wrong rows.
Esc clears the selection, because a selection you cannot get out of is a trap.
Dependencies
lucide-reactbutton