{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "action-bar",
  "title": "Action Bar",
  "description": "Appears when items are selected, with the count, the actions and a way to clear, floating over the page, docked to its edge or sitting in the flow.",
  "dependencies": [
    "radix-ui",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/action-bar.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useRef, useState } from \"react\"\r\nimport { Toolbar as ToolbarPrimitive } from \"radix-ui\"\r\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\r\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\r\nimport {\r\n  ActionBarAction,\r\n  ActionBarClear,\r\n  ActionBarSeparator,\r\n  ActionBarVariantContext,\r\n  DEFAULT_POSITION,\r\n  FIXED_POSITIONER_CLASS,\r\n  INLINE_POSITIONER_CLASS,\r\n  SURFACE_CLASS,\r\n  type ActionBarActionProps,\r\n  type ActionBarActionTone,\r\n  type ActionBarPosition,\r\n  type ActionBarVariant,\r\n} from \"@/registry/miseui/ui/action-bar-parts\"\r\nimport { shouldClearOnEscape, useEscapeToClear, useFocusReturn } from \"@/registry/miseui/ui/action-bar-state\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype ActionBarEffect = \"spring\"\r\n\r\n/** A fixed bar has to clear the screen edge, its own height included; an inline bar only needs a hint of direction. */\r\nconst TRAVEL_PX: Record<ActionBarPosition, number> = { fixed: 72, inline: 8 }\r\n\r\nconst COUNT_CLASS = \"whitespace-nowrap text-sm font-medium tabular-nums\"\r\n\r\ninterface BarBodyProps {\r\n  variant: ActionBarVariant\r\n  count: number\r\n  countText: React.ReactNode\r\n  clearLabel: string\r\n  onClear: () => void\r\n  children: React.ReactNode\r\n}\r\n\r\n/** Same three things in every variant; the order is what differs, because each one reads from a different place. */\r\nfunction BarBody({ variant, count, countText, clearLabel, onClear, children }: BarBodyProps) {\r\n  const visibleCount = <span aria-hidden=\"true\" data-slot=\"action-bar-count\" className={cn(COUNT_CLASS, \"px-2\")}>{countText}</span>\r\n  const clearIcon = <ActionBarClear label={clearLabel} wide={false} onClick={onClear} />\r\n  if (variant === \"floating\") {\r\n    return (\r\n      <>\r\n        {clearIcon}\r\n        {visibleCount}\r\n        <ActionBarSeparator />\r\n        {children}\r\n      </>\r\n    )\r\n  }\r\n  if (variant === \"docked\") {\r\n    return (\r\n      <>\r\n        {visibleCount}\r\n        <ActionBarClear label={clearLabel} wide onClick={onClear} />\r\n        <div className=\"ml-auto flex flex-wrap items-center justify-end gap-1\">{children}</div>\r\n      </>\r\n    )\r\n  }\r\n  if (variant === \"inline\") {\r\n    return (\r\n      <>\r\n        <span aria-hidden=\"true\" data-slot=\"action-bar-count\" className={COUNT_CLASS}>{countText}</span>\r\n        <div className=\"ml-auto flex flex-wrap items-center justify-end gap-1\">\r\n          {children}\r\n          {clearIcon}\r\n        </div>\r\n      </>\r\n    )\r\n  }\r\n  return (\r\n    <>\r\n      <span aria-hidden=\"true\" data-slot=\"action-bar-count\" className={cn(COUNT_CLASS, \"min-w-8 text-center\")}>{count}</span>\r\n      {children}\r\n      {clearIcon}\r\n    </>\r\n  )\r\n}\r\n\r\ntype ActionBarProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"dir\"> & {\r\n  /** How many items are selected. The bar shows above zero. */\r\n  count: number\r\n  /** Called by the clear button and by Escape. */\r\n  onClear: () => void\r\n  /** ActionBarAction buttons. */\r\n  children: React.ReactNode\r\n  /** `floating` an island over the page, `docked` a full-width footer, `inline` a tinted row in the flow, `compact` a small dark pill of glyphs. */\r\n  variant?: ActionBarVariant\r\n  /** `fixed` sits on the viewport, `inline` in the flow. `floating` and `docked` default to fixed, the others to inline. */\r\n  position?: ActionBarPosition\r\n  /** `spring` brings the bar in from the bottom and sends it back the same way. */\r\n  effect?: ActionBarEffect\r\n  /** Name of the toolbar for assistive tech. */\r\n  label?: string\r\n  /** What the count says, here and to screen readers. */\r\n  countLabel?: (count: number) => string\r\n  clearLabel?: string\r\n  /** Where focus goes when the bar closes. Defaults to where it came from. */\r\n  returnFocusRef?: React.RefObject<HTMLElement | null>\r\n  /** Limits Escape to focus inside this element, for several bars on one page. Defaults to the whole page. */\r\n  escapeScopeRef?: React.RefObject<HTMLElement | null>\r\n}\r\n\r\nconst defaultCountLabel = (count: number) => `${count} selected`\r\n\r\n/** Everything else on the props is an attribute of the toolbar. */\r\nconst OWN_PROPS: ReadonlySet<string> = new Set([\r\n  \"count\", \"onClear\", \"children\", \"variant\", \"position\", \"effect\", \"label\", \"countLabel\", \"clearLabel\", \"returnFocusRef\", \"escapeScopeRef\", \"className\",\r\n])\r\n\r\nfunction toolbarAttributes(props: ActionBarProps) {\r\n  return Object.fromEntries(Object.entries(props).filter(([key]) => !OWN_PROPS.has(key)))\r\n}\r\n\r\nfunction ActionBar(allProps: ActionBarProps) {\r\n  const { count, onClear, children, variant = \"floating\", position, effect, label = \"Selection actions\", className } = allProps\r\n  const { countLabel = defaultCountLabel, clearLabel = \"Clear selection\", returnFocusRef, escapeScopeRef } = allProps\r\n  const props = toolbarAttributes(allProps)\r\n  const open = count > 0\r\n  const resolvedPosition = position ?? DEFAULT_POSITION[variant]\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const barRef = useRef<HTMLDivElement>(null)\r\n  // The bar animates out after the count reached zero. It should leave saying what it said, not \"0 selected\".\r\n  const [lastCount, setLastCount] = useState(count)\r\n  if (open && count !== lastCount) setLastCount(count)\r\n  const noteFocusOrigin = useFocusReturn(open, barRef, returnFocusRef)\r\n  useEscapeToClear({ open, onClear, barRef, scopeRef: escapeScopeRef })\r\n\r\n  const away = reduceMotion ? { opacity: 0 } : { opacity: 0, y: TRAVEL_PX[resolvedPosition] }\r\n  const motionProps = effect === \"spring\" ? { initial: away, exit: away } : { initial: false as const }\r\n  const positioner = resolvedPosition === \"fixed\" ? FIXED_POSITIONER_CLASS[variant] : INLINE_POSITIONER_CLASS[variant]\r\n  return (\r\n    <div data-slot=\"action-bar\" data-variant={variant} data-position={resolvedPosition} data-effect={effect} data-state={open ? \"open\" : \"closed\"} className=\"contents\">\r\n      {/* Outside the presence so it is in the page before the first count, which is what makes it get announced. */}\r\n      <span role=\"status\" className=\"sr-only\">{open ? countLabel(count) : \"\"}</span>\r\n      <AnimatePresence initial={false}>\r\n        {open ? (\r\n          <motion.div key=\"bar\" data-slot=\"action-bar-positioner\" className={positioner} animate={{ opacity: 1, y: 0 }} transition={SPRING_DEFAULT} {...motionProps}>\r\n            <ActionBarVariantContext.Provider value={variant}>\r\n              <ToolbarPrimitive.Root ref={barRef} aria-label={label} data-slot=\"action-bar-surface\" onFocus={noteFocusOrigin} className={cn(SURFACE_CLASS[variant], className)} {...props}>\r\n                <BarBody {...{ variant, onClear, clearLabel }} count={lastCount} countText={countLabel(lastCount)}>\r\n                  {children}\r\n                </BarBody>\r\n              </ToolbarPrimitive.Root>\r\n            </ActionBarVariantContext.Provider>\r\n          </motion.div>\r\n        ) : null}\r\n      </AnimatePresence>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport {\r\n  ActionBar,\r\n  ActionBarAction,\r\n  shouldClearOnEscape,\r\n  type ActionBarProps,\r\n  type ActionBarActionProps,\r\n  type ActionBarActionTone,\r\n  type ActionBarVariant,\r\n  type ActionBarPosition,\r\n  type ActionBarEffect,\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/action-bar-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { createContext, useContext } from \"react\"\nimport { Toolbar as ToolbarPrimitive } from \"radix-ui\"\nimport { X } from \"lucide-react\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ActionBarVariant = \"floating\" | \"docked\" | \"inline\" | \"compact\"\ntype ActionBarPosition = \"fixed\" | \"inline\"\ntype ActionBarActionTone = \"neutral\" | \"destructive\"\n\nconst DEFAULT_POSITION: Record<ActionBarVariant, ActionBarPosition> = {\n  floating: \"fixed\",\n  docked: \"fixed\",\n  inline: \"inline\",\n  compact: \"inline\",\n}\n\nconst SURFACE_CLASS: Record<ActionBarVariant, string> = {\n  floating: \"flex max-w-full flex-wrap items-center justify-center gap-1 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg\",\n  docked: \"flex w-full flex-wrap items-center gap-2 border-t bg-background px-4 py-2 text-foreground\",\n  inline: \"flex w-full min-w-0 flex-wrap items-center gap-2 rounded-md bg-mise-soft px-3 py-1 text-foreground\",\n  compact: \"inline-flex max-w-full flex-wrap items-center justify-center gap-1 rounded-3xl bg-foreground p-1 text-background shadow-md\",\n}\n\n/**\n * The strip spans the viewport so the island can centre itself, and must not swallow clicks on the page beside it.\n * Every positioner is a container: labels fold into the buttons' names below 448 px, because a bar that wraps to two rows covers more of the list.\n */\nconst FIXED_ISLAND_CLASS =\n  \"pointer-events-none fixed inset-x-0 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-4 @container *:pointer-events-auto\"\n\nconst FIXED_POSITIONER_CLASS: Record<ActionBarVariant, string> = {\n  floating: FIXED_ISLAND_CLASS,\n  docked: \"fixed inset-x-0 bottom-0 z-40 bg-background pb-[env(safe-area-inset-bottom)] @container\",\n  inline: FIXED_ISLAND_CLASS,\n  compact: FIXED_ISLAND_CLASS,\n}\n\nconst INLINE_POSITIONER_CLASS: Record<ActionBarVariant, string> = {\n  floating: \"flex w-full min-w-0 justify-center @container\",\n  docked: \"w-full min-w-0 @container\",\n  inline: \"w-full min-w-0 @container\",\n  compact: \"flex w-full min-w-0 justify-center @container\",\n}\n\nconst ActionBarVariantContext = createContext<ActionBarVariant>(\"floating\")\n\nconst CONTROL_CLASS = cn(\n  \"relative inline-flex shrink-0 items-center justify-center gap-2 font-medium whitespace-nowrap outline-none select-none\",\n  \"transition-[color,background-color,scale] duration-(--duration-fast) ease-(--ease-mise)\",\n  \"focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n  \"active:scale-[0.97] active:duration-0 motion-reduce:active:scale-100\",\n  \"disabled:pointer-events-none disabled:opacity-50\",\n  \"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n)\n\nconst WIDE_CONTROL_CLASS = \"h-9 min-w-9 rounded-md px-2 text-sm hover:bg-foreground/[0.07] active:bg-foreground/10 pointer-coarse:h-10 pointer-coarse:min-w-10\"\nconst COMPACT_CONTROL_CLASS = \"size-8 rounded-full hover:bg-background/15 active:bg-background/25 pointer-coarse:size-10\"\n\n/** The compact surface is inverted, so the error colour needs the opposite lightness to stay readable on it. */\nconst DESTRUCTIVE_CLASS: Record<\"wide\" | \"compact\", string> = {\n  wide: \"text-destructive hover:bg-destructive/10 active:bg-destructive/15\",\n  compact: \"text-red-400 dark:text-red-600\",\n}\n\ntype ActionBarActionProps = Omit<React.ComponentProps<typeof ToolbarPrimitive.Button>, \"children\"> & {\n  /** Required: this text is the button's name even when the bar has only room for the glyph. */\n  children: string\n  icon?: React.ReactNode\n  tone?: ActionBarActionTone\n}\n\nfunction ActionBarAction({ children, icon, tone = \"neutral\", className, title, ...props }: ActionBarActionProps) {\n  const variant = useContext(ActionBarVariantContext)\n  const compact = variant === \"compact\"\n  const canFold = Boolean(icon)\n  return (\n    <ToolbarPrimitive.Button\n      data-slot=\"action-bar-action\"\n      data-tone={tone}\n      title={title ?? (compact ? children : undefined)}\n      className={cn(\n        CONTROL_CLASS,\n        compact ? COMPACT_CONTROL_CLASS : WIDE_CONTROL_CLASS,\n        tone === \"destructive\" && DESTRUCTIVE_CLASS[compact ? \"compact\" : \"wide\"],\n        compact && !canFold && \"w-auto px-3 pointer-coarse:w-auto\",\n        className,\n      )}\n      {...props}\n    >\n      {icon}\n      <span className={canFold ? (compact ? \"sr-only\" : \"sr-only @md:not-sr-only\") : undefined}>{children}</span>\n    </ToolbarPrimitive.Button>\n  )\n}\n\nfunction ActionBarClear({ label, wide, onClick }: { label: string; wide: boolean; onClick: () => void }) {\n  const variant = useContext(ActionBarVariantContext)\n  const compact = variant === \"compact\"\n  return (\n    <ToolbarPrimitive.Button\n      data-slot=\"action-bar-clear\"\n      aria-label={label}\n      onClick={onClick}\n      className={cn(CONTROL_CLASS, compact ? COMPACT_CONTROL_CLASS : WIDE_CONTROL_CLASS, !compact && \"text-muted-foreground\")}\n    >\n      {wide ? \"Clear\" : <X aria-hidden=\"true\" />}\n    </ToolbarPrimitive.Button>\n  )\n}\n\nfunction ActionBarSeparator() {\n  return <ToolbarPrimitive.Separator data-slot=\"action-bar-separator\" className=\"mx-1 h-4 w-px bg-border\" />\n}\n\nexport {\n  ActionBarAction,\n  ActionBarClear,\n  ActionBarSeparator,\n  ActionBarVariantContext,\n  DEFAULT_POSITION,\n  FIXED_POSITIONER_CLASS,\n  INLINE_POSITIONER_CLASS,\n  SURFACE_CLASS,\n  type ActionBarActionProps,\n  type ActionBarActionTone,\n  type ActionBarPosition,\n  type ActionBarVariant,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/action-bar-state.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useLayoutEffect, useRef } from \"react\"\n\nfunction isEditable(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) return false\n  return target.isContentEditable || [\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(target.tagName)\n}\n\ninterface EscapeContext {\n  key: string\n  defaultPrevented: boolean\n  target: EventTarget | null\n  bar: Element | null\n  /** Escape counts only while focus is inside this element. Null means the whole page. */\n  scope: Element | null\n  activeElement: Element | null\n}\n\n/**\n * An open dialog or menu marks its own Escape as handled, so that layer closes first and the selection survives.\n * A field outside the bar keeps Escape as well: there it means \"stop typing\", and losing a long selection to it would hurt.\n */\nfunction shouldClearOnEscape({ key, defaultPrevented, target, bar, scope, activeElement }: EscapeContext) {\n  if (key !== \"Escape\" || defaultPrevented) return false\n  if (scope && !scope.contains(activeElement)) return false\n  const insideBar = target instanceof Node && Boolean(bar?.contains(target))\n  return insideBar || !isEditable(target)\n}\n\ninterface EscapeOptions {\n  open: boolean\n  onClear: () => void\n  barRef: React.RefObject<HTMLElement | null>\n  scopeRef?: React.RefObject<HTMLElement | null>\n}\n\nfunction useEscapeToClear({ open, onClear, barRef, scopeRef }: EscapeOptions) {\n  useEffect(() => {\n    if (!open) return\n    const onKeyDown = (event: KeyboardEvent) => {\n      const clear = shouldClearOnEscape({\n        key: event.key,\n        defaultPrevented: event.defaultPrevented,\n        target: event.target,\n        bar: barRef.current,\n        scope: scopeRef?.current ?? null,\n        activeElement: document.activeElement,\n      })\n      if (clear) onClear()\n    }\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => document.removeEventListener(\"keydown\", onKeyDown)\n  }, [open, onClear, barRef, scopeRef])\n}\n\n/**\n * Selecting must not pull focus into the bar, so it never takes focus on arrival. When the bar leaves with focus\n * inside, focus goes back to where it came from, because otherwise the keyboard user lands at the top of the page.\n */\nfunction useFocusReturn(open: boolean, barRef: React.RefObject<HTMLElement | null>, returnFocusRef?: React.RefObject<HTMLElement | null>) {\n  const cameFromRef = useRef<HTMLElement | null>(null)\n  // A layout effect, and the bar is still in the DOM: AnimatePresence keeps it for this commit even without an exit.\n  useLayoutEffect(() => {\n    if (open || !barRef.current?.contains(document.activeElement)) return\n    const target = returnFocusRef?.current ?? cameFromRef.current\n    if (target?.isConnected) target.focus()\n  }, [open, barRef, returnFocusRef])\n  return (event: React.FocusEvent<HTMLElement>) => {\n    const from = event.relatedTarget\n    if (from instanceof HTMLElement && !event.currentTarget.contains(from)) cameFromRef.current = from\n  }\n}\n\nexport { shouldClearOnEscape, useEscapeToClear, useFocusReturn }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "overlays"
  ],
  "type": "registry:ui"
}