{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/tree-select",
  "title": "Tree Select",
  "description": "Hierarchical tree selection",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-symbols.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/tree-select.tsx",
      "content": "import { Box, Text } from \"ink\";\nimport React, { useEffect, useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveTerminalSymbol } from \"@/lib/terminal-symbols\";\n\nexport interface TreeSelectNode<T = string> {\n  value: T;\n  label: string;\n  children?: TreeSelectNode<T>[];\n  disabled?: boolean;\n}\n\nexport interface TreeSelectProps<T = string> {\n  nodes: TreeSelectNode<T>[];\n  value?: T;\n  defaultValue?: T;\n  onValueChange?: (value: T) => void;\n  onChange?: (value: T) => void;\n  onSubmit?: (value: T) => void;\n  label?: string;\n  expandedByDefault?: boolean;\n  id?: string;\n  autoFocus?: boolean;\n  isActive?: boolean;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n}\n\ninterface FlatNode<T> {\n  node: TreeSelectNode<T>;\n  depth: number;\n  path: string;\n  hasChildren: boolean;\n}\n\nconst flatten = <T,>(\n  nodes: TreeSelectNode<T>[],\n  depth: number,\n  expanded: Set<string>,\n  pathPrefix: string,\n  expandedByDefault: boolean\n): FlatNode<T>[] => {\n  const result: FlatNode<T>[] = [];\n\n  for (let i = 0; i < nodes.length; i += 1) {\n    const node = nodes[i];\n    const path = `${pathPrefix}/${i}`;\n    const hasChildren = !!(node.children && node.children.length > 0);\n    result.push({ depth, hasChildren, node, path });\n\n    if (hasChildren) {\n      const isExpanded =\n        expanded.has(path) ||\n        (expandedByDefault && !expanded.has(`${path}:collapsed`));\n      if (isExpanded) {\n        result.push(\n          ...flatten(\n            node.children ?? [],\n            depth + 1,\n            expanded,\n            path,\n            expandedByDefault\n          )\n        );\n      }\n    }\n  }\n\n  return result;\n};\n\nconst getNodeColor = (\n  disabled: boolean | undefined,\n  isCursor: boolean,\n  isSelected: boolean,\n  theme: ReturnType<typeof useTheme>\n): string => {\n  if (disabled) {\n    return theme.colors.mutedForeground;\n  }\n  if (isCursor) {\n    return theme.colors.primary;\n  }\n  if (isSelected) {\n    return theme.colors.accent;\n  }\n  return theme.colors.foreground;\n};\n\nexport const TreeSelect = <T = string,>({\n  nodes,\n  value: controlledValue,\n  defaultValue,\n  onValueChange,\n  onChange,\n  onSubmit,\n  label,\n  expandedByDefault = false,\n  id,\n  autoFocus = false,\n  isActive = true,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n}: TreeSelectProps<T>) => {\n  const theme = useTheme();\n  const unicode = useUnicode();\n  const [expanded, setExpanded] = useState<Set<string>>(new Set());\n  const [cursor, setCursor] = useState(0);\n  const [internalValue, setInternalValue] = useState<T | undefined>(\n    defaultValue\n  );\n\n  const selected = controlledValue ?? internalValue;\n  const flat = flatten(nodes, 0, expanded, \"\", expandedByDefault);\n\n  useEffect(() => {\n    setCursor((current) => Math.max(0, Math.min(current, flat.length - 1)));\n  }, [flat.length]);\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (key.upArrow) {\n        setCursor((c) => Math.max(0, c - 1));\n      } else if (key.downArrow) {\n        setCursor((c) => Math.max(0, Math.min(flat.length - 1, c + 1)));\n      } else if (key.leftArrow) {\n        const item = flat[cursor];\n        if (item && item.hasChildren) {\n          const isExpanded =\n            expanded.has(item.path) ||\n            (expandedByDefault && !expanded.has(`${item.path}:collapsed`));\n          if (isExpanded) {\n            setExpanded((prev) => {\n              const next = new Set(prev);\n              next.delete(item.path);\n              if (expandedByDefault) {\n                next.add(`${item.path}:collapsed`);\n              }\n              return next;\n            });\n          }\n        }\n      } else if (key.rightArrow || input === \" \") {\n        const item = flat[cursor];\n        if (item && item.hasChildren) {\n          const isExpanded =\n            expanded.has(item.path) ||\n            (expandedByDefault && !expanded.has(`${item.path}:collapsed`));\n          setExpanded((prev) => {\n            const next = new Set(prev);\n            if (isExpanded) {\n              next.delete(item.path);\n              if (expandedByDefault) {\n                next.add(`${item.path}:collapsed`);\n              }\n            } else {\n              next.add(item.path);\n              next.delete(`${item.path}:collapsed`);\n            }\n            return next;\n          });\n        }\n      } else if (key.home) {\n        setCursor(0);\n      } else if (key.end) {\n        setCursor(Math.max(0, flat.length - 1));\n      } else if (key.return) {\n        const item = flat[cursor];\n        if (item && !item.node.disabled) {\n          (onValueChange ?? onChange)?.(item.node.value);\n          onSubmit?.(item.node.value);\n          setInternalValue(item.node.value);\n        }\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  return (\n    <Box aria-role=\"list\" flexDirection=\"column\">\n      <Text\n        aria-label={ariaLabel ?? label ?? \"Tree select\"}\n        bold={Boolean(label)}\n      >\n        {label ?? \"\"}\n      </Text>\n      {flat.map((item, idx) => {\n        const isCursor = idx === cursor;\n        const isSelected =\n          selected !== undefined && item.node.value === selected;\n        const isExpanded =\n          item.hasChildren &&\n          (expanded.has(item.path) ||\n            (expandedByDefault && !expanded.has(`${item.path}:collapsed`)));\n\n        const prefix = \"  \".repeat(item.depth);\n        let icon = \"\";\n        if (item.hasChildren) {\n          icon = isExpanded\n            ? resolveTerminalSymbol(unicode, \"▼ \", \"v \")\n            : resolveTerminalSymbol(unicode, \"▶ \", \"> \");\n        } else {\n          icon = resolveTerminalSymbol(unicode, \"· \", \"- \");\n        }\n\n        const color = getNodeColor(\n          item.node.disabled,\n          isCursor,\n          isSelected,\n          theme\n        );\n\n        return (\n          <Box\n            key={item.path}\n            aria-label={`${item.node.label}, level ${item.depth + 1}${\n              item.hasChildren\n                ? `, ${isExpanded ? \"expanded\" : \"collapsed\"}`\n                : \"\"\n            }${isSelected ? \", selected\" : \"\"}`}\n            aria-role=\"listitem\"\n            aria-state={{\n              disabled: disabled || item.node.disabled,\n              expanded: item.hasChildren ? isExpanded : undefined,\n              selected: isSelected,\n            }}\n          >\n            <Text\n              aria-hidden\n              color={color}\n              bold={isCursor || isSelected}\n              dimColor={item.node.disabled}\n              backgroundColor={isCursor ? theme.colors.selection : undefined}\n            >\n              {prefix}\n              {icon}\n              {item.node.label}\n            </Text>\n          </Box>\n        );\n      })}\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "selection"
  ],
  "type": "registry:ui"
}