{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bulk-action-bar",
  "title": "Bulk Action Bar",
  "description": "A floating toolbar that mounts once rows are selected — inline actions, an overflow \"More\" menu, and a live-announced count.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/bulk-action-bar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { ChevronDown, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Overflow menu entrance only (the bar itself is animated by motion/react).\n * Ships via a React 19 hoisted <style href> — duplicates dedupe by href.\n */\nconst KEYFRAMES = `@keyframes bab-menu-in{from{opacity:0;transform:scale(0.96) translateY(4px)}to{opacity:1;transform:none}}`\n\nexport interface BulkAction {\n  key: string\n  label: string\n  icon?: React.ReactNode\n  onAction: () => void\n  /** Renders the row/button in the destructive token (delete / revoke / reject). */\n  destructive?: boolean\n  disabled?: boolean\n}\n\nexport interface BulkActionBarProps {\n  /** Number of currently selected items. The bar renders only while count > 0. */\n  count: number\n  /** Total selectable item count, if known — unlocks the \"N of M\" label and \"Select all\". */\n  total?: number\n  actions: BulkAction[]\n  onClear: () => void\n  /** Shown as \"Select all {total}\" whenever provided and count < total. */\n  onSelectAll?: () => void\n  /** How many actions render inline before the rest collapse into a \"More\" menu. Default 3. */\n  maxVisibleActions?: number\n  /** Which edge of its positioning context the bar docks to. Default \"bottom\". */\n  position?: \"bottom\" | \"top\"\n  /**\n   * \"container\" (default) — absolutely positioned against the nearest ancestor\n   * with `position: relative` (or similar). \"viewport\" — fixed to the browser viewport.\n   */\n  anchor?: \"container\" | \"viewport\"\n  /** Accessible name for the bar's group role. Default \"Bulk actions\". */\n  label?: string\n  /** Merged onto the bar panel (not the positioning wrapper). */\n  className?: string\n}\n\ninterface MoreMenuProps {\n  actions: BulkAction[]\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  triggerRef: React.RefObject<HTMLButtonElement | null>\n  /** Popup grows away from the bar's docked edge so it stays on-screen. */\n  openUpward: boolean\n}\n\nfunction MoreMenu({ actions, open, onOpenChange, triggerRef, openUpward }: MoreMenuProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const menuRef = React.useRef<HTMLDivElement>(null)\n  const pendingEdge = React.useRef<\"first\" | \"last\">(\"first\")\n  const menuId = React.useId()\n\n  const focusables = React.useCallback(\n    () =>\n      Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role=\"menuitem\"]:not(:disabled)') ?? []),\n    [],\n  )\n\n  const focusAt = React.useCallback(\n    (index: number) => {\n      const nodes = focusables()\n      if (nodes.length === 0) return\n      nodes[((index % nodes.length) + nodes.length) % nodes.length].focus()\n    },\n    [focusables],\n  )\n\n  const openWith = (edge: \"first\" | \"last\") => {\n    pendingEdge.current = edge\n    onOpenChange(true)\n  }\n\n  // Entering the menu: focus the edge the opening gesture asked for.\n  React.useEffect(() => {\n    if (!open) return\n    focusAt(pendingEdge.current === \"last\" ? -1 : 0)\n  }, [open, focusAt])\n\n  // Outside pointerdown dismisses without yanking focus back to the trigger.\n  React.useEffect(() => {\n    if (!open) return\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) onOpenChange(false)\n    }\n    document.addEventListener(\"pointerdown\", handlePointerDown)\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown)\n  }, [open, onOpenChange])\n\n  const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const nodes = focusables()\n    const index = nodes.indexOf(document.activeElement as HTMLButtonElement)\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault()\n      focusAt(index + 1)\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault()\n      focusAt(index - 1)\n    } else if (event.key === \"Home\") {\n      event.preventDefault()\n      focusAt(0)\n    } else if (event.key === \"End\") {\n      event.preventDefault()\n      focusAt(-1)\n    }\n    // Escape is handled by the bar's own listener (closes + refocuses this trigger).\n  }\n\n  return (\n    <div className=\"relative\" ref={rootRef}>\n      <style href=\"zyeon-bulk-action-bar-menu\" precedence=\"medium\">\n        {KEYFRAMES}\n      </style>\n      <button\n        aria-controls={open ? menuId : undefined}\n        aria-expanded={open}\n        aria-haspopup=\"menu\"\n        className={cn(\n          \"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 whitespace-nowrap rounded-full px-3 text-sm font-medium transition-colors\",\n          \"hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        )}\n        onClick={() => (open ? onOpenChange(false) : openWith(\"first\"))}\n        onKeyDown={event => {\n          if (event.key === \"ArrowDown\") {\n            event.preventDefault()\n            openWith(\"first\")\n          } else if (event.key === \"ArrowUp\") {\n            event.preventDefault()\n            openWith(\"last\")\n          }\n        }}\n        ref={triggerRef}\n        type=\"button\"\n      >\n        More\n        <ChevronDown aria-hidden=\"true\" className={cn(\"size-3.5 transition-transform\", open && \"rotate-180\")} />\n      </button>\n\n      {open && (\n        <div\n          className={cn(\n            \"absolute right-0 z-10 min-w-40 origin-bottom-right rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n            \"[animation:bab-menu-in_120ms_ease-out] motion-reduce:[animation:none]\",\n            openUpward ? \"bottom-full mb-2\" : \"top-full mt-2\",\n          )}\n          id={menuId}\n          onKeyDown={handleMenuKeyDown}\n          ref={menuRef}\n          role=\"menu\"\n        >\n          {actions.map(action => (\n            <button\n              className={cn(\n                \"flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors\",\n                \"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground\",\n                \"disabled:pointer-events-none disabled:opacity-50\",\n                action.destructive &&\n                  \"text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive\",\n              )}\n              disabled={action.disabled}\n              key={action.key}\n              onClick={() => {\n                action.onAction()\n                onOpenChange(false)\n                // The action usually clears the selection, which unmounts this whole bar\n                // — focusing a detached trigger silently drops focus on <body>. Restore\n                // it a frame later and only if the trigger is still in the document.\n                requestAnimationFrame(() => {\n                  const trigger = triggerRef.current\n                  if (trigger?.isConnected) trigger.focus()\n                })\n              }}\n              role=\"menuitem\"\n              tabIndex={-1}\n              type=\"button\"\n            >\n              {action.icon ? (\n                <span aria-hidden=\"true\" className=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\">\n                  {action.icon}\n                </span>\n              ) : null}\n              {action.label}\n            </button>\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction ActionButton({ action }: { action: BulkAction }) {\n  return (\n    <button\n      className={cn(\n        \"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-sm font-medium transition-colors\",\n        \"hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        action.destructive &&\n          \"text-destructive hover:bg-destructive/10 hover:text-destructive focus-visible:ring-destructive\",\n      )}\n      disabled={action.disabled}\n      onClick={action.onAction}\n      type=\"button\"\n    >\n      {action.icon ? (\n        <span aria-hidden=\"true\" className=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\">\n          {action.icon}\n        </span>\n      ) : null}\n      {action.label}\n    </button>\n  )\n}\n\n/**\n * Floating bulk-selection toolbar. Mounts only while `count > 0` — it never\n * leaves a hidden-but-focusable bar behind for Tab to fall into. Visibility\n * is fully consumer-driven (no internal auto-dismiss).\n */\nexport function BulkActionBar({\n  count,\n  total,\n  actions,\n  onClear,\n  onSelectAll,\n  maxVisibleActions = 3,\n  position = \"bottom\",\n  anchor = \"container\",\n  label = \"Bulk actions\",\n  className,\n}: BulkActionBarProps) {\n  const reducedMotion = useReducedMotion()\n  const visible = count > 0\n\n  const [moreOpen, setMoreOpen] = React.useState(false)\n  const moreTriggerRef = React.useRef<HTMLButtonElement>(null)\n\n  // Bar hidden → forget any open overflow menu, so it doesn't reappear stuck\n  // open next time selection goes positive again (render-time state adjust,\n  // not an effect — avoids the set-state-in-effect lint and any extra frame).\n  const [prevVisible, setPrevVisible] = React.useState(visible)\n  if (visible !== prevVisible) {\n    setPrevVisible(visible)\n    if (!visible) setMoreOpen(false)\n  }\n\n  // Esc: closes the overflow menu first (and returns focus to its trigger);\n  // only clears the whole selection once the menu is already closed. Listener\n  // only exists while the bar is visible, and is torn down with it.\n  React.useEffect(() => {\n    if (!visible) return\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return\n      if (moreOpen) {\n        event.preventDefault()\n        setMoreOpen(false)\n        moreTriggerRef.current?.focus()\n        return\n      }\n      onClear()\n    }\n    window.addEventListener(\"keydown\", handleKeyDown)\n    return () => window.removeEventListener(\"keydown\", handleKeyDown)\n  }, [visible, moreOpen, onClear])\n\n  // maxVisibleActions counts the inline buttons *including* the \"More\" trigger,\n  // so with a cap of 1 the trigger alone fills the budget and every action moves\n  // into the menu — never dropped. Clamped to >= 1 because a cap of 0 (or a\n  // negative) would otherwise render a bar with no way to reach the actions.\n  const visibleBudget = Math.max(1, Math.round(maxVisibleActions))\n  const overflowStart = actions.length > visibleBudget ? visibleBudget - 1 : actions.length\n  const primaryActions = actions.slice(0, overflowStart)\n  const overflowActions = actions.slice(overflowStart)\n  const hasAnyAction = primaryActions.length > 0 || overflowActions.length > 0\n\n  const canSelectAll = onSelectAll !== undefined && total !== undefined && count < total\n  const countLabel = total !== undefined ? `${count} of ${total} selected` : `${count} selected`\n\n  const initialY = position === \"top\" ? -12 : 12\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none px-4\",\n        anchor === \"viewport\" ? \"fixed inset-x-0 z-50\" : \"absolute inset-x-0 z-30\",\n        position === \"top\" ? \"top-4\" : \"bottom-4\",\n      )}\n    >\n      {/* Persistent (never unmounted) live region — announces selection count changes\n          independently of the bar's own mount/unmount cycle. sr-only + non-focusable,\n          so it can never be the \"hidden focusable bar\" this component must avoid. */}\n      <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        {visible ? countLabel : \"\"}\n      </span>\n\n      <AnimatePresence>\n        {visible && (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            aria-label={label}\n            className={cn(\n              \"pointer-events-auto mx-auto flex w-fit max-w-full flex-wrap items-center gap-3 rounded-full border bg-card px-4 py-2.5 text-card-foreground shadow-lg\",\n              className,\n            )}\n            exit={{ opacity: 0, y: initialY, transition: { duration: reducedMotion ? 0 : 0.15 } }}\n            initial={{ opacity: 0, y: initialY }}\n            role=\"group\"\n            transition={{ duration: reducedMotion ? 0 : 0.2, ease: \"easeOut\" }}\n          >\n            <span className=\"text-sm font-medium whitespace-nowrap\">{countLabel}</span>\n\n            {canSelectAll && (\n              <button\n                className=\"cursor-pointer rounded text-sm whitespace-nowrap text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                onClick={onSelectAll}\n                type=\"button\"\n              >\n                {`Select all ${total}`}\n              </button>\n            )}\n\n            {hasAnyAction && (\n              <>\n                <span aria-hidden=\"true\" className=\"h-5 w-px shrink-0 bg-border\" />\n                <div className=\"flex flex-wrap items-center gap-1\">\n                  {primaryActions.map(action => (\n                    <ActionButton action={action} key={action.key} />\n                  ))}\n                  {overflowActions.length > 0 && (\n                    <MoreMenu\n                      actions={overflowActions}\n                      onOpenChange={setMoreOpen}\n                      open={moreOpen}\n                      openUpward={position === \"bottom\"}\n                      triggerRef={moreTriggerRef}\n                    />\n                  )}\n                </div>\n              </>\n            )}\n\n            <span aria-hidden=\"true\" className=\"h-5 w-px shrink-0 bg-border\" />\n            <button\n              className=\"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={onClear}\n              type=\"button\"\n            >\n              <X aria-hidden=\"true\" className=\"size-4\" />\n              Clear\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nBulkActionBar.displayName = \"BulkActionBar\"\n\nexport default BulkActionBar\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}