{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-view",
  "type": "registry:ui",
  "title": "Tree View",
  "description": "Recursive expand/collapse tree with keyboard navigation, controlled/uncontrolled expansion + selection.",
  "dependencies": [
    "@saasflare/ui"
  ],
  "files": [
    {
      "path": "components/ui/tree-view.tsx",
      "type": "registry:ui",
      "content": "// @draft\n\"use client\"\n\n/**\n * @fileoverview Saasflare TreeView — recursive expand/collapse tree.\n * @author Saasflare™\n *\n * Renders a hierarchical list of nodes with disclosure carets. Supports\n * controlled + uncontrolled expansion and selection, keyboard navigation\n * (Arrow keys, Home/End), and per-node leading icons.\n *\n * @module packages/ui/components/ui/tree-view\n * @package ui\n * @layer core\n *\n * @example\n * <TreeView\n *   data={[\n *     {\n *       id: \"src\", label: \"src\",\n *       children: [\n *         { id: \"src/app\", label: \"app\" },\n *         { id: \"src/lib\", label: \"lib\", children: [{ id: \"src/lib/cn\", label: \"cn.ts\" }] },\n *       ],\n *     },\n *   ]}\n * />\n */\n\nimport {\n    useCallback,\n    useMemo,\n    useRef,\n    useState,\n    type KeyboardEvent,\n    type ReactNode,\n} from \"react\"\nimport { cn } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\nimport { CaretRightIcon } from \"@saasflare/ui\"\n\n/** A single tree node. */\nexport interface TreeNode {\n    /** Unique node id (stable across renders). */\n    id: string\n    /** Display label or any ReactNode. */\n    label: ReactNode\n    /** Leading icon (e.g. file/folder glyph). */\n    icon?: ReactNode\n    /** Child nodes (omit for leaves). */\n    children?: TreeNode[]\n    /** Disable interaction for this node. */\n    disabled?: boolean\n}\n\n/** Props for the TreeView component. */\nexport interface TreeViewProps extends SaasflareComponentProps {\n    /** Tree data, top-level nodes. */\n    data: TreeNode[]\n    /** Controlled expanded ids. */\n    expanded?: string[]\n    /** Uncontrolled initial expansion. */\n    defaultExpanded?: string[]\n    /** Called when a node is expanded/collapsed. */\n    onExpand?: (ids: string[]) => void\n    /** Controlled selected id. */\n    selected?: string | null\n    /** Uncontrolled initial selection. */\n    defaultSelected?: string | null\n    /** Called when selection changes. */\n    onSelect?: (id: string | null) => void\n    /** Additional class names on the root. */\n    className?: string\n}\n\ninterface FlatNode {\n    node: TreeNode\n    depth: number\n    parentId: string | null\n}\n\n/** Flatten the visible (expanded) tree into a list for keyboard nav. */\nfunction flattenVisible(data: TreeNode[], expanded: Set<string>): FlatNode[] {\n    const out: FlatNode[] = []\n    const walk = (nodes: TreeNode[], depth: number, parentId: string | null) => {\n        for (const node of nodes) {\n            out.push({ node, depth, parentId })\n            if (node.children && expanded.has(node.id)) {\n                walk(node.children, depth + 1, node.id)\n            }\n        }\n    }\n    walk(data, 0, null)\n    return out\n}\n\n/**\n * Recursive expand/collapse tree with keyboard navigation.\n *\n * @component\n * @layer core\n */\nexport function TreeView({\n    data,\n    expanded,\n    defaultExpanded,\n    onExpand,\n    selected,\n    defaultSelected,\n    onSelect,\n    className,\n    surface,\n    radius,\n    animated,\n    iconWeight,\n}: TreeViewProps) {\n    const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n\n    const treeRef = useRef<HTMLDivElement>(null)\n\n    const isExpandedControlled = expanded !== undefined\n    const [internalExpanded, setInternalExpanded] = useState<string[]>(\n        defaultExpanded ?? [],\n    )\n    const expandedSet = useMemo(\n        () => new Set(isExpandedControlled ? (expanded as string[]) : internalExpanded),\n        [isExpandedControlled, expanded, internalExpanded],\n    )\n\n    const isSelectedControlled = selected !== undefined\n    const [internalSelected, setInternalSelected] = useState<string | null>(\n        defaultSelected ?? null,\n    )\n    const currentSelected = isSelectedControlled ? selected : internalSelected\n\n    const toggle = useCallback(\n        (id: string) => {\n            const next = new Set(expandedSet)\n            if (next.has(id)) next.delete(id)\n            else next.add(id)\n            const arr = Array.from(next)\n            if (!isExpandedControlled) setInternalExpanded(arr)\n            onExpand?.(arr)\n        },\n        [expandedSet, isExpandedControlled, onExpand],\n    )\n\n    const select = useCallback(\n        (id: string | null) => {\n            if (!isSelectedControlled) setInternalSelected(id)\n            onSelect?.(id)\n        },\n        [isSelectedControlled, onSelect],\n    )\n\n    const flat = useMemo(() => flattenVisible(data, expandedSet), [data, expandedSet])\n\n    /** First non-disabled row, used as the roving tab stop when nothing is selected. */\n    const firstFocusableIndex = useMemo(\n        () => flat.findIndex((f) => !f.node.disabled),\n        [flat],\n    )\n\n    const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>, index: number) => {\n        const item = flat[index]\n        if (!item || item.node.disabled) return\n        const moveTo = (i: number) => {\n            const target = flat[i]\n            if (!target) return\n            select(target.node.id)\n            const el = treeRef.current?.querySelectorAll<HTMLDivElement>(\n                \"[data-slot='tree-view-row']\",\n            )[i]\n            el?.focus()\n        }\n        switch (e.key) {\n            case \"ArrowDown\":\n                e.preventDefault()\n                moveTo(Math.min(index + 1, flat.length - 1))\n                break\n            case \"ArrowUp\":\n                e.preventDefault()\n                moveTo(Math.max(index - 1, 0))\n                break\n            case \"ArrowRight\":\n                e.preventDefault()\n                if (item.node.children && !expandedSet.has(item.node.id)) {\n                    toggle(item.node.id)\n                } else {\n                    moveTo(index + 1)\n                }\n                break\n            case \"ArrowLeft\":\n                e.preventDefault()\n                if (item.node.children && expandedSet.has(item.node.id)) {\n                    toggle(item.node.id)\n                } else if (item.parentId) {\n                    const parentIdx = flat.findIndex((f) => f.node.id === item.parentId)\n                    if (parentIdx >= 0) moveTo(parentIdx)\n                }\n                break\n            case \"Home\":\n                e.preventDefault()\n                moveTo(0)\n                break\n            case \"End\":\n                e.preventDefault()\n                moveTo(flat.length - 1)\n                break\n            case \"Enter\":\n            case \" \":\n                e.preventDefault()\n                if (item.node.children) toggle(item.node.id)\n                select(item.node.id)\n                break\n        }\n    }\n\n    return (\n        <div\n            ref={treeRef}\n            data-slot=\"tree-view\"\n            data-surface={sf.surface}\n            data-radius={sf.radius}\n            data-animated={String(sf.animated)}\n            role=\"tree\"\n            className={cn(\"text-sm select-none\", className)}\n        >\n            {flat.map(({ node, depth }, index) => {\n                const hasChildren = !!node.children?.length\n                const isExpanded = expandedSet.has(node.id)\n                const isSelected = currentSelected === node.id\n                return (\n                    <div\n                        key={node.id}\n                        data-slot=\"tree-view-row\"\n                        role=\"treeitem\"\n                        aria-expanded={hasChildren ? isExpanded : undefined}\n                        aria-selected={isSelected}\n                        aria-level={depth + 1}\n                        aria-disabled={node.disabled || undefined}\n                        tabIndex={\n                            isSelected ||\n                            (currentSelected === null && index === firstFocusableIndex)\n                                ? 0\n                                : -1\n                        }\n                        onKeyDown={(e) => handleKeyDown(e, index)}\n                        onClick={() => {\n                            if (node.disabled) return\n                            if (hasChildren) toggle(node.id)\n                            select(node.id)\n                        }}\n                        className={cn(\n                            \"flex h-7 cursor-pointer items-center gap-1 rounded px-1.5\",\n                            \"transition-colors hover:bg-accent/40\",\n                            \"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                            isSelected && \"bg-accent/60 text-foreground\",\n                            node.disabled && \"pointer-events-none opacity-50\",\n                        )}\n                        style={{ paddingLeft: 6 + depth * 16 }}\n                    >\n                        {hasChildren ? (\n                            <CaretRightIcon\n                                weight={sf.iconWeight}\n                                className={cn(\n                                    \"size-3.5 shrink-0 text-muted-foreground transition-transform duration-150\",\n                                    isExpanded && \"rotate-90\",\n                                )}\n                            />\n                        ) : (\n                            <span className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n                        )}\n                        {node.icon !== undefined && (\n                            <span className=\"flex shrink-0 items-center [&_svg]:size-4\">\n                                {node.icon}\n                            </span>\n                        )}\n                        <span className=\"truncate\">{node.label}</span>\n                    </div>\n                )\n            })}\n        </div>\n    )\n}\n",
      "target": "components/ui/tree-view.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/tree-view.json"
  }
}
