{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree",
  "type": "registry:ui",
  "title": "Tree",
  "description": "Things inside things, opened a level at a time: a project's files, a docs site's pages. Indented rows with a hairline guide down each level, the selected row in the selected segment's look, and the keys of a file tree.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/tree.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/tree 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Tree: things inside things, opened a level at a time: a project's files, a docs site's pages, folders of assets\n * (components.md, \"Tree\"). The WAI-ARIA tree pattern, from data: pass `items` and the tree draws and wires itself.\n *\n * - Rows: 32px (44px on a touch screen) at the control radius, indented 16px a level. A 16px chevron for a branch, an\n *   icon (a folder or a file unless the item brings its own), the label at the small size, truncated, and an optional\n *   detail at the end in tabular figures at the caption size in --litt-fg-subtle (a size, a change mark). A 1px --litt-line guide runs\n *   down each level, under the chevrons, so the nesting reads without color.\n * - Hover fills fg 6% on the instant tier. The selected row is raised (R3, selected is raised): the press material's\n *   block (--litt-block, --litt-block-lift) on whatever the tree sits on, and its label in --litt-fg at the title weight. Never the\n *   accent. A pressed row on a touch screen takes the hover fill.\n * - Opening: the chevron turns a quarter on the responsive tier. The children appear in place, at once; no height\n *   animates, as in the Accordion. A closed branch does not render its children.\n * - Keys (one Tab stop, on the selected row or the first): ↑ ↓ move through the open rows, → opens a branch or goes\n *   to its first child, ← closes it or goes to the parent, Home and End go to the ends, Enter and Space select (and\n *   open or close a branch), * opens every branch beside the focused one, and typing a name's first letters jumps to it.\n * - Accessibility: role=\"tree\" named by `label`, each row a treeitem with aria-level, aria-setsize, aria-posinset,\n *   aria-expanded on branches and aria-selected, and children in a group. Focus shows the 2px ring inside the row.\n * - Flat: no level guides; the selected row is a grey bubble and a row takes one on hover.\n */\nimport { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport { ChevronRightIcon, FileIcon, FolderIcon } from \"./icons\";\n\nexport type TreeNode = {\n  /** Unique in the tree. */\n  id: string;\n  /** The row's text. Typing its first letters jumps to it. */\n  label: string;\n  /** A 16px icon in place of the folder or file. */\n  icon?: ReactNode;\n  /** A short note at the end of the row: a size, a count, a change mark (\"M\", \"+12\"). */\n  detail?: ReactNode;\n  /** The items inside it. An item with children, even an empty list, is a branch that opens. */\n  children?: TreeNode[];\n};\n\ntype Row = { node: TreeNode; level: number; parent: string | null; size: number; pos: number };\n\n/** The rows that show, in order: every item whose ancestors are all open. */\nfunction visibleRows(items: TreeNode[], open: Set<string>, level = 1, parent: string | null = null, out: Row[] = []): Row[] {\n  items.forEach((node, i) => {\n    out.push({ node, level, parent, size: items.length, pos: i + 1 });\n    if (node.children && open.has(node.id)) visibleRows(node.children, open, level + 1, node.id, out);\n  });\n  return out;\n}\n\nconst ROW =\n  \"relative flex h-8 min-w-0 cursor-pointer items-center gap-2 rounded-litt-control pr-2 text-litt-small select-none [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-[background-color,color] duration-(--litt-dur-instant) ease-litt pointer-coarse:h-11\";\n\n/* The focus ring sits on the focused item's own row only: items nest, so a group-focus-visible ring would also light\n   every row inside an open branch. The row is the item's one direct div; its children are in a ul. */\nconst ITEM_FOCUS = \"[&:focus-visible>div]:outline-2 [&:focus-visible>div]:-outline-offset-2 [&:focus-visible>div]:outline-litt-focus\";\n\nconst IDLE =\n  \"text-litt-fg-muted hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] hover:text-litt-fg pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] in-[[data-material=flat]]:hover:bg-litt-surface-3\";\n\n/* Selected is raised (R3): the press material's block, the same on the canvas and on a surface. */\nconst SELECTED = {\n  canvas: \"bg-litt-block font-litt-title text-litt-fg shadow-(--litt-block-lift) forced-colors:border\",\n  \"surface-1\": \"bg-litt-block font-litt-title text-litt-fg shadow-(--litt-block-lift) forced-colors:border\",\n} as const;\n\nconst INDENT = 16; // px per level\nconst START = 8; // px before the first level's chevron\n\nexport type TreeProps = {\n  /** What the tree holds, for screen readers: \"Files\", \"Pages\". */\n  label: string;\n  /** The items at the top level. */\n  items: TreeNode[];\n  /** The open branches. Pair it with onExpandedChange. Leave it out and the tree keeps its own. */\n  expanded?: string[];\n  /** The branches open to start with, when you do not control them. */\n  defaultExpanded?: string[];\n  /** Called with every open branch whenever one opens or closes. */\n  onExpandedChange?: (expanded: string[]) => void;\n  /** The selected item. Pair it with onSelect. Leave it out and the tree keeps its own. */\n  selected?: string | null;\n  /** The item selected to start with, when you do not control it. */\n  defaultSelected?: string | null;\n  /** Called when a row is chosen by a click, Enter or Space, with the item. */\n  onSelect?: (id: string, node: TreeNode) => void;\n  /** What the tree sits on, so the selected row is one surface up from it: the canvas, or a surface-1 panel. */\n  on?: \"canvas\" | \"surface-1\";\n  /** Extra classes for the tree, such as a width. */\n  className?: string;\n};\n\n/** Things inside things, opened a level at a time, with the keys of a file tree. Pass the items as data. */\nexport function Tree({\n  label,\n  items,\n  expanded: expandedProp,\n  defaultExpanded = [],\n  onExpandedChange,\n  selected: selectedProp,\n  defaultSelected = null,\n  onSelect,\n  on = \"canvas\",\n  className = \"\",\n}: TreeProps) {\n  const [innerOpen, setInnerOpen] = useState<string[]>(defaultExpanded);\n  const openList = expandedProp ?? innerOpen;\n  const open = useMemo(() => new Set(openList), [openList]);\n  const [innerSelected, setInnerSelected] = useState<string | null>(defaultSelected);\n  const selected = selectedProp !== undefined ? selectedProp : innerSelected;\n\n  const rows = useMemo(() => visibleRows(items, open), [items, open]);\n  const [focusId, setFocusId] = useState<string | null>(null);\n  /* The one Tab stop: the focused row if it still shows, else the selected one, else the first. */\n  const stop = rows.find((r) => r.node.id === focusId)?.node.id ?? rows.find((r) => r.node.id === selected)?.node.id ?? rows[0]?.node.id;\n  const tree = useRef<HTMLUListElement>(null);\n\n  const setOpen = (next: string[]) => {\n    if (expandedProp === undefined) setInnerOpen(next);\n    onExpandedChange?.(next);\n  };\n  const toggle = (id: string, to = !open.has(id)) => {\n    if (to === open.has(id)) return;\n    setOpen(to ? [...openList, id] : openList.filter((x) => x !== id));\n  };\n  const choose = (node: TreeNode) => {\n    if (selectedProp === undefined) setInnerSelected(node.id);\n    onSelect?.(node.id, node);\n  };\n  const focus = (id: string | undefined) => {\n    if (!id) return;\n    setFocusId(id);\n    tree.current?.querySelector<HTMLElement>(`[data-tree-id=\"${CSS.escape(id)}\"]`)?.focus();\n  };\n\n  /* Typing a name's first letters jumps to the next row that starts with them. */\n  const typed = useRef({ text: \"\", at: 0 });\n\n  const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {\n    const target = (e.target as HTMLElement).closest<HTMLElement>(\"[data-tree-id]\");\n    if (!target || e.altKey || e.ctrlKey || e.metaKey) return;\n    const i = rows.findIndex((r) => r.node.id === target.dataset.treeId);\n    const row = rows[i];\n    if (!row) return;\n    const branch = row.node.children !== undefined;\n    const isOpen = open.has(row.node.id);\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const inKey = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const outKey = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n\n    if (e.key === \"ArrowDown\") focus(rows[i + 1]?.node.id);\n    else if (e.key === \"ArrowUp\") focus(rows[i - 1]?.node.id);\n    else if (e.key === \"Home\") focus(rows[0]?.node.id);\n    else if (e.key === \"End\") focus(rows[rows.length - 1]?.node.id);\n    else if (e.key === inKey) {\n      if (branch && !isOpen) toggle(row.node.id, true);\n      else if (branch && row.node.children!.length) focus(rows[i + 1]?.node.id);\n    } else if (e.key === outKey) {\n      if (branch && isOpen) toggle(row.node.id, false);\n      else focus(row.parent ?? undefined);\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      choose(row.node);\n      if (branch) toggle(row.node.id);\n    } else if (e.key === \"*\") {\n      const siblings = rows.filter((r) => r.parent === row.parent && r.node.children !== undefined).map((r) => r.node.id);\n      setOpen([...new Set([...openList, ...siblings])]);\n    } else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      const now = performance.now();\n      typed.current = { text: (now - typed.current.at < 500 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      const q = typed.current.text;\n      const order = [...rows.slice(i + (q.length === 1 ? 1 : 0)), ...rows.slice(0, i + (q.length === 1 ? 1 : 0))];\n      focus(order.find((r) => r.node.label.toLowerCase().startsWith(q))?.node.id);\n    } else return;\n    e.preventDefault();\n  };\n\n  const render = (nodes: TreeNode[], level: number): ReactNode =>\n    nodes.map((node, i) => {\n      const branch = node.children !== undefined;\n      const isOpen = branch && open.has(node.id);\n      const isSelected = node.id === selected;\n      return (\n        <li\n          key={node.id}\n          role=\"treeitem\"\n          data-tree-id={node.id}\n          aria-level={level}\n          aria-setsize={nodes.length}\n          aria-posinset={i + 1}\n          aria-expanded={branch ? isOpen : undefined}\n          aria-selected={isSelected}\n          tabIndex={node.id === stop ? 0 : -1}\n          onFocus={(e) => {\n            if (e.target === e.currentTarget) setFocusId(node.id);\n          }}\n          className={\"outline-none \" + ITEM_FOCUS}\n        >\n          <div\n            className={ROW + \" \" + (isSelected ? SELECTED[on] : IDLE)}\n            style={{ paddingInlineStart: START + (level - 1) * INDENT }}\n            onClick={() => {\n              focus(node.id);\n              choose(node);\n              if (branch) toggle(node.id);\n            }}\n          >\n            {/* One guide per level above this one, under that level's chevrons. */}\n            {Array.from({ length: level - 1 }, (_, l) => (\n              <span\n                key={l}\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-y-0 w-px bg-litt-line in-[[data-material=flat]]:hidden\"\n                style={{ insetInlineStart: START + l * INDENT + 7.5 }}\n              />\n            ))}\n            <span aria-hidden=\"true\" className=\"grid size-4 shrink-0 place-items-center text-litt-fg-subtle\">\n              {branch && (\n                <ChevronRightIcon\n                  className={\n                    \"transition-[rotate] duration-(--litt-dur-responsive) ease-litt-responsive rtl:-scale-x-100 \" + (isOpen ? \"rotate-90 rtl:-rotate-90\" : \"\")\n                  }\n                />\n              )}\n            </span>\n            <span aria-hidden=\"true\" className={\"grid size-4 shrink-0 place-items-center \" + (isSelected ? \"text-litt-fg\" : \"text-litt-fg-subtle\")}>\n              {node.icon ?? (branch ? <FolderIcon /> : <FileIcon />)}\n            </span>\n            <span className=\"min-w-0 flex-1 truncate\">{node.label}</span>\n            {node.detail !== undefined && (\n              <span className=\"shrink-0 text-litt-caption font-normal text-litt-fg-subtle tabular-nums\">{node.detail}</span>\n            )}\n          </div>\n          {isOpen && node.children!.length > 0 && (\n            <ul role=\"group\" className=\"m-0 list-none p-0\">\n              {render(node.children!, level + 1)}\n            </ul>\n          )}\n        </li>\n      );\n    });\n\n  return (\n    <ul ref={tree} role=\"tree\" aria-label={label} onKeyDown={onKeyDown} className={\"m-0 min-w-0 list-none p-0 \" + className}>\n      {render(items, 1)}\n    </ul>\n  );\n}\n",
      "target": "components/litt/tree.tsx"
    }
  ],
  "docs": "Import Tree and TreeNode from components/litt/tree. Pass a label and items as data ({ id, label, detail, children }); an item with children is a branch. Hold expanded and selected in state, or leave them to the tree.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
