{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "action-bar",
  "type": "registry:ui",
  "title": "Action bar",
  "description": "A glass bar that floats at the bottom of the view while rows or cards are selected: the count in words, the actions as small buttons and Clear. A toolbar with one Tab stop and the arrow keys inside; Esc clears.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/action-bar.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/action-bar 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * ActionBar: what can be done to the rows or cards someone has selected, in a bar that floats at the bottom of the\n * view while the selection lasts (components.md, \"Action bar\"). Archive three projects, export twelve invoices.\n *\n * - It appears when `count` goes above zero and goes when it is back to zero. Place it after the list, inside the same\n *   scrolling area: it sticks 16px above the bottom of the view while the list scrolls, and rests under the list at\n *   its end, so it never covers the last row.\n * - Two or three actions fit a phone. With more, keep the usual ones and put the rest behind a \"More\" Menu.\n * - Surface: floating glass at the surface radius with the float shadow (it floats over content, so glass is earned,\n *   R3), padded 4px so sm buttons sit concentric in it. The count in words at the start (\"3 selected\", small, --litt-fg),\n *   a hairline, the actions as sm buttons, then a round ghost Clear. Never an accent fill: a selection can be large\n *   and the view's one primary action is elsewhere. A destructive action keeps its word (\"Delete\"), not a red fill.\n * - Flat: one ink pill, its count and actions in canvas, no hairline.\n * - Motion: it rises by --litt-travel and fades in on the responsive tier, and drops by half that on the exit token. The\n *   count changes in place. Under reduced motion it appears and goes.\n * - Keyboard: a toolbar. Tab enters it once, on the first action; ← and → move between its buttons, Home and End go\n *   to the ends; Esc clears the selection. When it goes while focus is inside, focus goes back to where it came from.\n * - Accessibility: role=\"toolbar\" named for what it acts on, and the count said once in a polite status as it\n *   changes (\"3 projects selected\").\n */\nimport { useEffect, useRef, type FocusEvent, type KeyboardEvent, type ReactNode } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { XIcon } from \"./icons\";\n\n/** Flat: one ink pill; the actions inside read canvas, bare, with a faint bubble on hover. */\nconst FLAT_BAR =\n  \"in-[[data-material=flat]]:rounded-litt-control in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-fg)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px] \" +\n  \"in-[[data-material=flat]]:[&_div_button]:bg-transparent! in-[[data-material=flat]]:[&_button]:text-litt-canvas! \" +\n  \"in-[[data-material=flat]]:[&_div_button:hover]:bg-[color-mix(in_oklab,var(--litt-canvas)_16%,transparent)]! \" +\n  \"in-[[data-material=flat]]:[&_button]:outline-litt-canvas\";\n\nexport type ActionBarProps = {\n  /** How many are selected. The bar shows while it is above zero. */\n  count: number;\n  /** What the rows are, for the toolbar's name and the spoken count: \"projects\" makes \"3 projects selected\". */\n  noun?: string;\n  /** Clears the selection: the Clear button and Esc call it. */\n  onClear: () => void;\n  /** The actions, as sm Buttons: secondary for the usual ones, ghost for quieter ones. Icons are welcome; words are required. */\n  children: ReactNode;\n  /** Extra classes for the sticky wrapper, such as a different bottom offset. */\n  className?: string;\n};\n\n/** A floating bar of actions for the current selection: the count, the actions and Clear. Place it after the list. */\nexport function ActionBar({ count, noun = \"items\", onClear, children, className = \"\" }: ActionBarProps) {\n  const spoken = count === 0 ? \"\" : `${count} ${count === 1 ? noun.replace(/s$/, \"\") : noun} selected`;\n  return (\n    <div className={\"pointer-events-none sticky bottom-4 z-10 flex justify-center \" + className}>\n      <AnimatePresence>{count > 0 && <Bar key=\"bar\" count={count} noun={noun} onClear={onClear}>{children}</Bar>}</AnimatePresence>\n      <p role=\"status\" className=\"sr-only\">\n        {spoken}\n      </p>\n    </div>\n  );\n}\n\nconst ITEMS = \"button:not(:disabled), a[href], [role=menuitem]\";\n\nfunction Bar({ count, noun, onClear, children }: Required<Pick<ActionBarProps, \"count\" | \"noun\" | \"onClear\" | \"children\">>) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const bar = useRef<HTMLDivElement>(null);\n  /* Where focus came from, to give it back if the bar goes while focus is inside it. */\n  const cameFrom = useRef<HTMLElement | null>(null);\n  const inside = useRef(false);\n\n  const items = () => Array.from(bar.current?.querySelectorAll<HTMLElement>(ITEMS) ?? []);\n\n  /* Roving tabindex: one Tab stop, on the item focused last (the first one to start with). */\n  const rove = (active: HTMLElement | undefined) => {\n    for (const el of items()) el.tabIndex = el === active ? 0 : -1;\n  };\n  useEffect(() => {\n    const all = items();\n    rove(all.find((el) => el.tabIndex === 0) ?? all[0]);\n  });\n\n  useEffect(() => {\n    const node = bar.current;\n    return () => {\n      /* The focused button went with the bar, so focus fell to the body. */\n      if (inside.current && (node?.contains(document.activeElement) || document.activeElement === document.body)) {\n        const back = cameFrom.current;\n        if (back?.isConnected) back.focus({ preventScroll: true });\n      }\n    };\n  }, []);\n\n  const onFocus = (e: FocusEvent<HTMLDivElement>) => {\n    inside.current = true;\n    const from = e.relatedTarget as HTMLElement | null;\n    if (from && !e.currentTarget.contains(from)) cameFrom.current = from;\n    if (e.target instanceof HTMLElement && items().includes(e.target)) rove(e.target);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      onClear();\n      return;\n    }\n    const all = items();\n    const at = all.indexOf(document.activeElement as HTMLElement);\n    if (at < 0) return;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const fwd = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const back = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    const to = e.key === fwd ? (at + 1) % all.length : e.key === back ? (at - 1 + all.length) % all.length : e.key === \"Home\" ? 0 : e.key === \"End\" ? all.length - 1 : -1;\n    if (to < 0) return;\n    e.preventDefault();\n    all[to]!.focus();\n  };\n\n  return (\n    <motion.div\n      ref={bar}\n      role=\"toolbar\"\n      aria-label={`Actions for the selected ${noun}`}\n      onFocus={onFocus}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) inside.current = false;\n      }}\n      onKeyDown={onKeyDown}\n      className={\"litt-glass pointer-events-auto flex max-w-full min-w-0 items-center gap-1 rounded-litt-surface p-1 [--litt-glass-shadow:var(--litt-shadow-float)] [&_button]:pointer-coarse:h-litt-control-md \" + FLAT_BAR}\n      initial={{ opacity: 0, y: travel }}\n      animate={{ opacity: 1, y: 0, transition: enter }}\n      exit={{ opacity: 0, y: travel / 2, transition: exit }}\n    >\n      <span className=\"shrink-0 truncate pr-2 pl-3 text-litt-small text-litt-fg tabular-nums in-[[data-material=flat]]:pl-4 in-[[data-material=flat]]:text-litt-canvas\">{count} selected</span>\n      <span aria-hidden=\"true\" className=\"h-5 w-px shrink-0 bg-litt-line in-[[data-material=flat]]:hidden\" />\n      <div className=\"flex min-w-0 items-center gap-1\">{children}</div>\n      <button\n        type=\"button\"\n        aria-label=\"Clear selection\"\n        onClick={onClear}\n        className={\n          \"grid size-8 shrink-0 cursor-pointer place-items-center rounded-litt-pill text-litt-fg-muted in-[[data-material=flat]]:bg-[color-mix(in_oklab,var(--litt-canvas)_16%,transparent)] in-[[data-material=flat]]:text-litt-canvas in-[[data-material=flat]]:hover:bg-[color-mix(in_oklab,var(--litt-canvas)_24%,transparent)] in-[[data-material=flat]]:hover:text-litt-canvas in-[[data-material=flat]]:focus-visible:outline-litt-canvas [-webkit-tap-highlight-color:transparent] \" +\n          \"transition-[background-color,color] duration-(--litt-dur-instant) ease-litt \" +\n          \"hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] hover:text-litt-fg active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus pointer-coarse:size-11\"\n        }\n      >\n        <XIcon />\n      </button>\n    </motion.div>\n  );\n}\n",
      "target": "components/litt/action-bar.tsx"
    }
  ],
  "docs": "Import ActionBar from components/litt/action-bar and place it after the list, inside the same scrolling area. Pass the count, a noun and onClear, and sm Buttons as children. Never an accent fill.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
