{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "folder-tree",
  "type": "registry:ui",
  "title": "Folder Tree",
  "description": "A customizable folder tree component for displaying hierarchical data with expandable nodes and file icons.",
  "author": "Jack Phat <https://jackphat.vercel.app/>",
  "registryDependencies": [],
  "dependencies": ["motion", "lucide-react", "clsx", "tailwind-merge"],
  "files": [
    {
      "type": "registry:ui",
      "path": "components/ui/folder-tree.tsx",
      "content": "'use client';\nimport React, {\n  useState,\n  useCallback,\n  createContext,\n  useContext,\n  useRef,\n  useEffect,\n} from 'react';\nimport { motion, AnimatePresence, Variants, easeInOut } from 'motion/react';\nimport {\n  ChevronRight,\n  Folder,\n  FolderOpen,\n  File,\n  LucideIcon,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils';\n\nconst animationVariants: Variants = {\n  rootInitial: { opacity: 0, y: 20 },\n  rootAnimate: { opacity: 1, y: 0 },\n  itemInitial: { opacity: 0, x: -10 },\n  itemAnimate: { opacity: 1, x: 0 },\n  contentHidden: { opacity: 0, height: 0 },\n  contentVisible: { opacity: 1, height: 'auto' },\n  chevronClosed: { rotate: 0 },\n  chevronOpen: { rotate: 90 },\n};\n\nconst transitions = {\n  root: { duration: 0.4 },\n  item: { duration: 0.2 },\n  content: { duration: 0.3, ease: easeInOut },\n  chevron: { duration: 0.2 },\n};\n\ninterface ExpansionContextType {\n  expandedIds: Set<string>;\n  toggleExpanded: (id: string) => void;\n}\n\ninterface SelectionContextType {\n  selectedId: string | null;\n  setSelected: (id: string) => void;\n  onSelect?: (id: string, label: string) => void;\n}\n\ninterface TreeContextType {\n  focusedId: string | null;\n  setFocusedId: (id: string | null) => void;\n  treeId: string;\n  setKeyboardMode: (mode: boolean) => void;\n  keyboardMode: boolean;\n}\n\ninterface LevelContextType {\n  level: number;\n}\n\nconst ExpansionContext = createContext<ExpansionContextType | null>(null);\nconst SelectionContext = createContext<SelectionContextType | null>(null);\nconst TreeContext = createContext<TreeContextType | null>(null);\nconst LevelContext = createContext<LevelContextType>({ level: 0 });\n\nconst useExpansion = () => {\n  const context = useContext(ExpansionContext);\n  if (!context) {\n    throw new Error(\n      'FolderTree components must be used within FolderTree.Root',\n    );\n  }\n  return context;\n};\n\nconst useSelection = () => {\n  const context = useContext(SelectionContext);\n  if (!context) {\n    throw new Error(\n      'FolderTree components must be used within FolderTree.Root',\n    );\n  }\n  return context;\n};\n\nconst useTree = () => {\n  const context = useContext(TreeContext);\n  if (!context) {\n    throw new Error(\n      'FolderTree components must be used within FolderTree.Root',\n    );\n  }\n  return context;\n};\n\nconst useLevel = () => {\n  return useContext(LevelContext);\n};\n\nconst getPaddingClass = (level: number): string => {\n  const paddingMap: Record<number, string> = {\n    0: 'pl-3',\n    1: 'pl-8',\n    2: 'pl-12',\n    3: 'pl-16',\n    4: 'pl-20',\n    5: 'pl-24',\n    6: 'pl-28',\n    7: 'pl-32',\n  };\n  return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;\n};\n\ninterface CustomBadge {\n  content: React.ReactNode;\n  className?: string;\n  ariaLabel?: string;\n}\n\ninterface RootProps {\n  defaultExpanded?: string[];\n  defaultSelected?: string;\n  onSelect?: (id: string, label: string) => void;\n  className?: string;\n  children: React.ReactNode;\n  id?: string;\n}\n\ninterface ItemProps {\n  id: string;\n  label: string;\n  icon?: LucideIcon;\n  badge?: string | number;\n  modified?: boolean | CustomBadge;\n  untracked?: boolean | CustomBadge;\n  className?: string;\n  children?: React.ReactNode;\n}\n\ninterface TriggerProps {\n  className?: string;\n}\n\ninterface ContentProps {\n  children: React.ReactNode;\n  className?: string;\n}\n\nconst Root: React.FC<RootProps> = ({\n  defaultExpanded = [],\n  defaultSelected,\n  onSelect,\n  className = '',\n  children,\n  id = 'folder-tree',\n}) => {\n  const [expandedIds, setExpandedIds] = useState<Set<string>>(\n    new Set(defaultExpanded),\n  );\n  const [selectedId, setSelectedId] = useState<string | null>(\n    defaultSelected || null,\n  );\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n  const [keyboardMode, setKeyboardMode] = useState(false);\n  const treeRef = useRef<HTMLDivElement>(null);\n\n  const toggleExpanded = useCallback((id: string) => {\n    setExpandedIds((prev) => {\n      const newSet = new Set(prev);\n      if (newSet.has(id)) {\n        newSet.delete(id);\n      } else {\n        newSet.add(id);\n      }\n      return newSet;\n    });\n  }, []);\n\n  const setSelected = useCallback((id: string) => {\n    setSelectedId(id);\n  }, []);\n\n  const getVisibleItemIds = useCallback(() => {\n    const items = Array.from(\n      treeRef.current?.querySelectorAll('[role=\"treeitem\"]') || [],\n    );\n    return items\n      .filter((item) => {\n        const element = item as HTMLElement;\n        return element.offsetHeight > 0 && element.offsetWidth > 0;\n      })\n      .map((item) => item.getAttribute('data-id'))\n      .filter(Boolean) as string[];\n  }, []);\n\n  const getAllItemIds = useCallback(() => {\n    const items = Array.from(\n      treeRef.current?.querySelectorAll('[role=\"treeitem\"]') || [],\n    );\n    return items\n      .map((item) => item.getAttribute('data-id'))\n      .filter(Boolean) as string[];\n  }, []);\n\n  const [treeHasFocus, setTreeHasFocus] = useState(false);\n\n  const handleTreeFocus = useCallback(() => {\n    if (!treeHasFocus) {\n      setTreeHasFocus(true);\n      setKeyboardMode(true);\n    }\n  }, [treeHasFocus]);\n\n  const handleTreeBlur = useCallback((e: React.FocusEvent) => {\n    if (!treeRef.current?.contains(e.relatedTarget as Node)) {\n      setTreeHasFocus(false);\n      setFocusedId(null);\n      setKeyboardMode(false);\n    }\n  }, []);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      const getVisibleItems = () => {\n        return Array.from(\n          treeRef.current?.querySelectorAll('[role=\"treeitem\"]') || [],\n        ).filter((item) => {\n          const element = item as HTMLElement;\n          return element.offsetHeight > 0 && element.offsetWidth > 0;\n        });\n      };\n\n      if (e.key === 'Tab') {\n        if (treeHasFocus && !focusedId) {\n          const visibleItemIds = getVisibleItemIds();\n          if (visibleItemIds.length > 0) {\n            setFocusedId(visibleItemIds[0]);\n            e.preventDefault();\n            return;\n          }\n        }\n\n        if (focusedId) {\n          const visibleItems = getVisibleItems();\n          const currentIndex = visibleItems.findIndex(\n            (item) => item.getAttribute('data-id') === focusedId,\n          );\n\n          if (e.shiftKey) {\n            if (currentIndex === 0) {\n              setFocusedId(null);\n              setTreeHasFocus(false);\n              setKeyboardMode(false);\n              return;\n            }\n            const nextIndex = Math.max(0, currentIndex - 1);\n            const nextItem = visibleItems[nextIndex] as HTMLElement;\n            const nextId = nextItem?.getAttribute('data-id');\n            if (nextId) {\n              setFocusedId(nextId);\n              e.preventDefault();\n            }\n          } else {\n            if (currentIndex === visibleItems.length - 1) {\n              setFocusedId(null);\n              setTreeHasFocus(false);\n              setKeyboardMode(false);\n              return;\n            }\n            const nextIndex = Math.min(\n              visibleItems.length - 1,\n              currentIndex + 1,\n            );\n            const nextItem = visibleItems[nextIndex] as HTMLElement;\n            const nextId = nextItem?.getAttribute('data-id');\n            if (nextId) {\n              setFocusedId(nextId);\n              e.preventDefault();\n            }\n          }\n        }\n        return;\n      }\n\n      if (!keyboardMode || !focusedId) return;\n\n      const visibleItems = getVisibleItems();\n      const currentIndex = visibleItems.findIndex(\n        (item) => item.getAttribute('data-id') === focusedId,\n      );\n\n      switch (e.key) {\n        case 'ArrowDown':\n          e.preventDefault();\n          if (currentIndex < visibleItems.length - 1) {\n            const nextItem = visibleItems[currentIndex + 1] as HTMLElement;\n            const nextId = nextItem.getAttribute('data-id');\n            if (nextId) setFocusedId(nextId);\n          }\n          break;\n        case 'ArrowUp':\n          e.preventDefault();\n          if (currentIndex > 0) {\n            const prevItem = visibleItems[currentIndex - 1] as HTMLElement;\n            const prevId = prevItem.getAttribute('data-id');\n            if (prevId) setFocusedId(prevId);\n          }\n          break;\n        case 'ArrowRight':\n          e.preventDefault();\n          if (!expandedIds.has(focusedId)) {\n            toggleExpanded(focusedId);\n          }\n          break;\n        case 'ArrowLeft':\n          e.preventDefault();\n          if (expandedIds.has(focusedId)) {\n            toggleExpanded(focusedId);\n          }\n          break;\n        case 'Enter':\n        case ' ':\n          e.preventDefault();\n          setSelected(focusedId);\n          if (onSelect) {\n            const currentItem = visibleItems[currentIndex] as HTMLElement;\n            const label =\n              currentItem.querySelector('span:nth-of-type(2)')?.textContent ||\n              '';\n            onSelect(focusedId, label);\n          }\n          break;\n      }\n    },\n    [\n      focusedId,\n      keyboardMode,\n      expandedIds,\n      toggleExpanded,\n      setSelected,\n      onSelect,\n      getVisibleItemIds,\n      treeHasFocus,\n    ],\n  );\n\n  useEffect(() => {\n    const handleMouseDown = () => setKeyboardMode(false);\n    document.addEventListener('mousedown', handleMouseDown);\n    return () => {\n      document.removeEventListener('mousedown', handleMouseDown);\n    };\n  }, []);\n\n  const expansionValue: ExpansionContextType = {\n    expandedIds,\n    toggleExpanded,\n  };\n\n  const selectionValue: SelectionContextType = {\n    selectedId,\n    setSelected,\n    onSelect,\n  };\n\n  const treeValue: TreeContextType = {\n    focusedId,\n    setFocusedId,\n    treeId: id,\n    setKeyboardMode,\n    keyboardMode,\n  };\n\n  return (\n    <ExpansionContext.Provider value={expansionValue}>\n      <SelectionContext.Provider value={selectionValue}>\n        <TreeContext.Provider value={treeValue}>\n          <LevelContext.Provider value={{ level: 0 }}>\n            <motion.div\n              ref={treeRef}\n              variants={animationVariants}\n              initial='rootInitial'\n              animate='rootAnimate'\n              transition={transitions.root}\n              className={cn(\n                'bg-white dark:bg-slate-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden',\n                className,\n              )}\n              role='tree'\n              aria-labelledby={`${id}-label`}\n              tabIndex={0}\n              onKeyDown={handleKeyDown}\n              onFocus={handleTreeFocus}\n              onBlur={handleTreeBlur}\n            >\n              <div className='w-full overflow-y-auto bg-background text-sm'>\n                {children}\n              </div>\n            </motion.div>\n          </LevelContext.Provider>\n        </TreeContext.Provider>\n      </SelectionContext.Provider>\n    </ExpansionContext.Provider>\n  );\n};\n\nconst ItemContext = createContext<{\n  itemId: string;\n  hasChildren: boolean;\n  isExpanded: boolean;\n  toggleExpanded: () => void;\n} | null>(null);\n\nconst Item: React.FC<ItemProps> = ({\n  id,\n  label,\n  icon,\n  badge,\n  modified,\n  untracked,\n  className = '',\n  children,\n}) => {\n  const expansionContext = useExpansion();\n  const selectionContext = useSelection();\n  const treeContext = useTree();\n  const { level } = useLevel();\n  const itemRef = useRef<HTMLDivElement>(null);\n  const keyboardMode = treeContext.keyboardMode;\n\n  const hasChildren = React.Children.count(children) > 0;\n  const isExpanded = expansionContext.expandedIds.has(id);\n  const isSelected = selectionContext.selectedId === id;\n  const isFocused = treeContext.focusedId === id;\n\n  const handleItemClick = useCallback(() => {\n    treeContext.setKeyboardMode(false);\n    selectionContext.setSelected(id);\n    treeContext.setFocusedId(id);\n    if (selectionContext.onSelect) {\n      selectionContext.onSelect(id, label);\n    }\n  }, [id, label, selectionContext, treeContext]);\n\n  const toggleExpanded = useCallback(() => {\n    if (hasChildren) {\n      expansionContext.toggleExpanded(id);\n    }\n  }, [id, hasChildren, expansionContext]);\n\n  const handleFocus = useCallback(() => {\n    treeContext.setFocusedId(id);\n  }, [id, treeContext]);\n\n  useEffect(() => {\n    if (isFocused && itemRef.current) {\n      itemRef.current.focus();\n    }\n  }, [isFocused]);\n\n  const IconComponent =\n    icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);\n\n  const itemContextValue = {\n    itemId: id,\n    hasChildren,\n    isExpanded,\n    toggleExpanded,\n  };\n\n  const renderBadge = (\n    badgeData: boolean | CustomBadge | undefined,\n    defaultContent: string,\n    defaultClassName: string,\n  ) => {\n    if (!badgeData) return null;\n\n    if (typeof badgeData === 'boolean') {\n      return (\n        <span\n          className={defaultClassName}\n          aria-label={`${defaultContent} status`}\n        >\n          {defaultContent}\n        </span>\n      );\n    }\n\n    return (\n      <span\n        className={cn(\n          'ml-auto text-xs px-2 py-0.5 rounded-full',\n          badgeData.className,\n        )}\n        aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}\n      >\n        {badgeData.content}\n      </span>\n    );\n  };\n\n  return (\n    <ItemContext.Provider value={itemContextValue}>\n      <LevelContext.Provider value={{ level: level + 1 }}>\n        <div>\n          <motion.div\n            ref={itemRef}\n            variants={animationVariants}\n            initial='itemInitial'\n            animate='itemAnimate'\n            transition={{ ...transitions.item, delay: level * 0.05 }}\n            data-selected={isSelected ? 'true' : 'false'}\n            data-id={id}\n            className={cn(\n              'flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none',\n              getPaddingClass(level),\n              className,\n              isSelected\n                ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border-r-2 border-blue-600'\n                : '',\n              !isSelected && 'hover:bg-gray-100 dark:hover:bg-slate-700/50',\n              keyboardMode && isFocused\n                ? 'focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-inset'\n                : 'focus:outline-hidden',\n            )}\n            onClick={(e: React.MouseEvent) => {\n              handleItemClick();\n              e.stopPropagation();\n              toggleExpanded();\n            }}\n            onFocus={handleFocus}\n            role='treeitem'\n            tabIndex={isFocused ? 0 : -1}\n            aria-expanded={hasChildren ? isExpanded : undefined}\n            aria-selected={isSelected}\n            aria-label={`${hasChildren ? 'Folder' : 'File'}: ${label}`}\n            aria-level={level + 1}\n          >\n            {hasChildren && (\n              <motion.span\n                className='shrink-0 cursor-pointer'\n                variants={animationVariants}\n                animate={isExpanded ? 'chevronOpen' : 'chevronClosed'}\n                transition={transitions.chevron}\n                aria-hidden='true'\n              >\n                <ChevronRight\n                  size={14}\n                  className='text-gray-500 dark:text-gray-400'\n                />\n              </motion.span>\n            )}\n            {!hasChildren && <span className='w-3 mr-2' aria-hidden='true' />}\n            {IconComponent && (\n              <IconComponent\n                size={16}\n                data-selected={isSelected ? 'true' : 'false'}\n                data-child={hasChildren ? 'true' : 'false'}\n                className={cn(\n                  'mr-1 shrink-0 text-gray-500 data-[child=true]:text-blue-500 data-[selected=true]:text-blue-600 dark:data-[selected=true]:text-blue-400',\n                )}\n                aria-hidden='true'\n              />\n            )}\n            <span className='flex-1'>{label}</span>\n            {badge && (\n              <span\n                className='ml-auto text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded-full'\n                aria-label={`Badge: ${badge}`}\n              >\n                {badge}\n              </span>\n            )}\n            {renderBadge(\n              modified,\n              'M',\n              'ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full',\n            )}\n            {renderBadge(\n              untracked,\n              'U',\n              'ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full',\n            )}\n          </motion.div>\n          {children}\n        </div>\n      </LevelContext.Provider>\n    </ItemContext.Provider>\n  );\n};\n\nconst Trigger: React.FC<TriggerProps> = ({ className = '' }) => {\n  const itemContext = useContext(ItemContext);\n  if (!itemContext || !itemContext.hasChildren) {\n    return null;\n  }\n\n  return (\n    <motion.span\n      className={cn('mr-2 shrink-0 cursor-pointer', className)}\n      variants={animationVariants}\n      animate={itemContext.isExpanded ? 'chevronOpen' : 'chevronClosed'}\n      transition={transitions.chevron}\n      onClick={(e: React.MouseEvent) => {\n        e.stopPropagation();\n        itemContext.toggleExpanded();\n      }}\n      role='button'\n      aria-label={itemContext.isExpanded ? 'Collapse' : 'Expand'}\n      tabIndex={-1}\n    >\n      <ChevronRight size={14} className='text-gray-500 dark:text-gray-400' />\n    </motion.span>\n  );\n};\n\nconst Content: React.FC<ContentProps> = ({ children, className = '' }) => {\n  const itemContext = useContext(ItemContext);\n  if (!itemContext) {\n    return <>{children}</>;\n  }\n\n  const hasContent = React.Children.count(children) > 0;\n\n  return (\n    <AnimatePresence>\n      {hasContent && itemContext.isExpanded && (\n        <motion.div\n          variants={animationVariants}\n          initial='contentHidden'\n          animate='contentVisible'\n          exit='contentHidden'\n          transition={transitions.content}\n          style={{ overflow: 'hidden' }}\n          className={className}\n          role='group'\n        >\n          {children}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n};\n\nconst FolderTree = {\n  Root,\n  Item,\n  Trigger,\n  Content,\n};\n\nexport default FolderTree;\n"
    },
    {
      "type": "registry:lib",
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}"
    }
  ]
}
