{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-toolbar",
  "title": "Expandable Toolbar",
  "description": "A controlled or uncontrolled toolbar primitive that measures its action slot and expands from either side.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/base/ui/expandable-toolbar.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  type ComponentProps,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const;\nconst DEFAULT_CONTENT_MAX_WIDTH = \"min(32rem, calc(100vw - 2rem))\";\n\nconst TOOLBAR_TRANSITION = {\n  width: { duration: 0.2, ease: EASE_OUT },\n  opacity: { duration: 0.14, ease: EASE_OUT },\n} as const;\nconst TRIGGER_SURFACE_TRANSITION = { duration: 0.18, ease: EASE_OUT } as const;\n// An icon swap is a tiny state change, so keep it snappy: ease-out, well\n// under 150ms — the copy button's copy → check treatment, trimmed a touch\n// because the swap runs exit-then-enter and the perceived time is doubled.\nconst TRIGGER_ICON_TRANSITION = {\n  duration: 0.1,\n  ease: [0.215, 0.61, 0.355, 1],\n} as const;\nconst TRIGGER_ICON_VARIANTS = {\n  hidden: { opacity: 0, scale: 0.5 },\n  visible: { opacity: 1, scale: 1 },\n} as const;\nconst TOOLBAR_PADDING = 2;\n/** Must match the toolbar surface's `border` class. */\nconst TOOLBAR_BORDER_WIDTH = 1;\nconst TRIGGER_RADIUS_OPEN = 8;\nconst TRIGGER_RADIUS_CLOSED = TRIGGER_RADIUS_OPEN + TOOLBAR_PADDING;\n\ntype ExpandableToolbarSide = \"start\" | \"end\" | \"center\";\ntype ExpandableToolbarAnchor = \"toolbar\" | \"trigger\";\ntype ExpandableToolbarTriggerProps = ComponentProps<\"button\"> & {\n  \"data-state\": \"open\" | \"closed\";\n};\n\nexport type ExpandableToolbarClassNames = {\n  triggerWrapper?: string;\n  trigger?: string;\n  triggerSeparator?: string;\n  panel?: string;\n  content?: string;\n};\n\nexport type ExpandableToolbarTriggerRenderProps = {\n  open: boolean;\n  disabled: boolean;\n  label: string;\n  controlsId: string;\n  triggerProps: ExpandableToolbarTriggerProps;\n};\n\ntype ExpandableToolbarBaseProps = Omit<\n  ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> & {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state for uncontrolled usage. */\n  defaultOpen?: boolean;\n  /** Called whenever the toolbar requests an open-state change. */\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Which side of the trigger the content should expand into. `center` splits\n   * the children into two panels flanking the trigger, so the toolbar grows\n   * symmetrically while the trigger stays put.\n   */\n  side?: ExpandableToolbarSide;\n  /** Whether the full toolbar or only the trigger participates in layout. */\n  anchor?: ExpandableToolbarAnchor;\n  expandLabel?: string;\n  collapseLabel?: string;\n  controlsId?: string;\n  disabled?: boolean;\n  /** Close the toolbar when Escape is pressed inside it. */\n  closeOnEscape?: boolean;\n  contentMaxWidth?: CSSProperties[\"maxWidth\"];\n  classNames?: ExpandableToolbarClassNames;\n  children: ReactNode;\n};\n\ntype ExpandableToolbarDefaultTriggerProps = {\n  /** Icon used by the default trigger when the toolbar is closed. */\n  expandIcon: ReactNode;\n  /** Icon used by the default trigger when the toolbar is open. */\n  collapseIcon?: ReactNode;\n  renderTrigger?: never;\n};\n\ntype ExpandableToolbarCustomTriggerProps = {\n  expandIcon?: ReactNode;\n  collapseIcon?: ReactNode;\n  /**\n   * Replace the default icon button trigger while keeping the measured panel,\n   * ARIA attributes, and open-state plumbing.\n   */\n  renderTrigger: (props: ExpandableToolbarTriggerRenderProps) => ReactNode;\n};\n\nexport type ExpandableToolbarProps = ExpandableToolbarBaseProps &\n  (ExpandableToolbarDefaultTriggerProps | ExpandableToolbarCustomTriggerProps);\n\nexport function ExpandableToolbar({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"start\",\n  anchor = \"toolbar\",\n  expandIcon,\n  collapseIcon,\n  expandLabel = \"Expand toolbar\",\n  collapseLabel = \"Collapse toolbar\",\n  controlsId,\n  disabled = false,\n  closeOnEscape = true,\n  contentMaxWidth = DEFAULT_CONTENT_MAX_WIDTH,\n  className,\n  classNames,\n  renderTrigger,\n  children,\n  role,\n  \"aria-label\": ariaLabel = \"Expandable toolbar\",\n  onKeyDown,\n  style,\n  ...props\n}: ExpandableToolbarProps) {\n  const generatedId = useId();\n  const panelId = controlsId ?? `${generatedId}-panel`;\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const triggerWrapperRef = useRef<HTMLDivElement>(null);\n  const [startContentRef, startContentWidth] =\n    useMeasuredWidth<HTMLDivElement>();\n  const [endContentRef, endContentWidth] = useMeasuredWidth<HTMLDivElement>();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const shouldReduceMotion = useReducedMotion();\n  const controlled = open !== undefined;\n  const isOpen = controlled ? open : internalOpen;\n  const currentLabel = isOpen ? collapseLabel : expandLabel;\n\n  // `center` flanks the trigger with two panels: the first half of the\n  // children expands into the start side, the rest into the end side.\n  const childArray = Children.toArray(children);\n  const splitIndex = Math.ceil(childArray.length / 2);\n  const startChildren =\n    side === \"start\"\n      ? childArray\n      : side === \"center\"\n        ? childArray.slice(0, splitIndex)\n        : [];\n  const endChildren =\n    side === \"end\"\n      ? childArray\n      : side === \"center\"\n        ? childArray.slice(splitIndex)\n        : [];\n  const startPanelId = side === \"center\" ? `${panelId}-start` : panelId;\n  const endPanelId = side === \"center\" ? `${panelId}-end` : panelId;\n  const startPanelWidth = isOpen ? startContentWidth : 0;\n\n  const focusTrigger = useCallback(() => {\n    const trigger =\n      triggerRef.current ?? getFirstFocusableElement(triggerWrapperRef.current);\n\n    trigger?.focus();\n  }, []);\n\n  const setOpen = useCallback(\n    (nextOpen: boolean) => {\n      if (disabled) return;\n\n      if (!controlled) {\n        setInternalOpen(nextOpen);\n      }\n\n      onOpenChange?.(nextOpen);\n    },\n    [controlled, disabled, onOpenChange],\n  );\n\n  const toggleOpen = useCallback(() => {\n    setOpen(!isOpen);\n  }, [isOpen, setOpen]);\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      onKeyDown?.(event);\n\n      if (event.defaultPrevented) return;\n\n      if (closeOnEscape && isOpen && event.key === \"Escape\") {\n        event.stopPropagation();\n        setOpen(false);\n        focusTrigger();\n        return;\n      }\n\n      // `role=\"toolbar\"` promises horizontal arrow-key navigation (ARIA APG),\n      // so move focus between the visible controls. Text-entry controls keep\n      // the arrows for caret movement.\n      if (!TOOLBAR_NAV_KEYS.includes(event.key)) return;\n\n      const activeElement = document.activeElement;\n\n      if (activeElement instanceof HTMLElement && isTextEntry(activeElement)) {\n        return;\n      }\n\n      // Collapsed panels carry `inert` + `aria-hidden`, so filter on that\n      // rather than on layout: `offsetParent` is also null for `position:\n      // fixed` elements that are perfectly visible.\n      const focusables = Array.from(\n        event.currentTarget.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),\n      ).filter(\n        (element) =>\n          !element.closest(\"[inert],[hidden],[aria-hidden='true']\"),\n      );\n\n      if (focusables.length === 0) return;\n\n      const currentIndex = focusables.indexOf(activeElement as HTMLElement);\n      const lastIndex = focusables.length - 1;\n      let nextIndex: number;\n\n      if (event.key === \"Home\") {\n        nextIndex = 0;\n      } else if (event.key === \"End\") {\n        nextIndex = lastIndex;\n      } else if (event.key === \"ArrowRight\") {\n        nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % focusables.length;\n      } else {\n        nextIndex =\n          currentIndex < 0\n            ? lastIndex\n            : (currentIndex - 1 + focusables.length) % focusables.length;\n      }\n\n      event.preventDefault();\n      focusables[nextIndex]?.focus();\n    },\n    [closeOnEscape, focusTrigger, isOpen, onKeyDown, setOpen],\n  );\n\n  const triggerRadius = isOpen ? TRIGGER_RADIUS_OPEN : TRIGGER_RADIUS_CLOSED;\n  const triggerTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : TRIGGER_SURFACE_TRANSITION;\n  const transition = shouldReduceMotion ? { duration: 0 } : TOOLBAR_TRANSITION;\n  const triggerProps = {\n    type: \"button\",\n    disabled,\n    \"aria-label\": currentLabel,\n    \"aria-expanded\": isOpen,\n    \"aria-controls\":\n      side === \"center\" ? `${startPanelId} ${endPanelId}` : panelId,\n    \"data-state\": isOpen ? \"open\" : \"closed\",\n    className: classNames?.trigger,\n    onClick: toggleOpen,\n  } satisfies ExpandableToolbarTriggerProps;\n\n  const trigger = (\n    <motion.div\n      ref={triggerWrapperRef}\n      data-slot=\"expandable-toolbar-trigger-wrapper\"\n      data-state={isOpen ? \"open\" : \"closed\"}\n      className={cn(\n        \"group/expandable-toolbar-trigger relative isolate flex size-8 shrink-0 items-center justify-center\",\n        classNames?.triggerWrapper,\n      )}\n    >\n      {!renderTrigger ? (\n        <motion.span\n          aria-hidden=\"true\"\n          data-slot=\"expandable-toolbar-trigger-surface\"\n          initial={false}\n          animate={{\n            inset: isOpen ? 0 : -TOOLBAR_PADDING,\n            borderRadius: triggerRadius,\n          }}\n          transition={triggerTransition}\n          className={cn(\n            \"pointer-events-none absolute z-0 bg-muted opacity-0 transition-opacity\",\n            \"group-hover/expandable-toolbar-trigger:opacity-100 group-data-[state=open]/expandable-toolbar-trigger:opacity-100\",\n            \"dark:bg-muted/50\",\n            disabled && \"hidden\",\n          )}\n        />\n      ) : null}\n      {renderTrigger ? (\n        renderTrigger({\n          open: isOpen,\n          disabled,\n          label: currentLabel,\n          controlsId: panelId,\n          triggerProps,\n        })\n      ) : (\n        <DefaultExpandableToolbarTrigger\n          open={isOpen}\n          expandIcon={expandIcon}\n          collapseIcon={collapseIcon}\n          triggerProps={triggerProps}\n        />\n      )}\n    </motion.div>\n  );\n\n  const separator = (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"expandable-toolbar-trigger-separator\"\n      className={cn(\n        \"mx-1 h-5 w-px shrink-0 bg-border\",\n        classNames?.triggerSeparator,\n      )}\n    />\n  );\n\n  const renderPanel = (\n    position: \"start\" | \"end\",\n    id: string,\n    content: ReactNode[],\n    contentRef: Ref<HTMLDivElement>,\n    contentWidth: number,\n  ) => (\n    <motion.div\n      initial={false}\n      id={id}\n      aria-hidden={!isOpen}\n      inert={!isOpen ? true : undefined}\n      data-slot=\"expandable-toolbar-panel\"\n      data-state={isOpen ? \"open\" : \"closed\"}\n      animate={{ width: isOpen ? contentWidth : 0, opacity: isOpen ? 1 : 0 }}\n      transition={transition}\n      className={cn(\n        \"flex min-w-0 overflow-hidden whitespace-nowrap\",\n        position === \"start\" ? \"justify-end\" : \"justify-start\",\n        !isOpen && \"pointer-events-none\",\n        classNames?.panel,\n      )}\n      style={{ maxWidth: contentMaxWidth }}\n    >\n      <div\n        ref={contentRef}\n        data-slot=\"expandable-toolbar-content\"\n        className={cn(\n          \"flex w-max shrink-0 flex-nowrap items-center gap-1\",\n          classNames?.content,\n        )}\n      >\n        {position === \"end\" ? separator : null}\n        {content}\n        {position === \"start\" ? separator : null}\n      </div>\n    </motion.div>\n  );\n\n  const toolbar = (\n    <div\n      role={role ?? \"toolbar\"}\n      aria-label={ariaLabel}\n      data-slot=\"expandable-toolbar\"\n      data-state={isOpen ? \"open\" : \"closed\"}\n      data-side={side}\n      className={cn(\n        \"inline-flex max-w-full items-center overflow-hidden rounded-lg border bg-background text-foreground shadow-sm\",\n        className,\n      )}\n      onKeyDown={handleKeyDown}\n      style={{ ...style, padding: TOOLBAR_PADDING }}\n      {...props}\n    >\n      {startChildren.length > 0\n        ? renderPanel(\n            \"start\",\n            startPanelId,\n            startChildren,\n            startContentRef,\n            startContentWidth,\n          )\n        : null}\n      {trigger}\n      {endChildren.length > 0\n        ? renderPanel(\n            \"end\",\n            endPanelId,\n            endChildren,\n            endContentRef,\n            endContentWidth,\n          )\n        : null}\n    </div>\n  );\n\n  if (anchor === \"trigger\") {\n    return (\n      <div\n        data-slot=\"expandable-toolbar-anchor\"\n        data-state={isOpen ? \"open\" : \"closed\"}\n        data-side={side}\n        className=\"relative inline-flex size-8 shrink-0\"\n      >\n        <motion.div\n          className={cn(\n            \"absolute top-0\",\n            side === \"start\" && \"right-0\",\n            side === \"end\" && \"left-0\",\n          )}\n          // For `center`, the box's left edge starts one border + padding to\n          // the left of the anchor slot and shifts by the start panel's width\n          // as it opens, so the trigger itself never moves — both panels\n          // appear to grow out of it symmetrically.\n          style={\n            side === \"center\"\n              ? { left: -(TOOLBAR_PADDING + TOOLBAR_BORDER_WIDTH) }\n              : undefined\n          }\n          animate={\n            side === \"center\" ? { x: -startPanelWidth } : undefined\n          }\n          transition={\n            shouldReduceMotion ? { duration: 0 } : TOOLBAR_TRANSITION.width\n          }\n        >\n          {toolbar}\n        </motion.div>\n      </div>\n    );\n  }\n\n  return toolbar;\n}\n\nfunction DefaultExpandableToolbarTrigger({\n  open,\n  expandIcon,\n  collapseIcon,\n  triggerProps,\n}: {\n  open: boolean;\n  expandIcon?: ReactNode;\n  collapseIcon?: ReactNode;\n  triggerProps: ExpandableToolbarTriggerProps;\n}) {\n  const shouldReduceMotion = useReducedMotion();\n  const { className, ...buttonProps } = triggerProps;\n  const icon = open ? (collapseIcon ?? expandIcon) : expandIcon;\n  const swapsIcon = collapseIcon != null;\n  const iconTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : TRIGGER_ICON_TRANSITION;\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      className={cn(\n        \"relative z-10 size-full bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground aria-expanded:bg-transparent aria-expanded:text-foreground active:scale-100 active:translate-y-0 active:not-aria-[haspopup]:translate-y-0 dark:hover:bg-transparent\",\n        className,\n      )}\n      {...buttonProps}\n    >\n      {swapsIcon ? (\n        <AnimatePresence mode=\"wait\" initial={false}>\n          <motion.span\n            key={open ? \"collapse\" : \"expand\"}\n            aria-hidden=\"true\"\n            variants={TRIGGER_ICON_VARIANTS}\n            initial=\"hidden\"\n            animate=\"visible\"\n            exit=\"hidden\"\n            transition={iconTransition}\n            className=\"flex items-center justify-center\"\n          >\n            {icon}\n          </motion.span>\n        </AnimatePresence>\n      ) : (\n        <span aria-hidden=\"true\" className=\"flex items-center justify-center\">\n          {icon}\n        </span>\n      )}\n    </Button>\n  );\n}\n\nconst FOCUSABLE_SELECTOR =\n  'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\nfunction getFirstFocusableElement(element: HTMLElement | null) {\n  return element?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;\n}\n\nconst TOOLBAR_NAV_KEYS = [\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"];\n\n/** Controls where the arrow keys belong to the caret, not to toolbar nav. */\nfunction isTextEntry(element: HTMLElement) {\n  return (\n    element instanceof HTMLTextAreaElement ||\n    element.isContentEditable ||\n    (element instanceof HTMLInputElement &&\n      ![\"button\", \"checkbox\", \"radio\", \"range\", \"submit\", \"reset\"].includes(\n        element.type,\n      ))\n  );\n}\n\nfunction useMeasuredWidth<T extends HTMLElement>() {\n  const [width, setWidth] = useState(0);\n  const observerRef = useRef<ResizeObserver | null>(null);\n\n  // A callback ref (rather than a mount-only effect) so a panel that mounts\n  // later — `side` flipping to \"center\", children growing past one — is still\n  // measured and observed instead of animating open to width 0.\n  const ref = useCallback((element: T | null) => {\n    observerRef.current?.disconnect();\n    observerRef.current = null;\n\n    if (!element) return;\n\n    const updateWidth = (nextWidth: number) => {\n      setWidth((currentWidth) =>\n        currentWidth === nextWidth ? currentWidth : nextWidth,\n      );\n    };\n\n    updateWidth(readElementWidth(element));\n\n    if (typeof ResizeObserver === \"undefined\") return;\n\n    const resizeObserver = new ResizeObserver((entries) => {\n      updateWidth(readElementWidth(element, entries[0]));\n    });\n\n    resizeObserver.observe(element);\n    observerRef.current = resizeObserver;\n  }, []);\n\n  return [ref, width] as const;\n}\n\nfunction readElementWidth(\n  element: HTMLElement,\n  entry?: ResizeObserverEntry,\n) {\n  const borderBoxSize = Array.isArray(entry?.borderBoxSize)\n    ? entry?.borderBoxSize[0]\n    : entry?.borderBoxSize;\n\n  if (borderBoxSize) {\n    return Math.ceil(borderBoxSize.inlineSize);\n  }\n\n  return Math.ceil(element.getBoundingClientRect().width || element.scrollWidth);\n}\n",
      "type": "registry:ui",
      "target": "components/ui/expandable-toolbar.tsx"
    }
  ],
  "meta": {
    "tags": [
      "toolbar",
      "expandable",
      "quick-actions",
      "measurement",
      "controlled",
      "button"
    ],
    "effects": [
      "width-animation",
      "fade",
      "reduced-motion"
    ]
  },
  "categories": [
    "container"
  ],
  "type": "registry:ui"
}