{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-view",
  "title": "Tree View",
  "description": "The nested list you can open, close and walk with the arrow keys: a file explorer or file tree, a folder or directory tree, a category or taxonomy picker, an org chart, an API-schema browser, a docs sidebar with nested sections. Common asks it answers: \"tree view\", \"tree component\", \"file tree\", \"folder tree\", \"directory tree\", \"file explorer sidebar\", \"nested list with expand/collapse\", \"collapsible tree\", \"expandable folder list\", \"VS Code-style explorer\", \"category tree\", \"org chart tree\". shadcn/ui ships no tree of any kind — its collapsible is one open/closed section and its sidebar nests menus without the tree semantics — so this gets hand-rolled every time, and the part that gets dropped is always the keyboard. Pass a `data` array of `{ id, label, children?, icon? }`: a node with a `children` array is a parent (an empty array is an empty folder, which still opens), a node without one is a leaf. Open state and selection are each controlled (`expandedIds` / `selectedId` plus `onExpandedChange` / `onSelect`, which hands you the whole node) or uncontrolled (`defaultExpandedIds` / `defaultSelectedId`), so it drops into a router-driven sidebar or runs on its own. It is the real ARIA tree pattern, not a pile of nested collapsibles: role=tree / treeitem / group with aria-expanded, aria-selected and aria-level/posinset/setsize, and a roving tabindex so the whole tree is one Tab stop instead of one stop per row. Up/Down walk only the rows actually on screen, Right opens a parent and then steps into it, Left closes it or jumps out to the parent, Home/End hit the ends, Enter/Space select, and type-ahead jumps to the next row starting with what you typed (repeat a letter to cycle). Three details that are easy to get wrong are handled: closing a subtree that contains the focused row hands focus back to the row being closed instead of dropping it on <body>; the row is named by its own label via aria-labelledby, because a treeitem owns its child group and a name computed from contents would read the entire subtree as one row's name; and the disclosure arrow is a click target rather than a nested <button>, since a treeitem must not contain its own focusable elements. Renders folder/file icons by default (`showIcons={false}` for category or org trees), `indent` sets the per-level offset, and per-node `icon` overrides a single row. Styled with shadcn tokens (accent, muted-foreground, ring) so it follows light and dark themes; lucide-react is the only dependency, with no Radix and no state library. Distinct from command-palette, which is a flat searchable launcher: this is for structure you navigate rather than a name you already know. Distinct from json-viewer, which takes the parsed JSON value itself and renders its keys, types and entry counts: reach for that one to display a payload you did not author, and this one when you have your own hierarchy to express as `{ id, label, children }` nodes.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/tree-view.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface TreeNode {\n  /** Unique across the whole tree — it keys the expanded set, the selection and focus. */\n  id: string\n  label: string\n  /**\n   * Present (even as an empty array) marks the node as a parent: it gets a disclosure\n   * arrow and `aria-expanded`, and an empty folder can still be opened to show that it\n   * holds nothing. Leave it undefined for a leaf.\n   */\n  children?: TreeNode[]\n  /** Replaces the default folder/file icon for this row. */\n  icon?: React.ReactNode\n}\n\ninterface TreeViewProps {\n  data: TreeNode[]\n  /** Controlled set of open parents. Pair with `onExpandedChange` to own the state. */\n  expandedIds?: string[]\n  /** Parents open on first render when uncontrolled. */\n  defaultExpandedIds?: string[]\n  onExpandedChange?: (ids: string[]) => void\n  /** Controlled selection (single-select). Pair with `onSelect`. */\n  selectedId?: string | null\n  defaultSelectedId?: string | null\n  /** Called with the whole node, so the handler gets the payload and not just an id. */\n  onSelect?: (node: TreeNode) => void\n  /** Draw the default folder/file icons. Turn off for category or org trees. */\n  showIcons?: boolean\n  /** Indent per level, in pixels. */\n  indent?: number\n  className?: string\n  /** Accessible name for the tree (or wire `aria-labelledby` to a visible heading). */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\ninterface FlatNode {\n  node: TreeNode\n  level: number\n  parentId: string | null\n  expandable: boolean\n}\n\n/**\n * A nested list you can walk with the keyboard: file explorers, folder trees, category or\n * org charts, JSON/API schema browsers, nested navigation.\n *\n * Pass a `data` array of `{ id, label, children? }` and it renders the whole hierarchy —\n * a node with a `children` array is a parent, a node without one is a leaf. Works\n * controlled (`expandedIds` / `selectedId` plus handlers) or uncontrolled\n * (`defaultExpandedIds` / `defaultSelectedId`).\n *\n * It implements the ARIA tree pattern rather than a pile of nested collapsibles: one tab\n * stop for the whole tree (roving tabindex), Up/Down through the *visible* rows only,\n * Right to open a parent and then step into it, Left to close it or jump out to the\n * parent, Home/End for the ends, Enter/Space to select, and type-ahead that jumps to the\n * next row starting with what you typed.\n */\nexport function TreeView({\n  data,\n  expandedIds,\n  defaultExpandedIds,\n  onExpandedChange,\n  selectedId,\n  defaultSelectedId = null,\n  onSelect,\n  showIcons = true,\n  indent = 16,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: TreeViewProps) {\n  const expandedControlled = expandedIds !== undefined\n  const [internalExpanded, setInternalExpanded] = React.useState<string[]>(\n    () => (expandedControlled ? expandedIds : defaultExpandedIds) ?? []\n  )\n  const expanded = expandedControlled ? (expandedIds as string[]) : internalExpanded\n\n  const selectionControlled = selectedId !== undefined\n  const [internalSelected, setInternalSelected] = React.useState<string | null>(\n    () => (selectionControlled ? selectedId : defaultSelectedId) ?? null\n  )\n  const selected = selectionControlled ? (selectedId as string | null) : internalSelected\n\n  const [focusedId, setFocusedId] = React.useState<string | null>(null)\n\n  const id = React.useId()\n  const itemRefs = React.useRef(new Map<string, HTMLLIElement>())\n  const typeahead = React.useRef({ buffer: \"\", time: 0 })\n\n  const expandedSet = React.useMemo(() => new Set(expanded), [expanded])\n\n  // Flatten what is on screen into one array. Every keyboard move is then index arithmetic\n  // over the *visible* rows, which is what the tree pattern navigates — walking the DOM\n  // instead would have to skip closed subtrees by hand.\n  const visible = React.useMemo(() => {\n    const out: FlatNode[] = []\n    const walk = (nodes: TreeNode[], level: number, parentId: string | null) => {\n      for (const node of nodes) {\n        const expandable = Array.isArray(node.children)\n        out.push({ node, level, parentId, expandable })\n        if (expandable && expandedSet.has(node.id)) {\n          walk(node.children as TreeNode[], level + 1, node.id)\n        }\n      }\n    }\n    walk(data, 0, null)\n    return out\n  }, [data, expandedSet])\n\n  // The single row that carries tabIndex={0}. Falling back through selection to the first\n  // row keeps the tree reachable by Tab before anything is focused, and re-resolves focus\n  // when the row that had it was collapsed out of the tree.\n  const activeId = React.useMemo(() => {\n    const ids = new Set(visible.map((v) => v.node.id))\n    if (focusedId && ids.has(focusedId)) return focusedId\n    if (selected && ids.has(selected)) return selected\n    return visible[0]?.node.id ?? null\n  }, [visible, focusedId, selected])\n\n  function focusItem(nodeId: string) {\n    setFocusedId(nodeId)\n    itemRefs.current.get(nodeId)?.focus()\n  }\n\n  function commitExpanded(next: string[]) {\n    if (!expandedControlled) setInternalExpanded(next)\n    onExpandedChange?.(next)\n  }\n\n  function expand(nodeId: string) {\n    if (expandedSet.has(nodeId)) return\n    commitExpanded([...expanded, nodeId])\n  }\n\n  function collapse(nodeId: string) {\n    if (!expandedSet.has(nodeId)) return\n    // Closing a subtree unmounts its rows. If focus is inside, take it back to the node\n    // being closed — otherwise the focused element disappears and focus falls to <body>,\n    // which drops the user out of the tree mid-keystroke.\n    const at = visible.findIndex((v) => v.node.id === nodeId)\n    if (at >= 0) {\n      const level = visible[at].level\n      for (let i = at + 1; i < visible.length && visible[i].level > level; i++) {\n        if (visible[i].node.id === activeId) {\n          focusItem(nodeId)\n          break\n        }\n      }\n    }\n    commitExpanded(expanded.filter((v) => v !== nodeId))\n  }\n\n  function toggle(nodeId: string) {\n    if (expandedSet.has(nodeId)) collapse(nodeId)\n    else expand(nodeId)\n  }\n\n  function select(node: TreeNode) {\n    if (!selectionControlled) setInternalSelected(node.id)\n    onSelect?.(node)\n  }\n\n  /** What a click on the row (or Enter/Space) does: select, and open a parent. */\n  function activate(node: TreeNode) {\n    focusItem(node.id)\n    select(node)\n    if (Array.isArray(node.children)) toggle(node.id)\n  }\n\n  function moveTo(index: number) {\n    const entry = visible[Math.max(0, Math.min(index, visible.length - 1))]\n    if (entry) focusItem(entry.node.id)\n  }\n\n  function typeaheadTo(char: string, from: number) {\n    const now = Date.now()\n    const state = typeahead.current\n    state.buffer = now - state.time > 600 ? char : state.buffer + char\n    state.time = now\n    const query = state.buffer.toLowerCase()\n    // A repeated single letter cycles to the next match; a longer buffer keeps matching\n    // the row it already landed on, so typing \"re\" does not skip past \"readme\".\n    const start = state.buffer.length > 1 ? from : from + 1\n    for (let i = 0; i < visible.length; i++) {\n      const entry = visible[(start + i) % visible.length]\n      if (entry.node.label.toLowerCase().startsWith(query)) {\n        focusItem(entry.node.id)\n        return\n      }\n    }\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent) {\n    const index = visible.findIndex((v) => v.node.id === activeId)\n    if (index < 0) return\n    const current = visible[index]\n\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault()\n        moveTo(index + 1)\n        break\n      case \"ArrowUp\":\n        e.preventDefault()\n        moveTo(index - 1)\n        break\n      case \"ArrowRight\": {\n        e.preventDefault()\n        if (!current.expandable) break\n        if (!expandedSet.has(current.node.id)) {\n          expand(current.node.id)\n          break\n        }\n        // Step into the subtree only if it actually has a first child: an open but empty\n        // folder must not hand focus to the next sibling.\n        const next = visible[index + 1]\n        if (next && next.level > current.level) moveTo(index + 1)\n        break\n      }\n      case \"ArrowLeft\":\n        e.preventDefault()\n        if (current.expandable && expandedSet.has(current.node.id)) collapse(current.node.id)\n        else if (current.parentId) focusItem(current.parentId)\n        break\n      case \"Home\":\n        e.preventDefault()\n        moveTo(0)\n        break\n      case \"End\":\n        e.preventDefault()\n        moveTo(visible.length - 1)\n        break\n      case \"Enter\":\n      case \" \":\n        e.preventDefault()\n        activate(current.node)\n        break\n      default:\n        if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n          typeaheadTo(e.key, index)\n        }\n    }\n  }\n\n  // `prefix` builds each label's id from the node's position rather than its `id`, which is\n  // free-form: an id holding a space would split aria-labelledby (a space-separated token\n  // list) into two dangling references and leave the row with no accessible name at all.\n  const renderNodes = (nodes: TreeNode[], level: number, prefix: string) =>\n    nodes.map((node, index) => {\n      const expandable = Array.isArray(node.children)\n      const isExpanded = expandable && expandedSet.has(node.id)\n      const isSelected = selected === node.id\n      const labelId = `${prefix}-${index}`\n\n      return (\n        <li\n          key={node.id}\n          ref={(el) => {\n            if (el) itemRefs.current.set(node.id, el)\n            else itemRefs.current.delete(node.id)\n          }}\n          role=\"treeitem\"\n          aria-expanded={expandable ? isExpanded : undefined}\n          aria-selected={isSelected}\n          aria-level={level + 1}\n          aria-posinset={index + 1}\n          aria-setsize={nodes.length}\n          // Name the row from its own label. A treeitem owns its child group, so a name\n          // computed from contents would read the entire subtree as the row's name.\n          aria-labelledby={labelId}\n          tabIndex={activeId === node.id ? 0 : -1}\n          // The focus ring belongs on the row, not on the <li>, which wraps the subtree\n          // too — hence the ring is drawn on the li's first child.\n          className=\"focus-visible:outline-none [&:focus-visible>:first-child]:ring-1 [&:focus-visible>:first-child]:ring-ring\"\n        >\n          <div\n            onClick={() => activate(node)}\n            style={{ paddingLeft: level * indent + 4 }}\n            className={cn(\n              \"flex cursor-pointer select-none items-center gap-1.5 rounded-sm py-1 pr-2 text-sm\",\n              isSelected\n                ? \"bg-accent text-accent-foreground\"\n                : \"text-foreground hover:bg-accent/50\"\n            )}\n          >\n            {expandable ? (\n              <ChevronRight\n                // Toggling without selecting is the one thing a mouse can do that the\n                // keyboard cannot, so the arrow is a hit area rather than a <button>:\n                // a treeitem must not contain its own focusable elements.\n                onClick={(e) => {\n                  e.stopPropagation()\n                  focusItem(node.id)\n                  toggle(node.id)\n                }}\n                className={cn(\n                  \"h-4 w-4 shrink-0 text-muted-foreground transition-transform\",\n                  isExpanded && \"rotate-90\"\n                )}\n                aria-hidden=\"true\"\n              />\n            ) : (\n              <span className=\"h-4 w-4 shrink-0\" aria-hidden=\"true\" />\n            )}\n\n            {node.icon !== undefined\n              ? node.icon\n              : showIcons && (\n                  <span className=\"shrink-0 text-muted-foreground\" aria-hidden=\"true\">\n                    {expandable ? (\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                  </span>\n                )}\n\n            <span id={labelId} className=\"truncate\">\n              {node.label}\n            </span>\n          </div>\n\n          {isExpanded && (node.children as TreeNode[]).length > 0 && (\n            <ul role=\"group\">\n              {renderNodes(node.children as TreeNode[], level + 1, labelId)}\n            </ul>\n          )}\n        </li>\n      )\n    })\n\n  return (\n    <ul\n      role=\"tree\"\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      onKeyDown={handleKeyDown}\n      className={cn(\"text-sm\", className)}\n    >\n      {renderNodes(data, 0, `${id}-n`)}\n    </ul>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}