{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree",
  "type": "registry:ui",
  "title": "File Tree",
  "description": "Smooth, interactive file tree component with guide lines, nested folder expansion, selection, and keyboard navigation.",
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/file-tree.tsx",
      "content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useRef } from \"react\";\nimport { ChevronRight, Folder, FolderOpen, File } from \"lucide-react\";\nimport { motion, AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TreeNode = {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  children?: TreeNode[];\n  data?: unknown;\n  disabled?: boolean;\n};\n\nexport type TreeViewProps = {\n  data: TreeNode[];\n  className?: string;\n  onNodeClick?: (node: TreeNode) => void;\n  onNodeExpand?: (nodeId: string, expanded: boolean) => void;\n  defaultExpandedIds?: string[];\n  expandedIds?: string[];\n  onExpandedIdsChange?: (expandedIds: string[]) => void;\n  showLines?: boolean;\n  showIcons?: boolean;\n  selectable?: boolean;\n  multiSelect?: boolean;\n  selectedIds?: string[];\n  defaultSelectedIds?: string[];\n  onSelectionChange?: (selectedIds: string[]) => void;\n  indent?: number;\n  animateExpand?: boolean;\n  expandOnFolderClick?: boolean;\n};\n\nexport function TreeView({\n  data,\n  className,\n  onNodeClick,\n  onNodeExpand,\n  defaultExpandedIds = [],\n  expandedIds: controlledExpandedIds,\n  onExpandedIdsChange,\n  showLines = true,\n  showIcons = true,\n  selectable = true,\n  multiSelect = false,\n  selectedIds: controlledSelectedIds,\n  defaultSelectedIds = [],\n  onSelectionChange,\n  indent = 16,\n  animateExpand = true,\n  expandOnFolderClick = true,\n}: TreeViewProps) {\n  const prefersReducedMotion = useReducedMotion();\n\n  const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(\n    () => new Set(defaultExpandedIds),\n  );\n\n  const isExpandedControlled = controlledExpandedIds !== undefined;\n  const currentExpandedSet = useMemo(() => {\n    if (isExpandedControlled) {\n      return new Set(controlledExpandedIds);\n    }\n    return internalExpandedIds;\n  }, [isExpandedControlled, controlledExpandedIds, internalExpandedIds]);\n\n  const toggleExpanded = useCallback(\n    (nodeId: string) => {\n      const nextSet = new Set(currentExpandedSet);\n      const isExpanded = nextSet.has(nodeId);\n      if (isExpanded) {\n        nextSet.delete(nodeId);\n      } else {\n        nextSet.add(nodeId);\n      }\n\n      if (!isExpandedControlled) {\n        setInternalExpandedIds(nextSet);\n      }\n\n      onExpandedIdsChange?.(Array.from(nextSet));\n      onNodeExpand?.(nodeId, !isExpanded);\n    },\n    [\n      currentExpandedSet,\n      isExpandedControlled,\n      onExpandedIdsChange,\n      onNodeExpand,\n    ],\n  );\n\n  const [internalSelectedIds, setInternalSelectedIds] =\n    useState<string[]>(defaultSelectedIds);\n\n  const isSelectedControlled = controlledSelectedIds !== undefined;\n  const currentSelectedIds = isSelectedControlled\n    ? controlledSelectedIds\n    : internalSelectedIds;\n\n  const handleSelection = useCallback(\n    (nodeId: string, isCtrlKey: boolean) => {\n      if (!selectable) return;\n\n      let nextSelection: string[];\n\n      if (multiSelect && isCtrlKey) {\n        nextSelection = currentSelectedIds.includes(nodeId)\n          ? currentSelectedIds.filter((id) => id !== nodeId)\n          : [...currentSelectedIds, nodeId];\n      } else {\n        nextSelection = currentSelectedIds.includes(nodeId) ? [] : [nodeId];\n      }\n\n      if (!isSelectedControlled) {\n        setInternalSelectedIds(nextSelection);\n      }\n\n      onSelectionChange?.(nextSelection);\n    },\n    [\n      selectable,\n      multiSelect,\n      currentSelectedIds,\n      isSelectedControlled,\n      onSelectionChange,\n    ],\n  );\n\n  const visibleNodes = useMemo(() => {\n    const list: { node: TreeNode; parentId: string | null }[] = [];\n    const walk = (nodes: TreeNode[], parentId: string | null) => {\n      for (const node of nodes) {\n        list.push({ node, parentId });\n        if (node.children?.length && currentExpandedSet.has(node.id)) {\n          walk(node.children, node.id);\n        }\n      }\n    };\n    walk(data, null);\n    return list;\n  }, [data, currentExpandedSet]);\n\n  const itemRefs = useRef(new Map<string, HTMLDivElement>());\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n\n  const tabStopId = useMemo(() => {\n    const visibleIds = visibleNodes.map(({ node }) => node.id);\n    if (focusedId && visibleIds.includes(focusedId)) return focusedId;\n    return (\n      visibleIds.find((id) => currentSelectedIds.includes(id)) ??\n      visibleIds[0] ??\n      null\n    );\n  }, [visibleNodes, focusedId, currentSelectedIds]);\n\n  const focusNode = useCallback((nodeId: string | null | undefined) => {\n    if (!nodeId) return;\n    setFocusedId(nodeId);\n    itemRefs.current.get(nodeId)?.focus();\n  }, []);\n\n  const moveFocus = useCallback(\n    (nodeId: string, key: string) => {\n      const index = visibleNodes.findIndex(({ node }) => node.id === nodeId);\n      if (index === -1) return false;\n      const current = visibleNodes[index];\n      const hasChildren = (current.node.children?.length ?? 0) > 0;\n      const isExpanded = currentExpandedSet.has(nodeId);\n\n      switch (key) {\n        case \"ArrowDown\":\n          focusNode(visibleNodes[index + 1]?.node.id);\n          return true;\n        case \"ArrowUp\":\n          focusNode(visibleNodes[index - 1]?.node.id);\n          return true;\n        case \"Home\":\n          focusNode(visibleNodes[0]?.node.id);\n          return true;\n        case \"End\":\n          focusNode(visibleNodes[visibleNodes.length - 1]?.node.id);\n          return true;\n        case \"ArrowRight\":\n          if (!hasChildren) return false;\n          if (isExpanded) focusNode(current.node.children?.[0]?.id);\n          else toggleExpanded(nodeId);\n          return true;\n        case \"ArrowLeft\":\n          if (hasChildren && isExpanded) toggleExpanded(nodeId);\n          else focusNode(current.parentId);\n          return true;\n        default:\n          return false;\n      }\n    },\n    [visibleNodes, currentExpandedSet, focusNode, toggleExpanded],\n  );\n\n  const handleNodeClick = useCallback(\n    (node: TreeNode, e: React.MouseEvent) => {\n      e.stopPropagation();\n      if (node.disabled) return;\n\n      const hasChildren = (node.children?.length ?? 0) > 0;\n      if (hasChildren && expandOnFolderClick) {\n        toggleExpanded(node.id);\n      }\n\n      handleSelection(node.id, e.ctrlKey || e.metaKey);\n      onNodeClick?.(node);\n    },\n    [expandOnFolderClick, toggleExpanded, handleSelection, onNodeClick],\n  );\n\n  const renderNode = (node: TreeNode, level = 0) => {\n    const hasChildren = (node.children?.length ?? 0) > 0;\n    const isExpanded = currentExpandedSet.has(node.id);\n    const isSelected = currentSelectedIds.includes(node.id);\n\n    return (\n      <div key={node.id} className=\"relative\">\n        <div\n          ref={(element) => {\n            if (element) itemRefs.current.set(node.id, element);\n            else itemRefs.current.delete(node.id);\n          }}\n          role=\"treeitem\"\n          aria-level={level + 1}\n          aria-expanded={hasChildren ? isExpanded : undefined}\n          aria-selected={selectable ? isSelected : undefined}\n          aria-disabled={node.disabled || undefined}\n          tabIndex={node.id === tabStopId ? 0 : -1}\n          onFocus={() => setFocusedId(node.id)}\n          className={cn(\n            \"group/node relative flex items-center h-8 px-2 rounded-md cursor-pointer select-none transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600\",\n            isSelected\n              ? \"bg-zinc-100 dark:bg-zinc-800/80 text-zinc-900 dark:text-zinc-100 font-medium\"\n              : \"text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100/70 dark:hover:bg-zinc-800/50 hover:text-zinc-900 dark:hover:text-zinc-100\",\n            node.disabled && \"opacity-50 pointer-events-none\",\n          )}\n          style={{ paddingLeft: `${level * indent + 8}px` }}\n          onClick={(e) => handleNodeClick(node, e)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" || e.key === \" \") {\n              e.preventDefault();\n              handleNodeClick(node, e as unknown as React.MouseEvent);\n            } else if (moveFocus(node.id, e.key)) {\n              e.preventDefault();\n            }\n          }}\n        >\n          {showLines && level > 0 && (\n            <div className=\"absolute left-0 top-0 bottom-0 pointer-events-none\">\n              {Array.from({ length: level }).map((_, i) => (\n                <div\n                  key={i}\n                  className=\"absolute top-0 bottom-0 border-l border-zinc-200 dark:border-zinc-800/80\"\n                  style={{ left: `${i * indent + 15}px` }}\n                />\n              ))}\n            </div>\n          )}\n\n          <div className=\"w-4 h-4 flex items-center justify-center shrink-0 mr-1\">\n            {hasChildren ? (\n              <motion.div\n                animate={{ rotate: isExpanded ? 90 : 0 }}\n                transition={\n                  prefersReducedMotion || !animateExpand\n                    ? { duration: 0 }\n                    : { duration: 0.15, ease: \"easeInOut\" }\n                }\n                className=\"flex items-center justify-center text-zinc-400 group-hover/node:text-zinc-600 dark:group-hover/node:text-zinc-300\"\n                onClick={(e) => {\n                  if (!expandOnFolderClick) {\n                    e.stopPropagation();\n                    toggleExpanded(node.id);\n                  }\n                }}\n              >\n                <ChevronRight className=\"h-3.5 w-3.5 shrink-0\" />\n              </motion.div>\n            ) : (\n              <span className=\"w-3.5 h-3.5\" />\n            )}\n          </div>\n\n          {showIcons && (\n            <div className=\"w-4 h-4 flex items-center justify-center shrink-0 mr-2 text-zinc-400 group-hover/node:text-zinc-600 dark:text-zinc-400 dark:group-hover/node:text-zinc-200\">\n              {node.icon ? (\n                node.icon\n              ) : hasChildren ? (\n                isExpanded ? (\n                  <FolderOpen className=\"h-4 w-4 text-amber-500/90 dark:text-amber-400/90\" />\n                ) : (\n                  <Folder className=\"h-4 w-4 text-amber-500/90 dark:text-amber-400/90\" />\n                )\n              ) : (\n                <File className=\"h-4 w-4 text-zinc-400 dark:text-zinc-500\" />\n              )}\n            </div>\n          )}\n\n          <span className=\"text-xs sm:text-sm truncate leading-none pt-px\">\n            {node.label}\n          </span>\n        </div>\n\n        <AnimatePresence initial={false}>\n          {hasChildren && isExpanded && (\n            <motion.div\n              initial={{ height: 0, opacity: 0 }}\n              animate={{\n                height: \"auto\",\n                opacity: 1,\n                transition:\n                  prefersReducedMotion || !animateExpand\n                    ? { duration: 0 }\n                    : {\n                        height: { duration: 0.2, ease: [0.16, 1, 0.3, 1] },\n                        opacity: { duration: 0.15, delay: 0.03 },\n                      },\n              }}\n              exit={{\n                height: 0,\n                opacity: 0,\n                transition:\n                  prefersReducedMotion || !animateExpand\n                    ? { duration: 0 }\n                    : {\n                        height: { duration: 0.18, ease: [0.16, 1, 0.3, 1] },\n                        opacity: { duration: 0.1 },\n                      },\n              }}\n              role=\"group\"\n              className=\"overflow-hidden\"\n            >\n              {node.children!.map((child) => renderNode(child, level + 1))}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    );\n  };\n\n  return (\n    <div\n      role=\"tree\"\n      className={cn(\n        \"w-full select-none text-zinc-900 dark:text-zinc-100 font-sans\",\n        className,\n      )}\n    >\n      {data.map((node) => renderNode(node, 0))}\n    </div>\n  );\n}\n\nexport const FileTree = TreeView;\n",
      "type": "registry:ui",
      "target": "@ui/file-tree.tsx"
    }
  ]
}
