{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:ui","title":"Tree View","description":"Disclosure the arrow keys can walk.","dependencies":["motion"],"categories":["navigation"],"docs":"https://www.interior.dev/docs/tree-view","files":[{"path":"registry/interior/tree-view.tsx","type":"registry:ui","target":"components/interior/tree-view.tsx","content":"\"use client\";\n\nimport { useCallback, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\nconst LEAVE = [0.4, 0, 1, 1] as const;\n\nconst SMALL = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\n\nconst OPEN_H = { duration: 0.28, ease: EASE } as const;\nconst OPEN_O = { duration: 0.18, ease: EASE } as const;\nconst SHUT_H = { duration: 0.2, ease: LEAVE } as const;\nconst SHUT_O = { duration: 0.14, ease: LEAVE } as const;\nconst STILL = { duration: 0 } as const;\n\nexport type TreeNode = {\n  id: string;\n  label: string;\n\n  meta?: string;\n  children?: TreeNode[];\n};\n\nexport type TreeRow = {\n  node: TreeNode;\n  level: number;\n  parentId: string | null;\n  posinset: number;\n  setsize: number;\n  branch: boolean;\n  open: boolean;\n};\n\nfunction flatten(\n  nodes: TreeNode[],\n  openSet: ReadonlySet<string>,\n  level = 1,\n  parentId: string | null = null,\n  out: TreeRow[] = [],\n): TreeRow[] {\n  nodes.forEach((node, i) => {\n    const children = node.children ?? [];\n    const branch = children.length > 0;\n    const open = branch && openSet.has(node.id);\n    out.push({\n      node,\n      level,\n      parentId,\n      posinset: i + 1,\n      setsize: nodes.length,\n      branch,\n      open,\n    });\n    if (open) flatten(children, openSet, level + 1, node.id, out);\n  });\n  return out;\n}\n\nexport type UseTreeViewOptions = {\n  nodes: TreeNode[];\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  selected?: string | null;\n  defaultSelected?: string | null;\n  onSelectedChange?: (selected: string) => void;\n};\n\nexport function useTreeView({\n  nodes,\n  expanded,\n  defaultExpanded = [],\n  onExpandedChange,\n  selected,\n  defaultSelected = null,\n  onSelectedChange,\n}: UseTreeViewOptions) {\n  const [internalOpen, setInternalOpen] = useState<string[]>(defaultExpanded);\n  const openControlled = expanded !== undefined;\n  const openList = openControlled ? expanded : internalOpen;\n  const openSet = new Set(openList);\n\n  const [internalSel, setInternalSel] = useState<string | null>(defaultSelected);\n  const selControlled = selected !== undefined;\n  const selectedId = selControlled ? selected : internalSel;\n\n  const emitOpen = useRef(onExpandedChange);\n  emitOpen.current = onExpandedChange;\n  const emitSel = useRef(onSelectedChange);\n  emitSel.current = onSelectedChange;\n\n  const rows = flatten(nodes, openSet);\n\n  const [focusId, setFocusId] = useState<string | null>(null);\n  const visible =\n    focusId !== null && rows.some((r) => r.node.id === focusId)\n      ? focusId\n      : (rows.find((r) => r.node.id === selectedId)?.node.id ??\n        rows[0]?.node.id ??\n        null);\n\n  const refs = useRef(new Map<string, HTMLElement>());\n  const register = useCallback((id: string, el: HTMLElement | null) => {\n    if (el) refs.current.set(id, el);\n    else refs.current.delete(id);\n  }, []);\n\n  const focusRow = useCallback((id: string) => {\n    setFocusId(id);\n    refs.current.get(id)?.focus();\n  }, []);\n\n  const setOpen = useCallback(\n    (next: string[]) => {\n      if (!openControlled) setInternalOpen(next);\n      emitOpen.current?.(next);\n    },\n    [openControlled],\n  );\n\n  const toggle = useCallback(\n    (id: string) => {\n      const has = openList.includes(id);\n      setOpen(has ? openList.filter((v) => v !== id) : [...openList, id]);\n    },\n    [openList, setOpen],\n  );\n\n  const select = useCallback(\n    (id: string) => {\n      if (!selControlled) setInternalSel(id);\n      emitSel.current?.(id);\n    },\n    [selControlled],\n  );\n\n  const handleKey = useCallback(\n    (event: React.KeyboardEvent, row: TreeRow) => {\n      const at = rows.findIndex((r) => r.node.id === row.node.id);\n      const go = (index: number) => {\n        const target = rows[index];\n        if (target) focusRow(target.node.id);\n      };\n\n      switch (event.key) {\n        case \"ArrowDown\":\n          event.preventDefault();\n          go(at + 1);\n          return;\n        case \"ArrowUp\":\n          event.preventDefault();\n          go(at - 1);\n          return;\n        case \"ArrowRight\":\n          event.preventDefault();\n          if (row.branch && !row.open) toggle(row.node.id);\n          else if (row.open) go(at + 1);\n          return;\n        case \"ArrowLeft\":\n          event.preventDefault();\n          if (row.open) toggle(row.node.id);\n          else if (row.parentId) focusRow(row.parentId);\n          return;\n        case \"Home\":\n          event.preventDefault();\n          go(0);\n          return;\n        case \"End\":\n          event.preventDefault();\n          go(rows.length - 1);\n          return;\n        case \"Enter\":\n        case \" \":\n          event.preventDefault();\n          select(row.node.id);\n          if (row.branch) toggle(row.node.id);\n          return;\n        default:\n      }\n\n      if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {\n        const letter = event.key.toLowerCase();\n        if (letter === \" \") return;\n        for (let step = 1; step <= rows.length; step++) {\n          const candidate = rows[(at + step) % rows.length];\n          if (candidate.node.label.toLowerCase().startsWith(letter)) {\n            event.preventDefault();\n            focusRow(candidate.node.id);\n            return;\n          }\n        }\n      }\n    },\n    [rows, focusRow, toggle, select],\n  );\n\n  return {\n    rows,\n    openSet,\n    selectedId,\n\n    tabStop: visible,\n    register,\n    focusRow,\n    setFocusId,\n    toggle,\n    select,\n    handleKey,\n  };\n}\n\nfunction Caret({ open }: { open: boolean }) {\n  const reduced = useReducedMotion();\n  return (\n    <motion.span\n      aria-hidden\n      initial={false}\n      animate={{ rotate: open ? 90 : 0 }}\n      transition={reduced ? STILL : SMALL}\n      className=\"flex size-4 shrink-0 items-center justify-center text-stone-400 dark:text-stone-500\"\n    >\n      <svg viewBox=\"0 0 12 12\" width=\"10\" height=\"10\" focusable=\"false\">\n        <path\n          d=\"M4.5 2.5 8 6l-3.5 3.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.6\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n    </motion.span>\n  );\n}\n\nexport type TreeViewProps = {\n  nodes: TreeNode[];\n  label: string;\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  selected?: string | null;\n  defaultSelected?: string | null;\n  onSelectedChange?: (selected: string) => void;\n  className?: string;\n};\n\nexport function TreeView({\n  nodes,\n  label,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  selected,\n  defaultSelected,\n  onSelectedChange,\n  className = \"\",\n}: TreeViewProps) {\n  const tree = useTreeView({\n    nodes,\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n    selected,\n    defaultSelected,\n    onSelectedChange,\n  });\n  const reduced = useReducedMotion();\n  const hintId = useId();\n\n  const renderNodes = (list: TreeNode[], level: number) =>\n    list.map((node, i) => {\n      const row = tree.rows.find((r) => r.node.id === node.id);\n\n      if (!row) return null;\n\n      const isSelected = tree.selectedId === node.id;\n\n      return (\n        <li key={node.id} role=\"none\">\n          <div\n            role=\"treeitem\"\n            ref={(el) => tree.register(node.id, el)}\n            aria-level={level}\n            aria-posinset={i + 1}\n            aria-setsize={list.length}\n            aria-expanded={row.branch ? row.open : undefined}\n            aria-selected={isSelected}\n            aria-describedby={hintId}\n            tabIndex={tree.tabStop === node.id ? 0 : -1}\n            onFocus={() => tree.setFocusId(node.id)}\n            onKeyDown={(e) => tree.handleKey(e, row)}\n            onClick={() => {\n              tree.select(node.id);\n              tree.focusRow(node.id);\n              if (row.branch) tree.toggle(node.id);\n            }}\n            className={`flex h-7 cursor-default select-none items-center gap-1 rounded-[8px] px-1.5 outline-none transition-colors duration-150 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n              isSelected\n                ? \"bg-stone-100/80 text-stone-800 dark:bg-white/[0.07] dark:text-stone-100\"\n                : \"text-stone-600 hover:bg-stone-100/60 dark:text-stone-300 dark:hover:bg-white/[0.04]\"\n            }`}\n          >\n\n            {row.branch ? <Caret open={row.open} /> : <span className=\"size-4 shrink-0\" />}\n\n            <span\n              className={`min-w-0 flex-1 truncate text-[12.5px] ${\n                isSelected ? \"font-medium\" : \"\"\n              }`}\n            >\n              {node.label}\n            </span>\n\n            {node.meta ? (\n              <span className=\"shrink-0 font-mono text-[10.5px] tabular-nums text-stone-400 dark:text-stone-500\">\n                {node.meta}\n              </span>\n            ) : null}\n          </div>\n\n          {row.branch ? (\n            <AnimatePresence initial={false}>\n              {row.open ? (\n                <motion.ul\n                  key=\"group\"\n                  role=\"group\"\n                  initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  exit={\n                    reduced\n                      ? { opacity: 0, transition: STILL }\n                      : {\n                          height: 0,\n                          opacity: 0,\n                          transition: { height: SHUT_H, opacity: SHUT_O },\n                        }\n                  }\n                  transition={\n                    reduced ? STILL : { height: OPEN_H, opacity: OPEN_O }\n                  }\n                  className=\"overflow-hidden\"\n                >\n                  <div className=\"ml-[13px] border-l border-stone-200/80 pl-[7px] dark:border-white/[0.16]\">\n                    {renderNodes(node.children ?? [], level + 1)}\n                  </div>\n                </motion.ul>\n              ) : null}\n            </AnimatePresence>\n          ) : null}\n        </li>\n      );\n    });\n\n  return (\n    <div\n      className={`rounded-[13px] border border-stone-200 bg-white p-[5px] shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}\n    >\n      <ul role=\"tree\" aria-label={label}>\n        {renderNodes(nodes, 1)}\n      </ul>\n      <span id={hintId} className=\"sr-only\">\n        Use the arrow keys to move. Right expands a folder, left collapses it\n        or climbs to its parent. Home and End jump to the ends, and typing a\n        letter jumps to the next name starting with it.\n      </span>\n    </div>\n  );\n}\n"}]}