{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree",
  "title": "Tree",
  "description": "A hierarchical view of expandable, selectable rows for project scopes, folder structures and nested taxonomies.",
  "dependencies": [
    "@base-ui/react@^1.6.0",
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/ui/tree.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Collapsible as CollapsiblePrimitive } from \"@base-ui/react/collapsible\"\nimport { ChevronRight } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype SelectionMode = \"single\" | \"multiple\" | \"none\"\n\n/** A node in the data-driven tree (`items` prop). */\nexport type TreeNode = {\n  id: string\n  label: React.ReactNode\n  icon?: React.ReactNode\n  /** Trailing slot — a badge, count or action rendered on the right of the row. */\n  badge?: React.ReactNode\n  children?: TreeNode[]\n  disabled?: boolean\n}\n\ntype TreeContextValue = {\n  expanded: Set<string>\n  selected: Set<string>\n  selectionMode: SelectionMode\n  activeId: string | null\n  setActiveId: (id: string | null) => void\n  toggleExpanded: (id: string, open?: boolean) => void\n  select: (id: string) => void\n  showGuides: boolean\n}\n\nconst TreeContext = React.createContext<TreeContextValue | null>(null)\n\nfunction useTree() {\n  const ctx = React.useContext(TreeContext)\n  if (!ctx) throw new Error(\"Tree parts must be used within <Tree>.\")\n  return ctx\n}\n\nconst LevelContext = React.createContext(1)\n\ntype ItemContextValue = {\n  value: string\n  level: number\n  disabled: boolean\n  hasChildren: boolean\n  open: boolean\n}\n\nconst ItemContext = React.createContext<ItemContextValue | null>(null)\n\nfunction useItem() {\n  const ctx = React.useContext(ItemContext)\n  if (!ctx) throw new Error(\"TreeItem parts must be used within <TreeItem>.\")\n  return ctx\n}\n\nfunction useControllableSet(\n  controlled: string[] | undefined,\n  defaultValue: string[] | undefined,\n  onChange?: (ids: string[]) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState<string[]>(\n    () => defaultValue ?? []\n  )\n  const isControlled = controlled !== undefined\n  const value = isControlled ? controlled : uncontrolled\n  const set = React.useMemo(() => new Set(value), [value])\n  const update = React.useCallback(\n    (next: string[]) => {\n      if (!isControlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [isControlled, onChange]\n  )\n  return [set, update] as const\n}\n\nfunction Tree({\n  items,\n  children,\n  expandedIds,\n  defaultExpandedIds,\n  onExpandedIdsChange,\n  selectedIds,\n  defaultSelectedIds,\n  onSelectedIdsChange,\n  selectionMode = \"single\",\n  showGuides = true,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** Data-driven mode — render the whole tree from a nested node array. */\n  items?: TreeNode[]\n  /** Controlled set of expanded ids. */\n  expandedIds?: string[]\n  /** Uncontrolled initial expanded ids. */\n  defaultExpandedIds?: string[]\n  onExpandedIdsChange?: (ids: string[]) => void\n  /** Controlled set of selected ids. */\n  selectedIds?: string[]\n  /** Uncontrolled initial selected ids. */\n  defaultSelectedIds?: string[]\n  onSelectedIdsChange?: (ids: string[]) => void\n  selectionMode?: SelectionMode\n  /** Draw the subtle vertical indentation guide lines (default true). */\n  showGuides?: boolean\n}) {\n  const [expanded, setExpandedArr] = useControllableSet(\n    expandedIds,\n    defaultExpandedIds,\n    onExpandedIdsChange\n  )\n  const [selected, setSelectedArr] = useControllableSet(\n    selectedIds,\n    defaultSelectedIds,\n    onSelectedIdsChange\n  )\n  const [activeId, setActiveId] = React.useState<string | null>(null)\n  const rootRef = React.useRef<HTMLDivElement>(null)\n\n  const toggleExpanded = React.useCallback(\n    (id: string, open?: boolean) => {\n      const willOpen = open ?? !expanded.has(id)\n      const next = new Set(expanded)\n      if (willOpen) next.add(id)\n      else next.delete(id)\n      setExpandedArr([...next])\n    },\n    [expanded, setExpandedArr]\n  )\n\n  const select = React.useCallback(\n    (id: string) => {\n      if (selectionMode === \"none\") return\n      if (selectionMode === \"multiple\") {\n        const next = new Set(selected)\n        if (next.has(id)) next.delete(id)\n        else next.add(id)\n        setSelectedArr([...next])\n      } else {\n        setSelectedArr([id])\n      }\n    },\n    [selectionMode, selected, setSelectedArr]\n  )\n\n  const getItems = React.useCallback(\n    () =>\n      Array.from(\n        rootRef.current?.querySelectorAll<HTMLElement>(\n          '[role=\"treeitem\"]'\n        ) ?? []\n      ).filter((el) => el.dataset.disabled !== \"true\"),\n    []\n  )\n\n  const focusItem = React.useCallback(\n    (el: HTMLElement | undefined) => {\n      if (!el) return\n      setActiveId(el.dataset.value ?? null)\n      el.focus()\n    },\n    []\n  )\n\n  const expandedKey = React.useMemo(\n    () => [...expanded].sort().join(\"|\"),\n    [expanded]\n  )\n\n  // Keep exactly one row in the roving tab sequence: reset to the first\n  // visible row whenever the active row disappears (e.g. its parent collapses).\n  React.useEffect(() => {\n    const list = getItems()\n    if (!list.length) return\n    if (!activeId || !list.some((el) => el.dataset.value === activeId)) {\n      setActiveId(list[0]?.dataset.value ?? null)\n    }\n  }, [activeId, expandedKey, getItems])\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const list = getItems()\n    if (!list.length) return\n    const current = document.activeElement as HTMLElement | null\n    let idx = current ? list.indexOf(current) : -1\n    if (idx === -1) idx = list.findIndex((el) => el.dataset.value === activeId)\n    const el = idx >= 0 ? list[idx] : undefined\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        focusItem(list[Math.min(idx + 1, list.length - 1)] ?? list[0])\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        focusItem(list[Math.max(idx - 1, 0)])\n        break\n      case \"Home\":\n        event.preventDefault()\n        focusItem(list[0])\n        break\n      case \"End\":\n        event.preventDefault()\n        focusItem(list[list.length - 1])\n        break\n      case \"ArrowRight\": {\n        event.preventDefault()\n        if (!el) break\n        const value = el.dataset.value!\n        if (el.dataset.haschildren === \"true\") {\n          if (el.getAttribute(\"aria-expanded\") === \"false\") {\n            toggleExpanded(value, true)\n          } else {\n            const child = list[idx + 1]\n            if (child && Number(child.dataset.level) > Number(el.dataset.level))\n              focusItem(child)\n          }\n        }\n        break\n      }\n      case \"ArrowLeft\": {\n        event.preventDefault()\n        if (!el) break\n        const value = el.dataset.value!\n        const level = Number(el.dataset.level)\n        if (\n          el.dataset.haschildren === \"true\" &&\n          el.getAttribute(\"aria-expanded\") === \"true\"\n        ) {\n          toggleExpanded(value, false)\n        } else {\n          for (let i = idx - 1; i >= 0; i--) {\n            if (Number(list[i].dataset.level) < level) {\n              focusItem(list[i])\n              break\n            }\n          }\n        }\n        break\n      }\n      case \"Enter\":\n      case \" \": {\n        event.preventDefault()\n        if (!el) break\n        const value = el.dataset.value!\n        select(value)\n        if (el.dataset.haschildren === \"true\") toggleExpanded(value)\n        break\n      }\n      default: {\n        // Typeahead — jump to the next row whose label starts with the key.\n        if (\n          event.key.length === 1 &&\n          !event.metaKey &&\n          !event.ctrlKey &&\n          !event.altKey\n        ) {\n          const char = event.key.toLowerCase()\n          const start = idx + 1\n          for (let i = 0; i < list.length; i++) {\n            const it = list[(start + i) % list.length]\n            if ((it.dataset.label ?? \"\").toLowerCase().startsWith(char)) {\n              focusItem(it)\n              break\n            }\n          }\n        }\n      }\n    }\n  }\n\n  const context = React.useMemo<TreeContextValue>(\n    () => ({\n      expanded,\n      selected,\n      selectionMode,\n      activeId,\n      setActiveId,\n      toggleExpanded,\n      select,\n      showGuides,\n    }),\n    [\n      expanded,\n      selected,\n      selectionMode,\n      activeId,\n      toggleExpanded,\n      select,\n      showGuides,\n    ]\n  )\n\n  return (\n    <TreeContext.Provider value={context}>\n      <div\n        ref={rootRef}\n        role=\"tree\"\n        aria-multiselectable={selectionMode === \"multiple\" || undefined}\n        data-slot=\"tree\"\n        className={cn(\"flex w-full flex-col gap-0.5\", className)}\n        onKeyDown={onKeyDown}\n        {...props}\n      >\n        <LevelContext.Provider value={1}>\n          {items\n            ? items.map((node) => <TreeNodeRenderer key={node.id} node={node} />)\n            : children}\n        </LevelContext.Provider>\n      </div>\n    </TreeContext.Provider>\n  )\n}\n\nfunction TreeNodeRenderer({ node }: { node: TreeNode }) {\n  const hasChildren = !!node.children?.length\n  return (\n    <TreeItem value={node.id} disabled={node.disabled}>\n      <TreeItemLabel icon={node.icon} trailing={node.badge}>\n        {node.label}\n      </TreeItemLabel>\n      {hasChildren ? (\n        <TreeItemGroup>\n          {node.children!.map((child) => (\n            <TreeNodeRenderer key={child.id} node={child} />\n          ))}\n        </TreeItemGroup>\n      ) : null}\n    </TreeItem>\n  )\n}\n\nfunction TreeItem({\n  value,\n  disabled = false,\n  children,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"value\"> & {\n  value: string\n  disabled?: boolean\n}) {\n  const level = React.useContext(LevelContext)\n  const { expanded, toggleExpanded } = useTree()\n  const open = expanded.has(value)\n  const hasChildren = React.Children.toArray(children).some(\n    (child) => React.isValidElement(child) && child.type === TreeItemGroup\n  )\n\n  const itemContext = React.useMemo<ItemContextValue>(\n    () => ({ value, level, disabled, hasChildren, open }),\n    [value, level, disabled, hasChildren, open]\n  )\n\n  if (!hasChildren) {\n    return (\n      <ItemContext.Provider value={itemContext}>\n        <div role=\"none\" data-slot=\"tree-item\" className={className} {...props}>\n          {children}\n        </div>\n      </ItemContext.Provider>\n    )\n  }\n\n  return (\n    <ItemContext.Provider value={itemContext}>\n      <CollapsiblePrimitive.Root\n        open={open}\n        onOpenChange={(next) => toggleExpanded(value, next)}\n        render={\n          <div role=\"none\" data-slot=\"tree-item\" className={className} />\n        }\n        {...props}\n      >\n        {children}\n      </CollapsiblePrimitive.Root>\n    </ItemContext.Provider>\n  )\n}\n\nfunction TreeItemLabel({\n  icon,\n  trailing,\n  children,\n  className,\n  onClick,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  icon?: React.ReactNode\n  trailing?: React.ReactNode\n}) {\n  const { value, level, disabled, hasChildren, open } = useItem()\n  const {\n    selected,\n    selectionMode,\n    activeId,\n    setActiveId,\n    toggleExpanded,\n    select,\n  } = useTree()\n  const isSelected = selected.has(value)\n  const isActive = activeId === value\n  const labelText = typeof children === \"string\" ? children : undefined\n\n  return (\n    <div\n      role=\"treeitem\"\n      aria-level={level}\n      aria-expanded={hasChildren ? open : undefined}\n      aria-selected={selectionMode !== \"none\" ? isSelected : undefined}\n      aria-disabled={disabled || undefined}\n      data-slot=\"tree-item-label\"\n      data-value={value}\n      data-level={level}\n      data-haschildren={hasChildren}\n      data-disabled={disabled}\n      data-label={labelText}\n      data-selected={isSelected || undefined}\n      tabIndex={disabled ? -1 : isActive ? 0 : -1}\n      onFocus={() => setActiveId(value)}\n      onClick={(event) => {\n        onClick?.(event)\n        if (disabled) return\n        setActiveId(value)\n        select(value)\n        if (hasChildren) toggleExpanded(value)\n      }}\n      className={cn(\n        \"group/tree-row relative flex cursor-pointer items-center gap-1.5 rounded-lg py-1.5 pr-2 pl-1.5 text-sm text-foreground outline-none transition-colors select-none\",\n        \"hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/25\",\n        isSelected &&\n          \"bg-secondary font-medium text-secondary-foreground hover:bg-secondary\",\n        disabled && \"pointer-events-none opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {hasChildren ? (\n        <CollapsiblePrimitive.Trigger\n          tabIndex={-1}\n          aria-label={open ? \"Collapse\" : \"Expand\"}\n          onClick={(event) => event.stopPropagation()}\n          className=\"group/tree-chevron flex size-5 shrink-0 items-center justify-center rounded-md text-fg-quaternary transition-colors hover:bg-black/5 dark:hover:bg-white/10\"\n        >\n          <ChevronRight className=\"size-4 transition-transform duration-200 group-data-[panel-open]/tree-chevron:rotate-90\" />\n        </CollapsiblePrimitive.Trigger>\n      ) : (\n        <span aria-hidden className=\"size-5 shrink-0\" />\n      )}\n      {icon ? (\n        <span className=\"flex size-4 shrink-0 items-center justify-center text-fg-quaternary [&_svg]:size-4\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n      {trailing ? (\n        <span\n          className=\"ml-auto flex shrink-0 items-center gap-1\"\n          onClick={(event) => event.stopPropagation()}\n        >\n          {trailing}\n        </span>\n      ) : null}\n    </div>\n  )\n}\n\nfunction TreeItemGroup({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  const { level } = useItem()\n  const { showGuides } = useTree()\n\n  return (\n    <CollapsiblePrimitive.Panel\n      data-slot=\"tree-group-panel\"\n      className=\"h-(--collapsible-panel-height) overflow-hidden data-ending-style:h-0 data-starting-style:h-0 motion-safe:transition-[height] motion-safe:duration-200 motion-safe:ease-out\"\n    >\n      <LevelContext.Provider value={level + 1}>\n        <div\n          role=\"group\"\n          data-slot=\"tree-group\"\n          className={cn(\n            \"relative ml-3.5 flex flex-col gap-0.5 pt-0.5 pl-2\",\n            showGuides && \"border-l border-border\",\n            className\n          )}\n          {...props}\n        >\n          {children}\n        </div>\n      </LevelContext.Provider>\n    </CollapsiblePrimitive.Panel>\n  )\n}\n\nexport { Tree, TreeItem, TreeItemLabel, TreeItemGroup }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}