HextorUI
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-bar

Source

"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