{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-view",
  "title": "Tree View",
  "description": "Hierarchical list with expandable rows and keyboard navigation.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "@pestoui/style",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/ui/tree-view.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { ChevronRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype TreeNode = {\n  id: string\n  label: string\n  icon?: React.ReactNode\n  children?: TreeNode[]\n}\n\n/* One visible row, flattened. Rebuilt whenever the data or the expansion set\n   changes — it is what the arrow keys walk. */\ntype TreeRow = {\n  id: string\n  parentId: string | undefined\n  hasChildren: boolean\n}\n\nfunction collectRows(\n  nodes: readonly TreeNode[],\n  expandedIds: ReadonlySet<string>,\n  parentId: string | undefined,\n  rows: TreeRow[]\n) {\n  for (const node of nodes) {\n    const children = node.children\n    const hasChildren = children !== undefined && children.length > 0\n    rows.push({ id: node.id, parentId, hasChildren })\n    if (children && hasChildren && expandedIds.has(node.id)) {\n      collectRows(children, expandedIds, node.id, rows)\n    }\n  }\n  return rows\n}\n\nconst treeItemVariants = cva(\n  \"group/tree-item relative flex w-full items-center border border-transparent text-left whitespace-nowrap transition-colors outline-none select-none hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    variants: {\n      /* Explicit squircle radii — these rows are short (28–32px), so a token or\n         full radius would exceed half the box and round a short label into a\n         lozenge. Matches the documented list-row spec (sidebar / menu items). */\n      size: {\n        default: \"h-8 gap-1.5 rounded-[10px] px-2 text-body\",\n        sm: \"h-7 gap-1 rounded-[9px] px-1.5 text-caption\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n    },\n  }\n)\n\ntype TreeContextValue = {\n  expandedIds: ReadonlySet<string>\n  selectedId: string | undefined\n  activeId: string | undefined\n  showGuides: boolean\n  size: VariantProps<typeof treeItemVariants>[\"size\"]\n  registerRow: (id: string, element: HTMLButtonElement | null) => void\n  activateRow: (id: string, hasChildren: boolean) => void\n  focusRow: (id: string) => void\n  handleKeyDown: (\n    event: React.KeyboardEvent<HTMLButtonElement>,\n    id: string\n  ) => void\n}\n\nconst TreeContext = React.createContext<TreeContextValue | null>(null)\n\nfunction useTreeContext() {\n  const context = React.useContext(TreeContext)\n  if (!context) {\n    throw new Error(\"TreeItem must be used within a <TreeView />.\")\n  }\n  return context\n}\n\nfunction TreeView({\n  className,\n  data,\n  defaultExpanded,\n  selectedId,\n  onSelect,\n  showGuides = true,\n  size = \"default\",\n  ...props\n}: Omit<React.ComponentProps<\"ul\">, \"onSelect\" | \"children\"> &\n  VariantProps<typeof treeItemVariants> & {\n    data: TreeNode[]\n    defaultExpanded?: string[]\n    selectedId?: string\n    onSelect?: (id: string) => void\n    showGuides?: boolean\n  }) {\n  const [expandedIds, setExpandedIds] = React.useState<ReadonlySet<string>>(\n    () => new Set(defaultExpanded)\n  )\n  const [internalSelectedId, setInternalSelectedId] = React.useState<string>()\n  const [focusedId, setFocusedId] = React.useState<string>()\n  const rowRefs = React.useRef(new Map<string, HTMLButtonElement>())\n\n  const rows = React.useMemo(\n    () => collectRows(data, expandedIds, undefined, []),\n    [data, expandedIds]\n  )\n\n  const resolvedSelectedId = selectedId ?? internalSelectedId\n  const isVisible = (id: string | undefined) =>\n    id !== undefined && rows.some((row) => row.id === id)\n\n  /* Roving tabindex — exactly one row is reachable with Tab: the last one\n     touched, otherwise the selected one, otherwise the first row. */\n  const activeId = isVisible(focusedId)\n    ? focusedId\n    : isVisible(resolvedSelectedId)\n      ? resolvedSelectedId\n      : rows[0]?.id\n\n  function registerRow(id: string, element: HTMLButtonElement | null) {\n    if (element) {\n      rowRefs.current.set(id, element)\n    } else {\n      rowRefs.current.delete(id)\n    }\n  }\n\n  function setExpansion(id: string, open: boolean) {\n    setExpandedIds((current) => {\n      const next = new Set(current)\n      if (open) {\n        next.add(id)\n      } else {\n        next.delete(id)\n      }\n      return next\n    })\n  }\n\n  function focusRow(id: string) {\n    setFocusedId(id)\n    rowRefs.current.get(id)?.focus()\n  }\n\n  function activateRow(id: string, hasChildren: boolean) {\n    if (hasChildren) {\n      setExpansion(id, !expandedIds.has(id))\n    }\n    setInternalSelectedId(id)\n    onSelect?.(id)\n  }\n\n  function handleKeyDown(\n    event: React.KeyboardEvent<HTMLButtonElement>,\n    id: string\n  ) {\n    const index = rows.findIndex((row) => row.id === id)\n    const row = rows[index]\n    if (!row) return\n\n    const step = (target: TreeRow | undefined) => {\n      if (!target) return\n      event.preventDefault()\n      focusRow(target.id)\n    }\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        step(rows[index + 1])\n        break\n      case \"ArrowUp\":\n        step(rows[index - 1])\n        break\n      case \"ArrowRight\":\n        if (!row.hasChildren) break\n        event.preventDefault()\n        if (expandedIds.has(id)) {\n          focusRow(rows[index + 1]?.id ?? id)\n        } else {\n          setExpansion(id, true)\n        }\n        break\n      case \"ArrowLeft\":\n        if (row.hasChildren && expandedIds.has(id)) {\n          event.preventDefault()\n          setExpansion(id, false)\n        } else if (row.parentId) {\n          event.preventDefault()\n          focusRow(row.parentId)\n        }\n        break\n      case \"Home\":\n        step(rows[0])\n        break\n      case \"End\":\n        step(rows[rows.length - 1])\n        break\n      case \"Enter\":\n      case \" \":\n        event.preventDefault()\n        activateRow(id, row.hasChildren)\n        break\n      default:\n        break\n    }\n  }\n\n  return (\n    <TreeContext.Provider\n      value={{\n        expandedIds,\n        selectedId: resolvedSelectedId,\n        activeId,\n        showGuides,\n        size,\n        registerRow,\n        activateRow,\n        focusRow,\n        handleKeyDown,\n      }}\n    >\n      <ul\n        data-slot=\"tree-view\"\n        role=\"tree\"\n        className={cn(\"flex w-full flex-col gap-0.5\", className)}\n        {...props}\n      >\n        {data.map((node) => (\n          <TreeItem key={node.id} node={node} depth={0} />\n        ))}\n      </ul>\n    </TreeContext.Provider>\n  )\n}\n\nfunction TreeItem({\n  className,\n  node,\n  depth = 0,\n  ...props\n}: Omit<React.ComponentProps<\"li\">, \"children\"> & {\n  node: TreeNode\n  depth?: number\n}) {\n  const {\n    expandedIds,\n    selectedId,\n    activeId,\n    showGuides,\n    size,\n    registerRow,\n    activateRow,\n    focusRow,\n    handleKeyDown,\n  } = useTreeContext()\n\n  const groupId = `tree-group-${React.useId()}`\n  const children = node.children\n  const hasChildren = children !== undefined && children.length > 0\n  const isExpanded = hasChildren && expandedIds.has(node.id)\n  const isSelected = selectedId === node.id\n\n  return (\n    <li\n      data-slot=\"tree-item\"\n      role=\"none\"\n      className={cn(\"min-w-0\", className)}\n      {...props}\n    >\n      <button\n        ref={(element) => {\n          registerRow(node.id, element)\n        }}\n        type=\"button\"\n        role=\"treeitem\"\n        data-slot=\"tree-item-trigger\"\n        data-selected={isSelected}\n        aria-level={depth + 1}\n        aria-selected={isSelected}\n        aria-expanded={hasChildren ? isExpanded : undefined}\n        aria-owns={isExpanded ? groupId : undefined}\n        tabIndex={activeId === node.id ? 0 : -1}\n        onClick={() => activateRow(node.id, hasChildren)}\n        onFocus={() => focusRow(node.id)}\n        onKeyDown={(event) => handleKeyDown(event, node.id)}\n        className={cn(\n          treeItemVariants({ size }),\n          /* One accent says \"this is the current row\": a --selected-ink label\n               over a low-alpha wash of --selected. The wash is a background, but it\n               stays on --selected rather than --primary — at these alphas the\n               CTA lime is too pale to register on a light surface. */\n          isSelected &&\n            \"bg-selected/10 font-medium text-selected-ink hover:bg-selected/15 dark:bg-selected/15 dark:hover:bg-selected/20\"\n        )}\n      >\n        {hasChildren ? (\n          /* The quarter turn is the Switch's travel (`turn-travel`, 3.7° past\n             and back) and the press its squeeze-and-lean toward the next turn.\n             The overshoot is on the chevron only: it is 4px of glyph, not the\n             subtree, so the \"too often for overshoot\" rule below still holds\n             for the thing that is large. Arrow keys get the turn, not the press. */\n          <ChevronRight\n            aria-hidden=\"true\"\n            className=\"size-4 text-muted-foreground/80 turn-travel group-active/tree-item:press-turn group-aria-expanded/tree-item:rotate-90 group-aria-expanded/tree-item:[--turn-dir:-1] group-aria-selected/tree-item:text-selected/80\"\n          />\n        ) : (\n          <span aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n        )}\n        {node.icon ? (\n          <span\n            data-slot=\"tree-item-icon\"\n            aria-hidden=\"true\"\n            className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground group-aria-selected/tree-item:text-selected [&_svg]:size-4\"\n          >\n            {node.icon}\n          </span>\n        ) : null}\n        <span data-slot=\"tree-item-label\" className=\"truncate\">\n          {node.label}\n        </span>\n      </button>\n      {isExpanded && children ? (\n        <ul\n          id={groupId}\n          role=\"group\"\n          data-slot=\"tree-item-group\"\n          /* ms-4 lands the guide directly under the parent chevron's centre.\n\n             The subtree settles into the space rather than appearing in it: it\n             is mounted by the expansion, so `starting:` is the only way in — a\n             plain transition has nothing to travel from on a fresh mount. The\n             glide spring is deliberately overdamped; a tree is expanded far too\n             often for overshoot to stay charming.\n\n             The row space itself opens instantly. Animating the height would\n             mean either measuring the group in JS or leaving `overflow-hidden`\n             on it forever, and a standing clip would cut the focus ring off the\n             rows inside — a rest-state regression to buy an enter animation. */\n          className={cn(\n            \"ms-4 flex flex-col gap-0.5 border-l ps-2 pt-0.5 transition-[opacity,translate] duration-spring-glide ease-spring-glide starting:-translate-y-1 starting:opacity-0\",\n            showGuides ? \"border-border\" : \"border-transparent\"\n          )}\n        >\n          {children.map((child) => (\n            <TreeItem key={child.id} node={child} depth={depth + 1} />\n          ))}\n        </ul>\n      ) : null}\n    </li>\n  )\n}\n\nexport { TreeView, TreeItem, treeItemVariants }\nexport type { TreeNode }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}