{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:ui","title":"Tree View","description":"A nested list of folders and files or any hierarchy – branches that open with a chevron and grow in height, indent guides, one selected row, icons and quiet meta, arrow keys and type-ahead as in the WAI-ARIA tree","dependencies":[],"files":[{"path":"registry/tree-view/tree-view.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\n\n/*\n * Tree View – a nested list of folders and files, or any hierarchy, in a single self-contained file.\n * A chevron opens and closes a branch, which grows and shrinks in height; thin guides show which level a row is on.\n * One row can be selected (onSelect). Follows the WAI-ARIA tree pattern: one tab stop, ↑ ↓ move, → opens or goes to\n * the first child, ← closes or goes to the parent, Home / End, * opens every sibling, and typing a name jumps to it.\n * The open branches can be controlled (expanded + onExpandedChange) or left to the tree (defaultExpanded).\n * 32px rows with soft gray glyphs; the selected row gets a gray fill and a firmer name.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type TreeNode = {\n  /** Unique in the whole tree */\n  id: string;\n  /** The name – one line, long ones end in … with the full name on hover; also what typing jumps to */\n  label: string;\n  /** A glyph before the name, e.g. a folder or file icon (16px) */\n  icon?: ReactNode;\n  /** A glyph for while the branch is open, e.g. an open folder – falls back to icon */\n  openIcon?: ReactNode;\n  /** Quiet text at the end of the row, e.g. a count or a size */\n  meta?: ReactNode;\n  disabled?: boolean;\n  /** Its children – any list (even an empty one) makes it a branch that opens and closes */\n  children?: TreeNode[];\n};\n\nexport type TreeViewProps = {\n  items: TreeNode[];\n  /** Controlled: the ids of the open branches */\n  expanded?: string[];\n  /** Uncontrolled: the branches open at the start */\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  /** Controlled: the id of the selected row, or null for none */\n  selected?: string | null;\n  /** Uncontrolled: the row selected at the start */\n  defaultSelected?: string | null;\n  /** A row was picked – by a click, Enter or Space */\n  onSelect?: (id: string, node: TreeNode) => void;\n  /** Thin vertical lines that show each level – on by default */\n  guides?: boolean;\n  /** Shown inside an open branch without children */\n  emptyText?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n};\n\n/** A node with what the tree works out about it once: its level, parent, place among its siblings */\ntype Entry = { node: TreeNode; id: string; n: number; depth: number; parent: string | null; pos: number; size: number; kids: Entry[] | null };\n\nexport function TreeView({\n  items,\n  expanded: expandedProp,\n  defaultExpanded,\n  onExpandedChange,\n  selected: selectedProp,\n  defaultSelected = null,\n  onSelect,\n  guides = true,\n  emptyText = \"Empty\",\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  className,\n}: TreeViewProps) {\n  const uid = useId();\n  const root = useRef<HTMLUListElement>(null);\n  const els = useRef(new Map<string, HTMLLIElement>());\n\n  // Gaps and repeated ids drop out – one id, one row (and no endless loop if a node holds itself)\n  const { roots, byId } = useMemo(() => normalize(items), [items]);\n\n  const [innerOpen, setInnerOpen] = useState<string[]>(() => (Array.isArray(defaultExpanded) ? defaultExpanded : []));\n  const openList = expandedProp !== undefined ? (Array.isArray(expandedProp) ? expandedProp : []) : innerOpen;\n  const open = useMemo(() => new Set(openList.map(String)), [openList]);\n\n  const [innerSel, setInnerSel] = useState<string | null>(defaultSelected);\n  const selected = selectedProp !== undefined ? selectedProp : innerSel;\n\n  const [focused, setFocused] = useState<string | null>(null);\n  // Branches that were open once keep their rows, so closing (and opening again) can animate\n  const seen = useRef(new Set<string>());\n\n  // The rows you can see, top to bottom\n  const visible = useMemo(() => {\n    const out: Entry[] = [];\n    const walk = (list: Entry[]) => {\n      for (const e of list) {\n        out.push(e);\n        if (e.kids && open.has(e.id)) walk(e.kids);\n      }\n    };\n    walk(roots);\n    return out;\n  }, [roots, open]);\n  const nav = visible.filter((e) => !e.node.disabled);\n\n  const setOpen = (id: string, to: boolean) => {\n    if (open.has(id) === to) return;\n    const next = to ? [...openList, id] : openList.filter((x) => String(x) !== id);\n    if (expandedProp === undefined) setInnerOpen(next);\n    onExpandedChange?.(next);\n  };\n  const openMany = (ids: string[]) => {\n    const add = ids.filter((id) => !open.has(id));\n    if (!add.length) return;\n    const next = [...openList, ...add];\n    if (expandedProp === undefined) setInnerOpen(next);\n    onExpandedChange?.(next);\n  };\n  const select = (e: Entry) => {\n    if (e.node.disabled) return;\n    if (selectedProp === undefined) setInnerSel(e.id);\n    onSelect?.(e.id, e.node);\n  };\n  const focusId = (id: string | undefined) => {\n    if (id) els.current.get(id)?.focus();\n  };\n\n  // One tab stop: the row last focused, else the selected one, else the first\n  const stop = (nav.find((e) => e.id === focused) ?? nav.find((e) => e.id === selected) ?? nav[0])?.id;\n\n  // A branch closed around the focused row (e.g. from outside): the focus moves up to the nearest row still shown\n  useEffect(() => {\n    const active = document.activeElement as HTMLElement | null;\n    if (!active || !root.current?.contains(active) || active.getAttribute(\"role\") !== \"treeitem\") return;\n    let id: string | null | undefined = active.dataset.id;\n    const shown = new Set(visible.map((e) => e.id));\n    if (id && shown.has(id)) return;\n    while (id && !shown.has(id)) id = byId.get(id)?.parent;\n    if (id) focusId(id);\n  }, [visible, byId]);\n\n  // Typing a name jumps to the next row that starts with it\n  const typed = useRef({ text: \"\", at: 0 });\n  const typeAhead = (key: string, from: number) => {\n    const now = Date.now();\n    const t = typed.current;\n    t.text = now - t.at > 700 ? key : t.text + key;\n    t.at = now;\n    const q = t.text.toLocaleLowerCase();\n    // The same letter again and again steps through the rows with that letter\n    const same = [...q].every((c) => c === q[0]);\n    const needle = same ? q[0] : q;\n    const start = same ? from + 1 : from;\n    for (let k = 0; k < nav.length; k++) {\n      const e = nav[(start + k + nav.length) % nav.length];\n      if (String(e.node.label ?? \"\").trimStart().toLocaleLowerCase().startsWith(needle)) return focusId(e.id);\n    }\n  };\n\n  const onKeyDown = (ev: KeyboardEvent<HTMLUListElement>) => {\n    const el = ev.target as HTMLElement;\n    if (el.getAttribute(\"role\") !== \"treeitem\" || ev.ctrlKey || ev.metaKey || ev.altKey) return;\n    const e = byId.get(el.dataset.id ?? \"\");\n    if (!e) return;\n    const at = nav.indexOf(e);\n    // In right-to-left pages → closes and ← opens\n    const rtl = root.current ? getComputedStyle(root.current).direction === \"rtl\" : false;\n    const inward = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const outward = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    const isOpen = e.kids !== null && open.has(e.id);\n    let done = true;\n    switch (ev.key) {\n      case \"ArrowDown\":\n        focusId(nav[at + 1]?.id);\n        break;\n      case \"ArrowUp\":\n        focusId(at > 0 ? nav[at - 1].id : undefined);\n        break;\n      case \"Home\":\n        focusId(nav[0]?.id);\n        break;\n      case \"End\":\n        focusId(nav[nav.length - 1]?.id);\n        break;\n      case inward:\n        if (e.kids && !e.node.disabled) {\n          if (!isOpen) setOpen(e.id, true);\n          else focusId(e.kids.find((k) => !k.node.disabled)?.id);\n        }\n        break;\n      case outward:\n        if (isOpen) setOpen(e.id, false);\n        else focusId(e.parent ?? undefined);\n        break;\n      case \"Enter\":\n        select(e);\n        if (e.kids && !e.node.disabled) setOpen(e.id, !isOpen);\n        break;\n      case \" \":\n        select(e);\n        break;\n      case \"*\": {\n        const siblings = e.parent ? byId.get(e.parent)?.kids ?? [] : roots;\n        openMany(siblings.filter((s) => s.kids && !s.node.disabled).map((s) => s.id));\n        break;\n      }\n      default:\n        if (ev.key.length === 1 && /\\S/.test(ev.key)) typeAhead(ev.key, at);\n        else done = false;\n    }\n    if (done) ev.preventDefault();\n  };\n\n  const level = (list: Entry[]): ReactNode =>\n    list.map((e) => {\n      const branch = e.kids !== null;\n      const isOpen = branch && open.has(e.id);\n      if (isOpen) seen.current.add(e.id);\n      const disabled = !!e.node.disabled;\n      const labelId = `${uid}-l${e.n}`;\n      const metaId = `${uid}-m${e.n}`;\n      const icon = isOpen && e.node.openIcon != null ? e.node.openIcon : e.node.icon;\n      return (\n        <li\n          key={e.id}\n          ref={(el) => {\n            if (el) els.current.set(e.id, el);\n            else els.current.delete(e.id);\n          }}\n          role=\"treeitem\"\n          className=\"ui-tv__item\"\n          data-id={e.id}\n          aria-level={e.depth + 1}\n          aria-posinset={e.pos}\n          aria-setsize={e.size}\n          aria-expanded={branch ? isOpen : undefined}\n          aria-selected={e.id === selected}\n          aria-disabled={disabled || undefined}\n          aria-labelledby={labelId}\n          aria-describedby={e.node.meta != null ? metaId : undefined}\n          tabIndex={e.id === stop ? 0 : -1}\n          onFocus={(ev) => {\n            if (ev.target === ev.currentTarget) setFocused(e.id);\n          }}\n        >\n          <div\n            className=\"ui-tv__row\"\n            style={{ [\"--ui-tv-d\" as string]: e.depth }}\n            // A disabled row takes no focus from a press – the focus stays where it was\n            onMouseDown={disabled ? (ev) => ev.preventDefault() : undefined}\n            onClick={() => {\n              if (disabled) return;\n              select(e);\n              if (branch) setOpen(e.id, !isOpen);\n            }}\n          >\n            <span\n              className=\"ui-tv__chev\"\n              aria-hidden=\"true\"\n              onClick={\n                branch\n                  ? (ev) => {\n                      // The chevron only opens and closes – it doesn't select\n                      ev.stopPropagation();\n                      if (!disabled) setOpen(e.id, !isOpen);\n                    }\n                  : undefined\n              }\n            >\n              {branch && <Chevron />}\n            </span>\n            {icon != null && (\n              <span className=\"ui-tv__icon\" aria-hidden=\"true\">\n                {icon}\n              </span>\n            )}\n            <span className=\"ui-tv__label\" id={labelId} dir=\"auto\" title={e.node.label}>\n              {e.node.label}\n            </span>\n            {e.node.meta != null && (\n              <span className=\"ui-tv__meta\" id={metaId} dir=\"auto\">\n                {e.node.meta}\n              </span>\n            )}\n          </div>\n          {branch && (\n            <div className=\"ui-tv__fold\" data-open={isOpen || undefined}>\n              <ul role=\"group\" className=\"ui-tv__group\" style={{ [\"--ui-tv-d\" as string]: e.depth + 1 }}>\n                {(isOpen || seen.current.has(e.id)) &&\n                  (e.kids!.length ? (\n                    level(e.kids!)\n                  ) : (\n                    <li role=\"none\" className=\"ui-tv__empty\" style={{ [\"--ui-tv-d\" as string]: e.depth + 1 }}>\n                      {emptyText}\n                    </li>\n                  ))}\n              </ul>\n            </div>\n          )}\n        </li>\n      );\n    });\n\n  return (\n    <ul\n      ref={root}\n      role=\"tree\"\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      className={[\"ui-tv\", className].filter(Boolean).join(\" \")}\n      data-guides={guides || undefined}\n      onKeyDown={onKeyDown}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many trees render */}\n      <style href=\"ui-tree-view\" precedence=\"default\">\n        {css}\n      </style>\n      {level(roots)}\n    </ul>\n  );\n}\n\nfunction normalize(items: TreeNode[]) {\n  const byId = new Map<string, Entry>();\n  let n = 0;\n  const walk = (list: unknown, depth: number, parent: string | null): Entry[] => {\n    const out: Entry[] = [];\n    for (const node of Array.isArray(list) ? (list as TreeNode[]) : []) {\n      if (!node || node.id == null) continue;\n      const id = String(node.id);\n      if (byId.has(id)) continue;\n      const e: Entry = { node, id, n: n++, depth, parent, pos: 0, size: 0, kids: null };\n      byId.set(id, e);\n      out.push(e);\n    }\n    out.forEach((e, i) => {\n      e.pos = i + 1;\n      e.size = out.length;\n      e.kids = Array.isArray(e.node.children) ? walk(e.node.children, depth + 1, e.id) : null;\n    });\n    return out;\n  };\n  return { roots: walk(items, 0, null), byId };\n}\n\n/* Chevron – Hugeicons Free (MIT, © Hugeicons, hugeicons.com), ArrowRight01 */\nconst Chevron = () => (\n  <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n    <path d=\"M9 6c0 0 6 4.42 6 6s-6 6-6 6\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n  </svg>\n);\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-tv (see Theming in the docs) */\n.ui-tv {\n  --ui-tv-ink: var(--aui-ink, #171717);\n  --ui-tv-text: var(--aui-ink, #262626);\n  --ui-tv-icon: var(--aui-muted, #8f8f8f);\n  --ui-tv-meta: var(--aui-muted, #8f8f8f);\n  --ui-tv-faint: var(--aui-placeholder, #a3a3a3);\n  --ui-tv-wash: #f4f4f5;\n  --ui-tv-fill: #ededed;\n  --ui-tv-guide: var(--aui-border, #ececec);\n  --ui-tv-guide-on: #c8c8c8;\n  --ui-tv-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-tv-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  /* Sizes: 32px rows, 18px per level */\n  --ui-tv-h: 32px;\n  --ui-tv-pad: 8px;\n  --ui-tv-indent: 18px;\n  --ui-tv-chev: 16px;\n  --ui-tv-r: calc(var(--aui-radius-sm, 5px) + 3px);\n\n  display: block;\n  box-sizing: border-box;\n  min-width: 0;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  font-weight: 400;\n  line-height: 20px;\n  color: var(--ui-tv-text);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-tv *, .ui-tv *::before, .ui-tv *::after { box-sizing: border-box; }\n.ui-tv__group { position: relative; min-height: 0; margin: 0; padding: 0; list-style: none; overflow: hidden; }\n.ui-tv__item { display: block; margin: 0; padding: 0; }\n\n/* A row: chevron, glyph, name, meta – one fixed height; deeper levels indent (at most to 45% of the width) */\n.ui-tv__row {\n  display: flex;\n  align-items: center;\n  gap: 5px;\n  height: var(--ui-tv-h);\n  padding-inline: calc(var(--ui-tv-pad) + min(var(--ui-tv-d, 0) * var(--ui-tv-indent), 45%)) var(--ui-tv-pad);\n  overflow: hidden;\n  border-radius: var(--ui-tv-r);\n  cursor: pointer;\n  -webkit-user-select: none;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color 90ms ease;\n}\n.ui-tv__item[aria-selected=\"true\"] > .ui-tv__row { background: var(--ui-tv-fill); color: var(--ui-tv-ink); }\n.ui-tv__item[aria-selected=\"true\"] > .ui-tv__row .ui-tv__label { font-weight: 500; }\n@media (hover: hover) {\n  .ui-tv__item:not([aria-selected=\"true\"]):not([aria-disabled=\"true\"]) > .ui-tv__row:hover { background: var(--ui-tv-wash); }\n  .ui-tv__row:hover .ui-tv__chev { color: var(--ui-tv-meta); }\n}\n.ui-tv__item[aria-disabled=\"true\"] > .ui-tv__row { opacity: 0.4; cursor: not-allowed; }\n\n/* The ring sits on the row, inside it – never around the open branch below */\n.ui-tv__item:focus { outline: none; }\n.ui-tv__item:focus-visible > .ui-tv__row { outline: 2px solid var(--ui-tv-focus); outline-offset: -2px; }\n\n.ui-tv__chev {\n  display: grid;\n  flex: none;\n  place-items: center;\n  width: var(--ui-tv-chev);\n  height: var(--ui-tv-chev);\n  color: var(--ui-tv-faint);\n  transition: transform 200ms var(--ui-tv-ease), color 90ms ease;\n}\n.ui-tv__chev svg { display: block; width: 14px; height: 14px; }\n.ui-tv__chev:dir(rtl) svg { transform: scaleX(-1); }\n.ui-tv__item[aria-expanded=\"true\"] > .ui-tv__row > .ui-tv__chev { transform: rotate(90deg); }\n.ui-tv__item[aria-expanded=\"true\"] > .ui-tv__row > .ui-tv__chev:dir(rtl) { transform: rotate(-90deg); }\n\n.ui-tv__icon { display: grid; flex: none; place-items: center; width: 16px; height: 16px; margin-inline-end: 4px; overflow: hidden; color: var(--ui-tv-icon); }\n.ui-tv__icon svg, .ui-tv__icon img { display: block; width: 100%; height: 100%; object-fit: contain; }\n.ui-tv__item[aria-selected=\"true\"] > .ui-tv__row .ui-tv__icon { color: var(--ui-tv-ink); }\n\n/* Long names end in … – the full name on hover */\n.ui-tv__label { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n.ui-tv__meta {\n  flex: none;\n  max-width: 45%;\n  margin-inline-start: 8px;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  font-size: 12.5px;\n  line-height: 16px;\n  color: var(--ui-tv-meta);\n  font-variant-numeric: tabular-nums;\n}\n\n/* A branch grows and shrinks in height; closed, its rows are hidden from the keyboard and screen readers too */\n.ui-tv__fold {\n  display: grid;\n  grid-template-rows: 0fr;\n  visibility: hidden;\n  transition: grid-template-rows 220ms var(--ui-tv-ease), visibility 0s linear 220ms;\n}\n.ui-tv__fold[data-open] { grid-template-rows: 1fr; visibility: visible; transition: grid-template-rows 220ms var(--ui-tv-ease), visibility 0s; }\n\n/* Guides: a thin line under each open branch's chevron – darker where the selected row is */\n.ui-tv[data-guides] .ui-tv__group::before {\n  content: \"\";\n  position: absolute;\n  top: 2px;\n  bottom: 2px;\n  inset-inline-start: calc(var(--ui-tv-pad) + min((var(--ui-tv-d) - 1) * var(--ui-tv-indent), 45%) + var(--ui-tv-chev) / 2 - 0.5px);\n  width: 1px;\n  background: var(--ui-tv-guide);\n  pointer-events: none;\n  transition: background-color 160ms ease;\n}\n.ui-tv[data-guides] .ui-tv__group:has(> .ui-tv__item[aria-selected=\"true\"])::before { background: var(--ui-tv-guide-on); }\n\n.ui-tv__empty {\n  display: flex;\n  align-items: center;\n  height: var(--ui-tv-h);\n  padding-inline: calc(var(--ui-tv-pad) + min(var(--ui-tv-d, 0) * var(--ui-tv-indent), 45%) + var(--ui-tv-chev) + 5px) var(--ui-tv-pad);\n  font-size: 12.5px;\n  color: var(--ui-tv-faint);\n  white-space: nowrap;\n}\n\n/* Touch screens: rows at least 40px tall */\n@media (pointer: coarse) {\n  .ui-tv { --ui-tv-h: 40px; --ui-tv-indent: 20px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-tv__row, .ui-tv__chev, .ui-tv__fold, .ui-tv__fold[data-open], .ui-tv[data-guides] .ui-tv__group::before { transition: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-tv {\n  --ui-tv-ink: var(--aui-ink, #ededed);\n  --ui-tv-text: var(--aui-ink, #e4e4e7);\n  --ui-tv-icon: var(--aui-muted, #8b8b92);\n  --ui-tv-meta: var(--aui-muted, #8b8b92);\n  --ui-tv-faint: var(--aui-placeholder, #5c5c63);\n  --ui-tv-wash: rgba(255, 255, 255, 0.05);\n  --ui-tv-fill: #26262a;\n  --ui-tv-guide: var(--aui-border, #2a2a2e);\n  --ui-tv-guide-on: #4a4a50;\n  --ui-tv-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}