{"$schema":"https://ui.logic2b.com/schema/registry-item.json","name":"tree-view","type":"registry:ui","description":"A hierarchical tree for browsing nested items (files, folders, categories) following the WAI-ARIA tree pattern: expand/collapse, single selection, roving focus and full keyboard navigation. Parts: TreeView (root, expanded/selected state), TreeItem (value, label, icon; nest items to make a branch).","dependencies":["lucide-react"],"registryDependencies":["utils"],"files":[{"path":"ui/tree-view.tsx","type":"registry:ui","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRightIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Tree view — WAI-ARIA tree pattern, hand-rolled (no Radix primitive exists).\n * Branch/leaf is inferred from children; expansion and selection are\n * controllable. Keyboard: arrows move through visible items, right/left\n * expand/collapse (left also jumps to the parent), Home/End, Enter/Space\n * select.\n */\n\nfunction useControllableState<T>(\n  prop: T | undefined,\n  defaultProp: T,\n  onChange?: (value: T) => void\n) {\n  const [state, setState] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : state\n  const set = React.useCallback(\n    (next: T) => {\n      if (!isControlled) setState(next)\n      onChange?.(next)\n    },\n    [isControlled, onChange]\n  )\n  return [value, set] as const\n}\n\ninterface TreeViewContextValue {\n  expanded: string[]\n  toggle: (value: string) => void\n  selected: string | null\n  select: (value: string) => void\n}\n\nconst TreeViewContext = React.createContext<TreeViewContextValue | null>(null)\nconst TreeLevelContext = React.createContext(1)\n\nfunction useTreeView() {\n  const ctx = React.useContext(TreeViewContext)\n  if (!ctx) throw new Error(\"TreeItem must be used within a TreeView.\")\n  return ctx\n}\n\nconst visibleItems = (tree: HTMLElement) =>\n  Array.from(\n    tree.querySelectorAll<HTMLElement>(\n      '[role=\"treeitem\"]:not([aria-disabled=\"true\"])'\n    )\n  )\n\nfunction TreeView({\n  className,\n  children,\n  expanded: expandedProp,\n  defaultExpanded,\n  onExpandedChange,\n  selected: selectedProp,\n  defaultSelected,\n  onSelectedChange,\n  ...props\n}: Omit<React.ComponentProps<\"ul\">, \"onSelect\"> & {\n  expanded?: string[]\n  defaultExpanded?: string[]\n  onExpandedChange?: (expanded: string[]) => void\n  selected?: string | null\n  defaultSelected?: string | null\n  onSelectedChange?: (selected: string | null) => void\n}) {\n  const ref = React.useRef<HTMLUListElement>(null)\n  const [hasFocusWithin, setHasFocusWithin] = React.useState(false)\n  const [expanded, setExpanded] = useControllableState(\n    expandedProp,\n    defaultExpanded ?? [],\n    onExpandedChange\n  )\n  const [selected, setSelected] = useControllableState<string | null>(\n    selectedProp,\n    defaultSelected ?? null,\n    onSelectedChange\n  )\n\n  const toggle = React.useCallback(\n    (value: string) =>\n      setExpanded(\n        expanded.includes(value)\n          ? expanded.filter((v) => v !== value)\n          : [...expanded, value]\n      ),\n    [expanded, setExpanded]\n  )\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    const item = (event.target as HTMLElement).closest<HTMLElement>(\n      '[role=\"treeitem\"]'\n    )\n    const tree = ref.current\n    if (!item || !tree) return\n    const items = visibleItems(tree)\n    const index = items.indexOf(item)\n    const isBranch = item.hasAttribute(\"aria-expanded\")\n    const isExpanded = item.getAttribute(\"aria-expanded\") === \"true\"\n    const value = item.dataset.value ?? \"\"\n    const focus = (el?: HTMLElement) => el?.focus()\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        focus(items[index + 1])\n        break\n      case \"ArrowUp\":\n        focus(items[index - 1])\n        break\n      case \"ArrowRight\":\n        if (isBranch && !isExpanded) toggle(value)\n        else if (isBranch) focus(items[index + 1])\n        break\n      case \"ArrowLeft\":\n        if (isBranch && isExpanded) toggle(value)\n        else focus(item.parentElement?.closest<HTMLElement>('[role=\"treeitem\"]') ?? undefined)\n        break\n      case \"Home\":\n        focus(items[0])\n        break\n      case \"End\":\n        focus(items[items.length - 1])\n        break\n      case \"Enter\":\n      case \" \":\n        setSelected(value)\n        if (isBranch) toggle(value)\n        break\n      default:\n        return\n    }\n    event.preventDefault()\n  }\n\n  return (\n    <TreeViewContext.Provider value={{ expanded, toggle, selected, select: setSelected }}>\n      <ul\n        ref={ref}\n        role=\"tree\"\n        data-slot=\"tree-view\"\n        tabIndex={hasFocusWithin ? -1 : 0}\n        className={cn(\n          \"flex flex-col gap-px text-sm outline-none select-none\",\n          className\n        )}\n        onFocus={(event) => {\n          setHasFocusWithin(true)\n          if (event.target !== ref.current) return\n          const tree = ref.current\n          const target =\n            tree.querySelector<HTMLElement>('[role=\"treeitem\"][aria-selected=\"true\"]') ??\n            visibleItems(tree)[0]\n          target?.focus()\n        }}\n        onBlur={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget)) {\n            setHasFocusWithin(false)\n          }\n        }}\n        onKeyDown={onKeyDown}\n        {...props}\n      >\n        {children}\n      </ul>\n    </TreeViewContext.Provider>\n  )\n}\n\nfunction TreeItem({\n  className,\n  children,\n  value,\n  label,\n  icon,\n  disabled,\n  ...props\n}: Omit<React.ComponentProps<\"li\">, \"onSelect\"> & {\n  value: string\n  label: React.ReactNode\n  icon?: React.ReactNode\n  disabled?: boolean\n}) {\n  const { expanded, toggle, selected, select } = useTreeView()\n  const level = React.useContext(TreeLevelContext)\n  const isBranch = React.Children.count(children) > 0\n  const isExpanded = isBranch && expanded.includes(value)\n  const isSelected = selected === value\n\n  return (\n    <li\n      role=\"treeitem\"\n      data-slot=\"tree-item\"\n      data-value={value}\n      aria-level={level}\n      aria-selected={isSelected}\n      aria-expanded={isBranch ? isExpanded : undefined}\n      aria-disabled={disabled || undefined}\n      tabIndex={-1}\n      className={cn(\"group/tree-item outline-none\", className)}\n      {...props}\n    >\n      <div\n        data-slot=\"tree-item-row\"\n        className={cn(\n          \"hover:bg-accent hover:text-accent-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors\",\n          \"group-focus-visible/tree-item:border-ring group-focus-visible/tree-item:ring-ring/50 group-focus-visible/tree-item:ring-[3px]\",\n          isSelected && \"bg-accent text-accent-foreground\",\n          disabled && \"pointer-events-none opacity-50\"\n        )}\n        style={{ paddingLeft: `calc(${level - 1} * 1rem + 0.5rem)` }}\n        onClick={() => {\n          if (disabled) return\n          select(value)\n          if (isBranch) toggle(value)\n        }}\n      >\n        {isBranch ? (\n          <ChevronRightIcon\n            data-slot=\"tree-item-chevron\"\n            className={cn(\n              \"text-muted-foreground size-4 shrink-0 transition-transform duration-200\",\n              isExpanded && \"rotate-90\"\n            )}\n            onClick={(event) => {\n              event.stopPropagation()\n              if (!disabled) toggle(value)\n            }}\n          />\n        ) : (\n          <span className=\"size-4 shrink-0\" />\n        )}\n        {icon && (\n          <span\n            data-slot=\"tree-item-icon\"\n            className=\"text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0\"\n          >\n            {icon}\n          </span>\n        )}\n        <span data-slot=\"tree-item-label\" className=\"truncate\">\n          {label}\n        </span>\n      </div>\n      {isExpanded && (\n        <ul role=\"group\" data-slot=\"tree-item-group\" className=\"flex flex-col gap-px\">\n          <TreeLevelContext.Provider value={level + 1}>\n            {children}\n          </TreeLevelContext.Provider>\n        </ul>\n      )}\n    </li>\n  )\n}\n\nexport { TreeView, TreeItem }\n"}],"categories":["data-display"],"accessibility":{"support":"authored","pattern":"tree","keyboard":[{"keys":["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"],"action":"Move within the composite control."},{"keys":["Home","End"],"action":"Move to the first or last visible node."},{"keys":["Enter","Space"],"action":"Select or activate the focused node."}],"aria":["Uses tree/treeitem/group roles with aria-expanded, aria-selected, level and set position metadata."],"consumer":["Give the tree a label and ensure node actions are distinguishable from expand/collapse."]},"api":{"source":"src/ui/tree-view.tsx","exports":[{"name":"TreeView","kind":"component","propsType":"Omit<React.ComponentProps<\"ul\">, \"onSelect\"> & { expanded?: string[] defaultExpanded?: string[] onExpandedChange?: (expanded: string[]) => void selected?: string | null defaultSelected?: string | null onSelectedChange?: (selected: string | null) => void }","props":[{"name":"expanded","type":"string[]","required":false},{"name":"defaultExpanded","type":"string[]","required":false},{"name":"onExpandedChange","type":"(expanded: string[]) => void","required":false},{"name":"selected","type":"string | null","required":false},{"name":"defaultSelected","type":"string | null","required":false},{"name":"onSelectedChange","type":"(selected: string | null) => void","required":false}]},{"name":"TreeItem","kind":"component","propsType":"Omit<React.ComponentProps<\"li\">, \"onSelect\"> & { value: string label: React.ReactNode icon?: React.ReactNode disabled?: boolean }","props":[{"name":"value","type":"string","required":true},{"name":"label","type":"React.ReactNode","required":true},{"name":"icon","type":"React.ReactNode","required":false},{"name":"disabled","type":"boolean","required":false}]}]}}