{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"folder-tree","type":"registry:ui","title":"Folder Tree","description":"File explorer tree with springy expand/collapse, a highlight that glides to the selected row, file-type icons and full keyboard navigation.","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/ui/folder-tree.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronRight, File, FileCode, FileImage, FileJson, FileText, Folder, FolderOpen } from \"lucide-react\";\n\nexport interface TreeNode {\n  id: string;\n  name: string;\n  /** Present (even empty) = folder. */\n  children?: TreeNode[];\n  /** Override the default icon. */\n  icon?: ReactNode;\n}\n\nexport interface FolderTreeProps {\n  data: TreeNode[];\n  /** Folder ids open at first render. */\n  defaultExpanded?: string[];\n  /** Node id selected at first render. */\n  defaultSelected?: string;\n  /** Px of indent per level. */\n  indent?: number;\n  /** Draw vertical guide lines for nesting. */\n  showLines?: boolean;\n  /** Tint folder icons. */\n  folderColor?: string;\n  onSelect?: (node: TreeNode, path: TreeNode[]) => void;\n  /** Accessible name for the tree. */\n  label?: string;\n  className?: string;\n}\n\nconst FILE_ICONS: [RegExp, typeof File, string][] = [\n  [/\\.(tsx?|jsx?|mjs)$/, FileCode, \"#60a5fa\"],\n  [/\\.json$/, FileJson, \"#fbbf24\"],\n  [/\\.(md|mdx|txt)$/, FileText, \"#a1a1aa\"],\n  [/\\.(png|jpe?g|svg|gif|webp|ico)$/, FileImage, \"#f472b6\"],\n  [/\\.(css|scss)$/, FileCode, \"#c084fc\"],\n];\n\ntype Row = { node: TreeNode; depth: number; parent: string | null; path: TreeNode[] };\n\n/** Visible rows in display order (children of collapsed folders are skipped). */\nfunction flatten(nodes: TreeNode[], open: Set<string>, depth = 0, parent: string | null = null, path: TreeNode[] = [], out: Row[] = []) {\n  for (const node of nodes) {\n    const p = [...path, node];\n    out.push({ node, depth, parent, path: p });\n    if (node.children && open.has(node.id)) flatten(node.children, open, depth + 1, node.id, p, out);\n  }\n  return out;\n}\n\n/**\n * A file tree with springy expand/collapse, a highlight that glides to the selected row,\n * file-type icons and full WAI-ARIA tree keyboard support (arrows, Home/End, Enter, type-ahead).\n */\nexport function FolderTree({\n  data,\n  defaultExpanded = [],\n  defaultSelected,\n  indent = 16,\n  showLines = true,\n  folderColor = \"#7dd3fc\",\n  onSelect,\n  label = \"Files\",\n  className,\n}: FolderTreeProps) {\n  const [open, setOpen] = useState(() => new Set(defaultExpanded));\n  const [selected, setSelected] = useState(defaultSelected);\n  const [focused, setFocused] = useState<string | undefined>(defaultSelected);\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const items = useRef(new Map<string, HTMLLIElement>());\n  const rows = useMemo(() => flatten(data, open), [data, open]);\n  const tabStop = rows.some((r) => r.node.id === focused) ? focused : rows[0]?.node.id;\n\n  const toggle = (id: string, to?: boolean) =>\n    setOpen((s) => {\n      const next = new Set(s);\n      if (to ?? !next.has(id)) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n\n  const focus = (id: string | undefined) => {\n    if (!id) return;\n    setFocused(id);\n    items.current.get(id)?.focus();\n  };\n\n  const select = (row: Row) => {\n    setSelected(row.node.id);\n    setFocused(row.node.id);\n    if (row.node.children) toggle(row.node.id);\n    onSelect?.(row.node, row.path);\n  };\n\n  const onKey = (e: KeyboardEvent) => {\n    const i = rows.findIndex((r) => r.node.id === tabStop);\n    const row = rows[i];\n    if (!row) return;\n    const isOpen = open.has(row.node.id);\n    const folder = !!row.node.children;\n    let handled = true;\n    switch (e.key) {\n      case \"ArrowDown\":\n        focus(rows[i + 1]?.node.id);\n        break;\n      case \"ArrowUp\":\n        focus(rows[i - 1]?.node.id);\n        break;\n      case \"ArrowRight\":\n        if (folder && !isOpen) toggle(row.node.id, true);\n        else if (folder && row.node.children?.length) focus(rows[i + 1]?.node.id);\n        break;\n      case \"ArrowLeft\":\n        if (folder && isOpen) toggle(row.node.id, false);\n        else focus(row.parent ?? undefined);\n        break;\n      case \"Home\":\n        focus(rows[0]?.node.id);\n        break;\n      case \"End\":\n        focus(rows[rows.length - 1]?.node.id);\n        break;\n      case \"Enter\":\n      case \" \":\n        select(row);\n        break;\n      default:\n        // Type-ahead: jump to the next visible item starting with the typed character.\n        if (e.key.length === 1 && /\\S/.test(e.key)) {\n          const k = e.key.toLowerCase();\n          const order = [...rows.slice(i + 1), ...rows.slice(0, i + 1)];\n          focus(order.find((r) => r.node.name.toLowerCase().startsWith(k))?.node.id);\n        } else handled = false;\n    }\n    if (handled) {\n      e.preventDefault();\n      e.stopPropagation();\n    }\n  };\n\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, bounce: 0.15, duration: 0.4 };\n\n  const renderLevel = (nodes: TreeNode[], depth: number, path: TreeNode[]): ReactNode =>\n    nodes.map((node) => {\n      const folder = !!node.children;\n      const isOpen = folder && open.has(node.id);\n      const on = selected === node.id;\n      const row: Row = { node, depth, parent: path[path.length - 1]?.id ?? null, path: [...path, node] };\n      const match = FILE_ICONS.find(([re]) => re.test(node.name));\n      const Icon = folder ? (isOpen ? FolderOpen : Folder) : (match?.[1] ?? File);\n      const tint = folder ? folderColor : (match?.[2] ?? \"#a1a1aa\");\n      return (\n        <li\n          key={node.id}\n          ref={(el) => {\n            if (el) items.current.set(node.id, el);\n            else items.current.delete(node.id);\n          }}\n          role=\"treeitem\"\n          aria-level={depth + 1}\n          aria-expanded={folder ? isOpen : undefined}\n          aria-selected={on}\n          tabIndex={tabStop === node.id ? 0 : -1}\n          onFocus={(e) => {\n            if (e.target === e.currentTarget) setFocused(node.id);\n          }}\n          className=\"outline-none [&:focus-visible>div]:ring-2 [&:focus-visible>div]:ring-ring\"\n        >\n          <div\n            onClick={() => select(row)}\n            className={`relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg pr-3 text-sm transition-colors ${\n              on ? \"text-foreground\" : \"text-muted-foreground hover:bg-muted/50 hover:text-foreground\"\n            }`}\n            style={{ paddingLeft: depth * indent + 6 }}\n          >\n            {on && (\n              <motion.span\n                layoutId={`${uid}-sel`}\n                transition={spring}\n                className=\"absolute inset-0 rounded-lg bg-muted shadow-[inset_0_0_0_1px_var(--border)]\"\n              />\n            )}\n            <span className=\"relative grid size-4 shrink-0 place-items-center\">\n              {folder && (\n                <motion.span initial={false} animate={{ rotate: isOpen ? 90 : 0 }} transition={spring} className=\"grid\">\n                  <ChevronRight className=\"size-3.5\" />\n                </motion.span>\n              )}\n            </span>\n            <Icon className=\"relative size-4 shrink-0\" style={{ color: tint }} fill={folder ? `color-mix(in srgb, ${tint} 22%, transparent)` : \"none\"} />\n            <span className=\"relative truncate\">{node.name}</span>\n          </div>\n          <AnimatePresence initial={false}>\n            {isOpen && (\n              <motion.ul\n                role=\"group\"\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0 }}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", bounce: 0, duration: 0.32 }}\n                className=\"relative overflow-hidden\"\n              >\n                {showLines && (\n                  <span\n                    aria-hidden\n                    className=\"pointer-events-none absolute inset-y-1 w-px bg-border\"\n                    style={{ left: depth * indent + 14 }}\n                  />\n                )}\n                {node.children!.length ? (\n                  renderLevel(node.children!, depth + 1, row.path)\n                ) : (\n                  <li role=\"none\" className=\"h-8 py-1.5 text-xs italic text-muted-foreground/70\" style={{ paddingLeft: (depth + 1) * indent + 28 }}>\n                    Empty\n                  </li>\n                )}\n              </motion.ul>\n            )}\n          </AnimatePresence>\n        </li>\n      );\n    });\n\n  return (\n    <ul role=\"tree\" aria-label={label} onKeyDown={onKey} className={`flex flex-col text-foreground ${className ?? \"\"}`}>\n      {renderLevel(data, 0, [])}\n    </ul>\n  );\n}\n"}]}