{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-view",
  "title": "Tree View",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A keyboard-operable hierarchical tree with selection, expansion, and optional node icons.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/tree-view.tsx",
      "content": "// Ballmac UI: Tree View. https://ui.ballmac.com/components/tree-view\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRight } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\n\ntype TreeNode = {\n  /** Stable node identifier. */\n  id: string\n  /** Visible node label. */\n  label: string\n  /** Nested children. */\n  children?: TreeNode[]\n  /** Disable selection for this node. */\n  disabled?: boolean\n}\ntype TreeViewProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** Hierarchical nodes to display. */\n  nodes: TreeNode[]\n  /** Accessible name for the tree. */\n  label: string\n  /** Controlled selected node ID. */\n  selectedId?: string\n  /** Initial selected node ID. */\n  defaultSelectedId?: string\n  /** Called when selection changes. */\n  onSelectedIdChange?: (id: string) => void\n  /** Controlled expanded IDs. */\n  expandedIds?: string[]\n  /** Initially expanded IDs. */\n  defaultExpandedIds?: string[]\n  /** Called when expansion changes. */\n  onExpandedIdsChange?: (ids: string[]) => void\n  /** Optional icon for each node. */\n  renderIcon?: (node: TreeNode, expanded: boolean) => React.ReactNode\n}\ntype VisibleNode = {\n  node: TreeNode\n  depth: number\n  parent?: string\n  position: number\n  siblingCount: number\n}\n\nfunction TreeView({\n  className,\n  nodes,\n  label,\n  selectedId,\n  defaultSelectedId,\n  onSelectedIdChange,\n  expandedIds,\n  defaultExpandedIds = [],\n  onExpandedIdsChange,\n  renderIcon,\n  ...props\n}: TreeViewProps) {\n  const dir = useDirection()\n  const [internalSelected, setInternalSelected] = React.useState(\n    defaultSelectedId ?? nodes[0]?.id,\n  )\n  const [internalExpanded, setInternalExpanded] =\n    React.useState(defaultExpandedIds)\n  const selection = selectedId ?? internalSelected\n  const expanded = expandedIds ?? internalExpanded\n  const expandedSet = new Set(expanded)\n  const visible: VisibleNode[] = []\n  function add(items: TreeNode[], depth: number, parent?: string) {\n    items.forEach((node, index) => {\n      visible.push({\n        node,\n        depth,\n        parent,\n        position: index + 1,\n        siblingCount: items.length,\n      })\n      if (node.children?.length && expandedSet.has(node.id))\n        add(node.children, depth + 1, node.id)\n    })\n  }\n  add(nodes, 1)\n  const tabbableId = visible.some(\n    (entry) => entry.node.id === selection && !entry.node.disabled,\n  )\n    ? selection\n    : visible.find((entry) => !entry.node.disabled)?.node.id\n  const refMap = React.useRef(new Map<string, HTMLDivElement>())\n  const focus = (id: string) => refMap.current.get(id)?.focus()\n  function select(id: string) {\n    if (selectedId === undefined) setInternalSelected(id)\n    onSelectedIdChange?.(id)\n  }\n  function toggle(id: string, open?: boolean) {\n    const next = expandedSet.has(id)\n      ? open === true\n        ? expanded\n        : expanded.filter((item) => item !== id)\n      : open === false\n        ? expanded\n        : [...expanded, id]\n    if (expandedIds === undefined) setInternalExpanded(next)\n    onExpandedIdsChange?.(next)\n  }\n  function onKeyDown(\n    event: React.KeyboardEvent<HTMLDivElement>,\n    current: VisibleNode,\n    index: number,\n  ) {\n    const children = current.node.children?.length ?? 0\n    let next: string | undefined\n    if (event.key === \"ArrowDown\")\n      next = visible.slice(index + 1).find((entry) => !entry.node.disabled)\n        ?.node.id\n    else if (event.key === \"ArrowUp\")\n      next = [...visible.slice(0, index)]\n        .reverse()\n        .find((entry) => !entry.node.disabled)?.node.id\n    else if (event.key === \"Home\")\n      next = visible.find((entry) => !entry.node.disabled)?.node.id\n    else if (event.key === \"End\")\n      next = [...visible].reverse().find((entry) => !entry.node.disabled)\n        ?.node.id\n    else if (event.key === (dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\") && children) {\n      if (!expandedSet.has(current.node.id)) toggle(current.node.id, true)\n      else next = current.node.children?.find((child) => !child.disabled)?.id\n    } else if (event.key === (dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\")) {\n      if (children && expandedSet.has(current.node.id))\n        toggle(current.node.id, false)\n      else next = current.parent\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      select(current.node.id)\n      if (children) toggle(current.node.id)\n    } else return\n    event.preventDefault()\n    if (next) {\n      select(next)\n      focus(next)\n    }\n  }\n  return (\n    <div\n      data-slot=\"tree-view\"\n      role=\"tree\"\n      aria-label={label}\n      className={cn(\n        \"min-w-0 rounded-xl border border-border bg-card p-1\",\n        className,\n      )}\n      {...props}\n    >\n      {visible.map((entry, index) => {\n        const { node, depth } = entry\n        const hasChildren = Boolean(node.children?.length)\n        const isExpanded = expandedSet.has(node.id)\n        return (\n          <div\n            key={node.id}\n            ref={(element) => {\n              if (element) refMap.current.set(node.id, element)\n              else refMap.current.delete(node.id)\n            }}\n            data-slot=\"tree-view-item\"\n            role=\"treeitem\"\n            aria-level={depth}\n            aria-posinset={entry.position}\n            aria-setsize={entry.siblingCount}\n            aria-selected={selection === node.id}\n            aria-expanded={hasChildren ? isExpanded : undefined}\n            aria-disabled={node.disabled || undefined}\n            tabIndex={tabbableId === node.id ? 0 : -1}\n            onClick={() => {\n              if (!node.disabled) {\n                select(node.id)\n                focus(node.id)\n              }\n            }}\n            onDoubleClick={() => {\n              if (hasChildren) toggle(node.id)\n            }}\n            onKeyDown={(event) => {\n              if (!node.disabled) onKeyDown(event, entry, index)\n            }}\n            className={cn(\n              \"flex min-h-9 cursor-default items-center gap-1 rounded-md pe-2 text-sm outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n              selection === node.id && \"bg-accent text-accent-foreground\",\n              node.disabled && \"pointer-events-none opacity-50\",\n            )}\n            style={{ paddingInlineStart: `${(depth - 1) * 16 + 8}px` }}\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"flex size-4 shrink-0 items-center justify-center\"\n            >\n              {hasChildren && (\n                <ChevronRight\n                  className={cn(\n                    \"size-3.5 transition-transform duration-150 motion-reduce:transition-none\",\n                    isExpanded ? \"rotate-90\" : \"rtl:rotate-180\",\n                  )}\n                />\n              )}\n            </span>\n            {renderIcon && (\n              <span\n                aria-hidden=\"true\"\n                className=\"text-muted-foreground flex size-4 shrink-0 items-center justify-center\"\n              >\n                {renderIcon(node, isExpanded)}\n              </span>\n            )}\n            <span className=\"min-w-0 truncate\">{node.label}</span>\n          </div>\n        )\n      })}\n    </div>\n  )\n}\nexport { TreeView, type TreeViewProps, type TreeNode }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/tree-view.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "tree",
      "navigation",
      "hierarchy"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "A keyboard-operable hierarchical tree with selection, expansion, and optional node icons.",
      "whenToUse": [
        "Browse nested navigation or structured data"
      ],
      "whenNotToUse": [
        "Use File Tree for folders and files"
      ],
      "composesWith": [
        "file-tree"
      ],
      "a11y": [
        {
          "keys": "Arrow keys / Home / End / Enter / Space",
          "action": "Navigates, expands, and selects nodes"
        }
      ],
      "customization": [
        "Controlled and uncontrolled state",
        "Content and token-based className styling"
      ]
    },
    "examples": [
      "tree-view-demo",
      "tree-view-states"
    ],
    "url": "https://ui.ballmac.com/components/tree-view"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}