{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"A Figma-style floating vector toolbar with tool selection, nested flyouts and a contextual properties bar.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentType,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\n\nconst LONG_PRESS_MS = 450;\n\n/** Moving on screen: spring, no overshoot worth noticing on a 36px hop. */\nconst PILL_SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\n/** Entering: strong ease-out. Exits are shorter, the decision is already made. */\nconst ENTER_TRANSITION = {\n  duration: 0.22,\n  ease: [0.23, 1, 0.32, 1] as [number, number, number, number],\n};\nconst EXIT_TRANSITION = {\n  duration: 0.14,\n  ease: [0.23, 1, 0.32, 1] as [number, number, number, number],\n};\nconst INSTANT = { duration: 0 };\n\nconst PANEL_SURFACE =\n  \"rounded-2xl border border-foreground/10 bg-background/80 shadow-[0px_0px_0.5px_0px_rgba(0,0,0,0.18),0px_8px_24px_-8px_rgba(0,0,0,0.28),0px_2px_6px_-2px_rgba(0,0,0,0.12)] backdrop-blur-xl\";\n\nconst EDITABLE_TAGS = new Set([\"INPUT\", \"TEXTAREA\", \"SELECT\"]);\n\nconst isEditableTarget = (target: EventTarget | null): boolean => {\n  if (!(target instanceof HTMLElement)) {\n    return false;\n  }\n  if (EDITABLE_TAGS.has(target.tagName)) {\n    return true;\n  }\n  return target.isContentEditable;\n};\n\nconst findOwnerTool = (\n  tools: VectorTool[],\n  id: string | undefined\n): VectorTool | undefined => {\n  if (!id) {\n    return;\n  }\n  for (const tool of tools) {\n    if (tool.id === id || tool.items?.some((item) => item.id === id)) {\n      return tool;\n    }\n  }\n};\n\nexport interface VectorToolItem {\n  icon: ComponentType<{ className?: string }>;\n  id: string;\n  label: string;\n  shortcut?: string;\n}\n\nexport interface VectorTool extends VectorToolItem {\n  items?: VectorToolItem[];\n}\n\nexport interface VectorEditorToolbarProps {\n  /** Controlled active tool id (matches a top-level tool id or a nested item id). */\n  activeTool?: string;\n  className?: string;\n  /**\n   * Renders the toolbar as a draggable panel pinned near the top of its nearest\n   * positioned ancestor. The overlay itself is click-through, so only the panel\n   * intercepts pointer events and whatever sits underneath stays interactive.\n   */\n  floating?: boolean;\n  onToolChange?: (toolId: string) => void;\n  orientation?: \"horizontal\" | \"vertical\";\n  /**\n   * Content for the contextual properties panel. It floats beside the toolbar\n   * rather than sitting in its flow, so revealing it never moves the tools.\n   */\n  properties?: ReactNode;\n  /** Enables single-key shortcuts bound on `document`. Defaults to true. */\n  shortcutsEnabled?: boolean;\n  tools: VectorTool[];\n}\n\n/** Bottom-right corner triangle marking a tool that hides more tools. */\nconst CornerTriangle = () => (\n  <svg\n    aria-hidden=\"true\"\n    className=\"size-1.5 fill-current\"\n    focusable=\"false\"\n    viewBox=\"0 0 6 6\"\n  >\n    <path d=\"M6 0v6H0z\" />\n  </svg>\n);\n\nexport default function VectorEditorToolbar({\n  activeTool,\n  className,\n  floating = false,\n  onToolChange,\n  orientation = \"horizontal\",\n  properties,\n  shortcutsEnabled = true,\n  tools,\n}: VectorEditorToolbarProps) {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const pillId = useId();\n  const isHorizontal = orientation === \"horizontal\";\n\n  const [internalActiveTool, setInternalActiveTool] = useState<\n    string | undefined\n  >(activeTool ?? tools[0]?.id);\n  const resolvedActiveTool = activeTool ?? internalActiveTool;\n  const activeOwner = findOwnerTool(tools, resolvedActiveTool) ?? tools[0];\n  const activeOwnerId = activeOwner?.id;\n\n  const [focusedToolId, setFocusedToolId] = useState(activeOwnerId ?? \"\");\n  const [openFlyoutId, setOpenFlyoutId] = useState<string | null>(null);\n  const [flyoutFocusedIndex, setFlyoutFocusedIndex] = useState(0);\n\n  const boundsRef = useRef<HTMLDivElement>(null);\n  const flyoutPanelRef = useRef<HTMLDivElement | null>(null);\n  const toolButtonRefs = useRef(new Map<string, HTMLButtonElement>());\n  const flyoutItemRefs = useRef(new Map<string, HTMLButtonElement>());\n  const longPressTimerRef = useRef<number | null>(null);\n  const longPressTriggeredRef = useRef(false);\n\n  const selectTool = useCallback(\n    (id: string) => {\n      if (activeTool === undefined) {\n        setInternalActiveTool(id);\n      }\n      onToolChange?.(id);\n      setOpenFlyoutId(null);\n    },\n    [activeTool, onToolChange]\n  );\n\n  // Keep roving tabindex in sync with the resolved active tool.\n  useEffect(() => {\n    setFocusedToolId(activeOwnerId ?? \"\");\n  }, [activeOwnerId]);\n\n  // Close the open flyout when clicking outside its panel and trigger.\n  useEffect(() => {\n    if (!openFlyoutId) {\n      return;\n    }\n    const handlePointerDown = (event: PointerEvent) => {\n      const panel = flyoutPanelRef.current;\n      const trigger = toolButtonRefs.current.get(openFlyoutId);\n      const target = event.target as Node;\n      if (panel?.contains(target) || trigger?.contains(target)) {\n        return;\n      }\n      setOpenFlyoutId(null);\n    };\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [openFlyoutId]);\n\n  // Move focus onto the highlighted flyout item as the user navigates it.\n  useEffect(() => {\n    if (!openFlyoutId) {\n      return;\n    }\n    const tool = tools.find((candidate) => candidate.id === openFlyoutId);\n    const item = tool?.items?.[flyoutFocusedIndex];\n    if (item) {\n      flyoutItemRefs.current.get(item.id)?.focus();\n    }\n  }, [openFlyoutId, flyoutFocusedIndex, tools]);\n\n  // Single-key shortcuts, ignored while typing in an editable field.\n  useEffect(() => {\n    if (!shortcutsEnabled) {\n      return;\n    }\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (isEditableTarget(event.target)) {\n        return;\n      }\n      if (event.metaKey || event.ctrlKey || event.altKey) {\n        return;\n      }\n      const key = event.key.toLowerCase();\n      for (const tool of tools) {\n        if (tool.shortcut?.toLowerCase() === key) {\n          selectTool(tool.id);\n          return;\n        }\n        for (const item of tool.items ?? []) {\n          if (item.shortcut?.toLowerCase() === key) {\n            selectTool(item.id);\n            return;\n          }\n        }\n      }\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [shortcutsEnabled, tools, selectTool]);\n\n  useEffect(\n    () => () => {\n      if (longPressTimerRef.current !== null) {\n        window.clearTimeout(longPressTimerRef.current);\n      }\n    },\n    []\n  );\n\n  const clearLongPressTimer = () => {\n    if (longPressTimerRef.current !== null) {\n      window.clearTimeout(longPressTimerRef.current);\n      longPressTimerRef.current = null;\n    }\n  };\n\n  const openFlyout = (toolId: string) => {\n    setOpenFlyoutId(toolId);\n    setFlyoutFocusedIndex(0);\n  };\n\n  const handleToolPointerDown = (tool: VectorTool) => {\n    longPressTriggeredRef.current = false;\n    if (!tool.items?.length) {\n      return;\n    }\n    longPressTimerRef.current = window.setTimeout(() => {\n      longPressTriggeredRef.current = true;\n      openFlyout(tool.id);\n    }, LONG_PRESS_MS);\n  };\n\n  const handleToolClick = (tool: VectorTool) => {\n    if (longPressTriggeredRef.current) {\n      longPressTriggeredRef.current = false;\n      return;\n    }\n    // Clicking the tool that is already active reveals its variants, so the\n    // flyout is reachable without knowing about long-press.\n    if (tool.items?.length && tool.id === activeOwnerId) {\n      openFlyout(tool.id);\n      return;\n    }\n    selectTool(tool.id);\n  };\n\n  const handleCornerClick = (\n    tool: VectorTool,\n    event: ReactMouseEvent<HTMLButtonElement>\n  ) => {\n    event.stopPropagation();\n    clearLongPressTimer();\n    setOpenFlyoutId((previous) => (previous === tool.id ? null : tool.id));\n    setFlyoutFocusedIndex(0);\n  };\n\n  const handleToolbarKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (openFlyoutId) {\n      return;\n    }\n    const nextKey = isHorizontal ? \"ArrowRight\" : \"ArrowDown\";\n    const prevKey = isHorizontal ? \"ArrowLeft\" : \"ArrowUp\";\n    const openKey = isHorizontal ? \"ArrowDown\" : \"ArrowRight\";\n\n    if (event.key === openKey) {\n      const tool = tools.find((candidate) => candidate.id === focusedToolId);\n      if (tool?.items?.length) {\n        event.preventDefault();\n        openFlyout(tool.id);\n      }\n      return;\n    }\n\n    if (event.key !== nextKey && event.key !== prevKey) {\n      return;\n    }\n    event.preventDefault();\n    const currentIndex = tools.findIndex(\n      (candidate) => candidate.id === focusedToolId\n    );\n    const delta = event.key === nextKey ? 1 : -1;\n    const nextIndex = (currentIndex + delta + tools.length) % tools.length;\n    const nextTool = tools[nextIndex];\n    if (!nextTool) {\n      return;\n    }\n    setFocusedToolId(nextTool.id);\n    toolButtonRefs.current.get(nextTool.id)?.focus();\n  };\n\n  const handleFlyoutKeyDown = (\n    event: ReactKeyboardEvent<HTMLDivElement>,\n    tool: VectorTool\n  ) => {\n    const items = tool.items ?? [];\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setOpenFlyoutId(null);\n      toolButtonRefs.current.get(tool.id)?.focus();\n      return;\n    }\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      const delta = event.key === \"ArrowDown\" ? 1 : -1;\n      setFlyoutFocusedIndex(\n        (previous) => (previous + delta + items.length) % items.length\n      );\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      const item = items[flyoutFocusedIndex];\n      if (item) {\n        selectTool(item.id);\n        toolButtonRefs.current.get(tool.id)?.focus();\n      }\n    }\n  };\n\n  const renderFlyout = (tool: VectorTool) => (\n    <div\n      className={cn(\n        \"absolute z-30\",\n        isHorizontal\n          ? \"top-full left-1/2 mt-2 -translate-x-1/2\"\n          : \"top-0 left-full ml-2\"\n      )}\n    >\n      <AnimatePresence>\n        {openFlyoutId === tool.id ? (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            aria-label={`${tool.label} options`}\n            className={cn(\n              \"relative flex min-w-44 flex-col gap-0.5 p-1.5\",\n              PANEL_SURFACE\n            )}\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: INSTANT }\n                : { opacity: 0, scale: 0.98, transition: EXIT_TRANSITION }\n            }\n            initial={\n              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.96 }\n            }\n            onKeyDown={(event) => handleFlyoutKeyDown(event, tool)}\n            ref={flyoutPanelRef}\n            role=\"group\"\n            style={{\n              transformOrigin: isHorizontal ? \"top center\" : \"left center\",\n            }}\n            transition={shouldReduceMotion ? INSTANT : ENTER_TRANSITION}\n          >\n            <span\n              aria-hidden=\"true\"\n              className={cn(\n                \"absolute size-2 rotate-45 border-foreground/10 bg-background\",\n                isHorizontal\n                  ? \"-top-1 left-1/2 -translate-x-1/2 border-t border-l\"\n                  : \"top-4 -left-1 border-b border-l\"\n              )}\n            />\n            {tool.items?.map((item, index) => {\n              const ItemIcon = item.icon;\n              const isItemActive = resolvedActiveTool === item.id;\n              return (\n                <button\n                  aria-label={item.label}\n                  aria-pressed={isItemActive}\n                  className={cn(\n                    \"flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-foreground/70 text-sm transition-colors ease-out hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand focus-visible:outline-offset-1\",\n                    isItemActive && \"bg-foreground/10 text-foreground\",\n                    flyoutFocusedIndex === index && \"bg-foreground/5\"\n                  )}\n                  key={item.id}\n                  onClick={() => {\n                    selectTool(item.id);\n                    toolButtonRefs.current.get(tool.id)?.focus();\n                  }}\n                  ref={(el) => {\n                    if (el) {\n                      flyoutItemRefs.current.set(item.id, el);\n                    } else {\n                      flyoutItemRefs.current.delete(item.id);\n                    }\n                  }}\n                  tabIndex={-1}\n                  type=\"button\"\n                >\n                  <ItemIcon className=\"size-4 shrink-0\" />\n                  <span className=\"flex-1\">{item.label}</span>\n                  {item.shortcut ? (\n                    <span className=\"font-medium text-[10px] text-foreground/40 uppercase tabular-nums\">\n                      {item.shortcut}\n                    </span>\n                  ) : null}\n                </button>\n              );\n            })}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n\n  const toolbarPanel = (\n    <div className=\"relative\">\n      <div\n        aria-label=\"Vector editor tools\"\n        aria-orientation={orientation}\n        className={cn(\n          \"flex items-center gap-1 p-1.5\",\n          PANEL_SURFACE,\n          isHorizontal ? \"flex-row\" : \"flex-col\"\n        )}\n        onKeyDown={handleToolbarKeyDown}\n        role=\"toolbar\"\n      >\n        {tools.map((tool) => {\n          const isActive = tool.id === activeOwnerId;\n          const activeItem = tool.items?.find(\n            (item) => item.id === resolvedActiveTool\n          );\n          // Figma-style: the cell adopts the icon of the variant last used.\n          const Icon = (isActive && activeItem?.icon) || tool.icon;\n          const isFocusable = tool.id === focusedToolId;\n          const hasItems = Boolean(tool.items?.length);\n          const isFlyoutOpen = openFlyoutId === tool.id;\n          const label = (isActive && activeItem?.label) || tool.label;\n\n          return (\n            <div className=\"relative size-9 shrink-0\" key={tool.id}>\n              {isActive ? (\n                <motion.span\n                  className=\"absolute inset-0 rounded-xl bg-foreground/10\"\n                  layoutId={`vector-editor-toolbar-pill-${pillId}`}\n                  transition={shouldReduceMotion ? INSTANT : PILL_SPRING}\n                />\n              ) : null}\n              <button\n                aria-expanded={hasItems ? isFlyoutOpen : undefined}\n                aria-haspopup={hasItems ? \"menu\" : undefined}\n                aria-label={label}\n                aria-pressed={isActive}\n                className={cn(\n                  \"absolute inset-0 flex cursor-pointer items-center justify-center rounded-xl text-foreground/60\",\n                  \"transition-[color,transform] duration-150 ease-out active:scale-[0.92]\",\n                  \"hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand focus-visible:outline-offset-2\",\n                  \"motion-reduce:transition-none motion-reduce:active:scale-100\",\n                  isActive && \"text-foreground\"\n                )}\n                onClick={() => handleToolClick(tool)}\n                onPointerCancel={clearLongPressTimer}\n                onPointerDown={() => handleToolPointerDown(tool)}\n                onPointerLeave={clearLongPressTimer}\n                onPointerUp={clearLongPressTimer}\n                ref={(el) => {\n                  if (el) {\n                    toolButtonRefs.current.set(tool.id, el);\n                  } else {\n                    toolButtonRefs.current.delete(tool.id);\n                  }\n                }}\n                tabIndex={isFocusable ? 0 : -1}\n                title={\n                  tool.shortcut\n                    ? `${label} (${tool.shortcut.toUpperCase()})`\n                    : label\n                }\n                type=\"button\"\n              >\n                <Icon className=\"size-4\" />\n              </button>\n              {hasItems ? (\n                <button\n                  aria-label={`${tool.label} options`}\n                  className={cn(\n                    \"absolute right-0 bottom-0 flex size-3.5 cursor-pointer items-end justify-end rounded-br-xl pr-[3px] pb-[3px]\",\n                    \"text-foreground/25 transition-colors ease-out hover:text-foreground/70\",\n                    isActive && \"text-foreground/45\"\n                  )}\n                  onClick={(event) => handleCornerClick(tool, event)}\n                  tabIndex={-1}\n                  type=\"button\"\n                >\n                  <CornerTriangle />\n                </button>\n              ) : null}\n              {hasItems ? renderFlyout(tool) : null}\n            </div>\n          );\n        })}\n      </div>\n\n      <div\n        className={cn(\n          \"absolute z-20\",\n          // Vertical flyouts already own the right-hand side, so the properties\n          // panel goes below the toolbar in both orientations.\n          isHorizontal\n            ? \"top-full left-1/2 mt-1.5 -translate-x-1/2\"\n            : \"top-full left-0 mt-1.5\"\n        )}\n      >\n        <AnimatePresence>\n          {properties ? (\n            <motion.div\n              animate={{ opacity: 1, scale: 1, y: 0 }}\n              className={cn(\"w-max p-2\", PANEL_SURFACE)}\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, transition: INSTANT }\n                  : {\n                      opacity: 0,\n                      scale: 0.98,\n                      transition: EXIT_TRANSITION,\n                      y: -4,\n                    }\n              }\n              initial={\n                shouldReduceMotion\n                  ? { opacity: 1, scale: 1, y: 0 }\n                  : { opacity: 0, scale: 0.98, y: -4 }\n              }\n              style={{\n                transformOrigin: isHorizontal ? \"top center\" : \"top left\",\n              }}\n              transition={shouldReduceMotion ? INSTANT : ENTER_TRANSITION}\n            >\n              {properties}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n\n  if (!floating) {\n    return (\n      <div className={cn(\"relative\", className)} ref={boundsRef}>\n        {toolbarPanel}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\"pointer-events-none absolute inset-0\", className)}\n      ref={boundsRef}\n    >\n      <div className=\"absolute inset-x-0 top-4 flex justify-center\">\n        <motion.div\n          className=\"pointer-events-auto\"\n          drag\n          dragConstraints={boundsRef}\n          dragElastic={0}\n          dragMomentum={false}\n          style={{ touchAction: \"none\" }}\n        >\n          {toolbarPanel}\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/vector-editor-toolbar/index.tsx","type":"registry:ui"}],"name":"vector-editor-toolbar","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Vector Editor Toolbar","type":"registry:ui"}