{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree","type":"registry:ui","title":"tree","description":"A composable tree component with animated expand/collapse and customizable nodes.","author":"Hayden Bleasel <hello@haydenbleasel.com>","dependencies":["motion","lucide-react"],"devDependencies":[],"registryDependencies":[],"files":[{"type":"registry:ui","path":"index.tsx","content":"\"use client\";\n\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type HTMLAttributes,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useId,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype TreeContextType = {\n  expandedIds: Set<string>;\n  selectedIds: string[];\n  toggleExpanded: (nodeId: string) => void;\n  handleSelection: (nodeId: string, ctrlKey: boolean) => void;\n  showLines?: boolean;\n  showIcons?: boolean;\n  selectable?: boolean;\n  multiSelect?: boolean;\n  indent?: number;\n  animateExpand?: boolean;\n};\n\nconst TreeContext = createContext<TreeContextType | undefined>(undefined);\n\nconst useTree = () => {\n  const context = useContext(TreeContext);\n  if (!context) {\n    throw new Error(\"Tree components must be used within a TreeProvider\");\n  }\n  return context;\n};\n\ntype TreeNodeContextType = {\n  nodeId: string;\n  level: number;\n  isLast: boolean;\n  parentPath: boolean[];\n};\n\nconst TreeNodeContext = createContext<TreeNodeContextType | undefined>(\n  undefined\n);\n\nconst useTreeNode = () => {\n  const context = useContext(TreeNodeContext);\n  if (!context) {\n    throw new Error(\"TreeNode components must be used within a TreeNode\");\n  }\n  return context;\n};\n\nexport type TreeProviderProps = {\n  children: ReactNode;\n  defaultExpandedIds?: string[];\n  showLines?: boolean;\n  showIcons?: boolean;\n  selectable?: boolean;\n  multiSelect?: boolean;\n  selectedIds?: string[];\n  onSelectionChange?: (selectedIds: string[]) => void;\n  indent?: number;\n  animateExpand?: boolean;\n  className?: string;\n};\n\nexport const TreeProvider = ({\n  children,\n  defaultExpandedIds = [],\n  showLines = true,\n  showIcons = true,\n  selectable = true,\n  multiSelect = false,\n  selectedIds,\n  onSelectionChange,\n  indent = 20,\n  animateExpand = true,\n  className,\n}: TreeProviderProps) => {\n  const [expandedIds, setExpandedIds] = useState<Set<string>>(\n    new Set(defaultExpandedIds)\n  );\n  const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(\n    selectedIds ?? []\n  );\n\n  const isControlled =\n    selectedIds !== undefined && onSelectionChange !== undefined;\n  const currentSelectedIds = isControlled ? selectedIds : internalSelectedIds;\n\n  const toggleExpanded = useCallback((nodeId: string) => {\n    setExpandedIds((prev) => {\n      const newSet = new Set(prev);\n      if (newSet.has(nodeId)) {\n        newSet.delete(nodeId);\n      } else {\n        newSet.add(nodeId);\n      }\n      return newSet;\n    });\n  }, []);\n\n  const handleSelection = useCallback(\n    (nodeId: string, ctrlKey = false) => {\n      if (!selectable) {\n        return;\n      }\n\n      let newSelection: string[];\n\n      if (multiSelect && ctrlKey) {\n        newSelection = currentSelectedIds.includes(nodeId)\n          ? currentSelectedIds.filter((id) => id !== nodeId)\n          : [...currentSelectedIds, nodeId];\n      } else {\n        newSelection = currentSelectedIds.includes(nodeId) ? [] : [nodeId];\n      }\n\n      if (isControlled) {\n        onSelectionChange?.(newSelection);\n      } else {\n        setInternalSelectedIds(newSelection);\n      }\n    },\n    [\n      selectable,\n      multiSelect,\n      currentSelectedIds,\n      isControlled,\n      onSelectionChange,\n    ]\n  );\n\n  return (\n    <TreeContext.Provider\n      value={{\n        expandedIds,\n        selectedIds: currentSelectedIds,\n        toggleExpanded,\n        handleSelection,\n        showLines,\n        showIcons,\n        selectable,\n        multiSelect,\n        indent,\n        animateExpand,\n      }}\n    >\n      <motion.div\n        animate={{ opacity: 1, y: 0 }}\n        className={cn(\"w-full\", className)}\n        initial={{ opacity: 0, y: 10 }}\n        transition={{ duration: 0.3, ease: \"easeOut\" }}\n      >\n        {children}\n      </motion.div>\n    </TreeContext.Provider>\n  );\n};\n\nexport type TreeViewProps = HTMLAttributes<HTMLDivElement>;\n\nexport const TreeView = ({ className, children, ...props }: TreeViewProps) => (\n  <div className={cn(\"p-2\", className)} {...props}>\n    {children}\n  </div>\n);\n\nexport type TreeNodeProps = HTMLAttributes<HTMLDivElement> & {\n  nodeId?: string;\n  level?: number;\n  isLast?: boolean;\n  parentPath?: boolean[];\n  children?: ReactNode;\n};\n\nexport const TreeNode = ({\n  nodeId: providedNodeId,\n  level = 0,\n  isLast = false,\n  parentPath = [],\n  children,\n  className,\n  onClick,\n  ...props\n}: TreeNodeProps) => {\n  const generatedId = useId();\n  const nodeId = providedNodeId ?? generatedId;\n\n  // Build the parent path - mark positions where the parent was the last child\n  const currentPath = level === 0 ? [] : [...parentPath];\n  if (level > 0 && parentPath.length < level - 1) {\n    // Fill in missing levels with false (not last)\n    while (currentPath.length < level - 1) {\n      currentPath.push(false);\n    }\n  }\n  if (level > 0) {\n    currentPath[level - 1] = isLast;\n  }\n\n  return (\n    <TreeNodeContext.Provider\n      value={{\n        nodeId,\n        level,\n        isLast,\n        parentPath: currentPath,\n      }}\n    >\n      <div className={cn(\"select-none\", className)} {...props}>\n        {children}\n      </div>\n    </TreeNodeContext.Provider>\n  );\n};\n\nexport type TreeNodeTriggerProps = ComponentProps<typeof motion.div>;\n\nexport const TreeNodeTrigger = ({\n  children,\n  className,\n  onClick,\n  ...props\n}: TreeNodeTriggerProps) => {\n  const { selectedIds, toggleExpanded, handleSelection, indent } = useTree();\n  const { nodeId, level } = useTreeNode();\n  const isSelected = selectedIds.includes(nodeId);\n\n  return (\n    <motion.div\n      className={cn(\n        \"group relative mx-1 flex cursor-pointer items-center rounded-md px-3 py-2 transition-all duration-200\",\n        \"hover:bg-accent/50\",\n        isSelected && \"bg-accent/80\",\n        className\n      )}\n      onClick={(e) => {\n        toggleExpanded(nodeId);\n        handleSelection(nodeId, e.ctrlKey || e.metaKey);\n        onClick?.(e);\n      }}\n      style={{ paddingLeft: level * (indent ?? 0) + 8 }}\n      whileTap={{ scale: 0.98, transition: { duration: 0.1 } }}\n      {...props}\n    >\n      <TreeLines />\n      {children as ReactNode}\n    </motion.div>\n  );\n};\n\nexport const TreeLines = () => {\n  const { showLines, indent } = useTree();\n  const { level, isLast, parentPath } = useTreeNode();\n\n  if (!showLines || level === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"pointer-events-none absolute top-0 bottom-0 left-0\">\n      {/* Render vertical lines for all parent levels */}\n      {Array.from({ length: level }, (_, index) => {\n        const shouldHideLine = parentPath[index] === true;\n        if (shouldHideLine && index === level - 1) {\n          return null;\n        }\n\n        return (\n          <div\n            className=\"absolute top-0 bottom-0 border-border/40 border-l\"\n            key={index.toString()}\n            style={{\n              left: index * (indent ?? 0) + 12,\n              display: shouldHideLine ? \"none\" : \"block\",\n            }}\n          />\n        );\n      })}\n\n      {/* Horizontal connector line */}\n      <div\n        className=\"absolute top-1/2 border-border/40 border-t\"\n        style={{\n          left: (level - 1) * (indent ?? 0) + 12,\n          width: (indent ?? 0) - 4,\n          transform: \"translateY(-1px)\",\n        }}\n      />\n\n      {/* Vertical line to midpoint for last items */}\n      {isLast && (\n        <div\n          className=\"absolute top-0 border-border/40 border-l\"\n          style={{\n            left: (level - 1) * (indent ?? 0) + 12,\n            height: \"50%\",\n          }}\n        />\n      )}\n    </div>\n  );\n};\n\nexport type TreeNodeContentProps = ComponentProps<typeof motion.div> & {\n  hasChildren?: boolean;\n};\n\nexport const TreeNodeContent = ({\n  children,\n  hasChildren = false,\n  className,\n  ...props\n}: TreeNodeContentProps) => {\n  const { animateExpand, expandedIds } = useTree();\n  const { nodeId } = useTreeNode();\n  const isExpanded = expandedIds.has(nodeId);\n\n  return (\n    <AnimatePresence>\n      {hasChildren && isExpanded && (\n        <motion.div\n          animate={{ height: \"auto\", opacity: 1 }}\n          className=\"overflow-hidden\"\n          exit={{ height: 0, opacity: 0 }}\n          initial={{ height: 0, opacity: 0 }}\n          transition={{\n            duration: animateExpand ? 0.3 : 0,\n            ease: \"easeInOut\",\n          }}\n        >\n          <motion.div\n            animate={{ y: 0 }}\n            className={className}\n            exit={{ y: -10 }}\n            initial={{ y: -10 }}\n            transition={{\n              duration: animateExpand ? 0.2 : 0,\n              delay: animateExpand ? 0.1 : 0,\n            }}\n            {...props}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n};\n\nexport type TreeExpanderProps = ComponentProps<typeof motion.div> & {\n  hasChildren?: boolean;\n};\n\nexport const TreeExpander = ({\n  hasChildren = false,\n  className,\n  onClick,\n  ...props\n}: TreeExpanderProps) => {\n  const { expandedIds, toggleExpanded } = useTree();\n  const { nodeId } = useTreeNode();\n  const isExpanded = expandedIds.has(nodeId);\n\n  if (!hasChildren) {\n    return <div className=\"mr-1 h-4 w-4\" />;\n  }\n\n  return (\n    <motion.div\n      animate={{ rotate: isExpanded ? 90 : 0 }}\n      className={cn(\n        \"mr-1 flex h-4 w-4 cursor-pointer items-center justify-center\",\n        className\n      )}\n      onClick={(e) => {\n        e.stopPropagation();\n        toggleExpanded(nodeId);\n        onClick?.(e);\n      }}\n      transition={{ duration: 0.2, ease: \"easeInOut\" }}\n      {...props}\n    >\n      <ChevronRight className=\"h-3 w-3 text-muted-foreground\" />\n    </motion.div>\n  );\n};\n\nexport type TreeIconProps = ComponentProps<typeof motion.div> & {\n  icon?: ReactNode;\n  hasChildren?: boolean;\n};\n\nexport const TreeIcon = ({\n  icon,\n  hasChildren = false,\n  className,\n  ...props\n}: TreeIconProps) => {\n  const { showIcons, expandedIds } = useTree();\n  const { nodeId } = useTreeNode();\n  const isExpanded = expandedIds.has(nodeId);\n\n  if (!showIcons) {\n    return null;\n  }\n\n  const getDefaultIcon = () =>\n    hasChildren ? (\n      isExpanded ? (\n        <FolderOpen className=\"h-4 w-4\" />\n      ) : (\n        <Folder className=\"h-4 w-4\" />\n      )\n    ) : (\n      <File className=\"h-4 w-4\" />\n    );\n\n  return (\n    <motion.div\n      className={cn(\n        \"mr-2 flex h-4 w-4 items-center justify-center text-muted-foreground\",\n        className\n      )}\n      transition={{ duration: 0.15 }}\n      whileHover={{ scale: 1.1 }}\n      {...props}\n    >\n      {icon || getDefaultIcon()}\n    </motion.div>\n  );\n};\n\nexport type TreeLabelProps = HTMLAttributes<HTMLSpanElement>;\n\nexport const TreeLabel = ({ className, ...props }: TreeLabelProps) => (\n  <span className={cn(\"font flex-1 truncate text-sm\", className)} {...props} />\n);\n","target":"components/kibo-ui/tree/index.tsx"}],"css":{}}