{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-view",
  "title": "Tree View",
  "description": "A nested list written to the full ARIA tree pattern: arrow keys, type-ahead, single or multiple selection and lazy children, with guide lines, dense rows or a card per branch.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/tree-view.tsx",
      "content": "import type * as React from \"react\"\nimport { useRef } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { TreeItem, type TreeContext, type TreeViewVariant } from \"@/registry/miseui/ui/tree-view-item\"\nimport type { TreeNode } from \"@/registry/miseui/ui/tree-view-math\"\nimport { useTreeState, type TreeSelectionMode } from \"@/registry/miseui/ui/tree-view-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype TreeViewEffect = \"spring\"\n\nconst ROOT_CLASS: Record<TreeViewVariant, string> = {\n  plain: \"gap-1\",\n  lines: \"gap-1\",\n  compact: \"\",\n  card: \"gap-2\",\n}\n\ntype RootAttributes = Omit<React.ComponentProps<\"ul\">, \"children\" | \"role\" | \"onKeyDown\">\n\ninterface TreeViewProps extends RootAttributes {\n  nodes: readonly TreeNode[]\n  /** `plain` indented rows, `lines` guide rules, `compact` dense rows with no icons, `card` each top level on its own surface. */\n  variant?: TreeViewVariant\n  /** `spring` opens and closes branches with a height spring that a second press can reverse. */\n  effect?: TreeViewEffect\n  /** `none` is for navigation: a press only opens and closes. */\n  selectionMode?: TreeSelectionMode\n  expanded?: readonly string[]\n  defaultExpanded?: readonly string[]\n  /** Also where lazy children start: load them when `change.expanded` is true, then pass them in `nodes`. */\n  onExpandedChange?: (expanded: string[], change: { id: string; expanded: boolean }) => void\n  selected?: readonly string[]\n  defaultSelected?: readonly string[]\n  onSelectedChange?: (selected: string[]) => void\n  /** What a `lazy` node shows while its children are on the way, such as a spinner. */\n  renderLazy?: (node: TreeNode) => React.ReactNode\n  /** Used when `renderLazy` is not given. */\n  loadingLabel?: string\n}\n\nconst EMPTY: readonly string[] = []\n\nfunction TreeView({\n  nodes,\n  variant = \"plain\",\n  effect,\n  selectionMode = \"none\",\n  expanded,\n  defaultExpanded = EMPTY,\n  onExpandedChange,\n  selected,\n  defaultSelected = EMPTY,\n  onSelectedChange,\n  renderLazy,\n  loadingLabel = \"Loading\",\n  className,\n  ...props\n}: TreeViewProps) {\n  const rootRef = useRef<HTMLUListElement>(null)\n  const reduceMotion = useReducedMotion() ?? false\n  const state = useTreeState({ nodes, selectionMode, expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelectedChange }, rootRef)\n  const context: TreeContext = { variant, selectionMode, spring: effect === \"spring\" && !reduceMotion, state, renderLazy, loadingLabel }\n  return (\n    <ul\n      ref={rootRef}\n      role=\"tree\"\n      aria-multiselectable={selectionMode === \"multiple\" || undefined}\n      data-slot=\"tree-view\"\n      data-variant={variant}\n      data-effect={effect}\n      className={cn(\"grid min-w-0\", ROOT_CLASS[variant], className)}\n      onKeyDown={state.onKeyDown}\n      {...props}\n    >\n      {nodes.map((node, index) => (\n        <TreeItem key={node.id} node={node} level={1} position={index + 1} setSize={nodes.length} context={context} />\n      ))}\n    </ul>\n  )\n}\n\nexport { TreeView, type TreeViewProps, type TreeNode, type TreeViewVariant, type TreeViewEffect, type TreeSelectionMode }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/tree-view-item.tsx",
      "content": "import type * as React from \"react\"\nimport { useId } from \"react\"\nimport { Check, ChevronRight } from \"lucide-react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { isBranch, type TreeNode } from \"@/registry/miseui/ui/tree-view-math\"\nimport type { TreeSelectionMode, TreeState } from \"@/registry/miseui/ui/tree-view-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype TreeViewVariant = \"plain\" | \"lines\" | \"compact\" | \"card\"\n\n/** Pixels per level. `lines` indents through its nested lists instead, so the rule can run down the left of each one. */\nconst INDENT_PX: Record<TreeViewVariant, number> = { plain: 16, lines: 0, compact: 12, card: 16 }\n\nconst ROW_CLASS: Record<TreeViewVariant, string> = {\n  plain: \"min-h-8 rounded-md pr-2 text-sm pointer-coarse:min-h-10\",\n  lines: \"min-h-8 rounded-md pr-2 text-sm pointer-coarse:min-h-10\",\n  compact: \"min-h-7 pr-2 text-[13px] pointer-coarse:min-h-10\",\n  card: \"min-h-10 rounded-md pr-3 text-sm\",\n}\n\nconst SELECTED_CLASS: Record<TreeViewVariant, string> = {\n  plain: \"bg-accent font-medium\",\n  lines: \"bg-accent font-medium\",\n  compact: \"bg-mise-soft\",\n  card: \"bg-accent font-medium\",\n}\n\ninterface TreeContext {\n  variant: TreeViewVariant\n  selectionMode: TreeSelectionMode\n  spring: boolean\n  state: TreeState\n  renderLazy?: (node: TreeNode) => React.ReactNode\n  loadingLabel: string\n}\n\nfunction GroupList({ context, labelledBy, children }: { context: TreeContext; labelledBy: string; children: React.ReactNode }) {\n  const lines = context.variant === \"lines\"\n  const className = cn(lines && \"ms-4 border-s ps-1\")\n  if (!context.spring) return <ul role=\"group\" aria-labelledby={labelledBy} className={className}>{children}</ul>\n  return (\n    <motion.ul\n      role=\"group\"\n      aria-labelledby={labelledBy}\n      data-slot=\"tree-view-group\"\n      className={cn(className, \"overflow-hidden\")}\n      initial={{ height: 0 }}\n      animate={{ height: \"auto\" }}\n      exit={{ height: 0 }}\n      transition={SPRING_DEFAULT}\n    >\n      {children}\n    </motion.ul>\n  )\n}\n\ninterface TreeRowProps {\n  node: TreeNode\n  level: number\n  labelId: string\n  open: boolean\n  selected: boolean\n  context: TreeContext\n}\n\nfunction TreeRow({ node, level, labelId, open, selected, context }: TreeRowProps) {\n  const { variant, selectionMode, state } = context\n  const branch = isBranch(node)\n  return (\n    <div\n      data-slot=\"tree-view-row\"\n      style={{ paddingInlineStart: (level - 1) * INDENT_PX[variant] }}\n      className={cn(\n        \"flex cursor-default items-center gap-1 select-none\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent/50\",\n        // Inset, so the clip on an animating group cannot cut it. Scoped to its own li, or a focused parent would ring every row below.\n        \"[li:focus-visible>&]:ring-[3px] [li:focus-visible>&]:ring-ring/50 [li:focus-visible>&]:ring-inset\",\n        ROW_CLASS[variant],\n        selected && SELECTED_CLASS[variant],\n        node.disabled && \"cursor-not-allowed opacity-50\",\n      )}\n      onClick={() => state.onRowClick(node)}\n    >\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"tree-view-toggle\"\n        className={cn(\"flex w-6 shrink-0 items-center justify-center self-stretch text-muted-foreground pointer-coarse:w-10\", !branch && \"invisible\")}\n        onClick={(event) => {\n          event.stopPropagation()\n          state.toggle(node)\n        }}\n      >\n        <ChevronRight\n          className={cn(\n            \"size-4 transition-transform duration-(--duration-fast) ease-(--ease-mise) motion-reduce:transition-none\",\n            open && \"rotate-90\",\n          )}\n        />\n      </span>\n      {selectionMode === \"multiple\" ? (\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"tree-view-checkbox\"\n          className={cn(\"flex size-4 shrink-0 items-center justify-center rounded-sm border border-input\", selected && \"border-mise bg-mise text-mise-foreground\")}\n        >\n          {selected ? <Check className=\"size-3\" /> : null}\n        </span>\n      ) : null}\n      {node.icon && variant !== \"compact\" ? <span aria-hidden=\"true\" className=\"flex shrink-0 text-muted-foreground [&_svg]:size-4\">{node.icon}</span> : null}\n      <span id={labelId} className=\"min-w-0 py-1 break-words\">{node.label}</span>\n    </div>\n  )\n}\n\ninterface TreeItemProps {\n  node: TreeNode\n  level: number\n  position: number\n  setSize: number\n  context: TreeContext\n}\n\nfunction TreeItem({ node, level, position, setSize, context }: TreeItemProps) {\n  const { variant, selectionMode, state } = context\n  const labelId = useId()\n  const branch = isBranch(node)\n  const open = branch && state.openSet.has(node.id)\n  const selected = state.selectedList.includes(node.id)\n  const pending = open && node.lazy === true && !node.children?.length\n  const group = open ? (\n    <GroupList key=\"group\" context={context} labelledBy={labelId}>\n      {node.children?.map((child, index) => (\n        <TreeItem key={child.id} node={child} level={level + 1} position={index + 1} setSize={node.children?.length ?? 0} context={context} />\n      ))}\n      {pending ? (\n        <li role=\"none\" data-slot=\"tree-view-lazy\" style={{ paddingInlineStart: level * INDENT_PX[variant] + 24 }} className=\"py-1 text-[13px] text-muted-foreground\">\n          {context.renderLazy?.(node) ?? context.loadingLabel}\n        </li>\n      ) : null}\n    </GroupList>\n  ) : null\n  return (\n    <li\n      role=\"treeitem\"\n      tabIndex={state.tabStop === node.id ? 0 : -1}\n      // Named by its own label; left to itself the name would swallow every row nested inside.\n      aria-labelledby={labelId}\n      aria-level={level}\n      aria-posinset={position}\n      aria-setsize={setSize}\n      aria-expanded={branch ? open : undefined}\n      aria-selected={selectionMode === \"none\" ? undefined : selected}\n      aria-disabled={node.disabled || undefined}\n      aria-busy={pending || undefined}\n      data-tree-id={node.id}\n      className={cn(\"outline-none\", variant === \"card\" && level === 1 && \"rounded-lg border bg-card p-1\")}\n      onFocus={(event) => {\n        if (event.target === event.currentTarget) state.setFocusedId(node.id)\n      }}\n    >\n      <TreeRow node={node} level={level} labelId={labelId} open={open} selected={selected} context={context} />\n      {context.spring ? <AnimatePresence initial={false}>{group}</AnimatePresence> : group}\n    </li>\n  )\n}\n\nexport { TreeItem, type TreeContext, type TreeViewVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/tree-view-math.ts",
      "content": "import type * as React from \"react\"\n\ninterface TreeNode {\n  id: string\n  label: string\n  icon?: React.ReactNode\n  children?: readonly TreeNode[]\n  /** Has children that are not loaded yet. Expanding it fires onExpandedChange; pass `children` once you have them. */\n  lazy?: boolean\n  disabled?: boolean\n}\n\ninterface VisibleNode {\n  node: TreeNode\n  level: number\n  parentId: string | null\n  position: number\n  setSize: number\n}\n\ntype TreeMove = { focus?: string; expand?: string; collapse?: string }\n\nconst isBranch = (node: TreeNode) => node.lazy === true || (node.children?.length ?? 0) > 0\n\n/** Depth first, only through open branches: the same order the rows have on screen and the arrow keys walk. */\nfunction flattenVisible(\n  nodes: readonly TreeNode[],\n  expanded: ReadonlySet<string>,\n  level = 1,\n  parentId: string | null = null,\n): readonly VisibleNode[] {\n  return nodes.flatMap((node, index) => {\n    const entry = { node, level, parentId, position: index + 1, setSize: nodes.length }\n    const open = expanded.has(node.id) && node.children\n    return [entry, ...(open ? flattenVisible(node.children ?? [], expanded, level + 1, node.id) : [])]\n  })\n}\n\n/** What each key does in the WAI-ARIA tree pattern. Null when the key is not a tree key. */\nfunction treeMove(key: string, visible: readonly VisibleNode[], current: string, expanded: ReadonlySet<string>): TreeMove | null {\n  const index = visible.findIndex((entry) => entry.node.id === current)\n  if (index === -1) return null\n  const entry = visible[index]\n  const open = expanded.has(current)\n  const branch = isBranch(entry.node)\n  const moves: Record<string, () => TreeMove> = {\n    ArrowDown: () => ({ focus: visible[index + 1]?.node.id }),\n    ArrowUp: () => ({ focus: visible[index - 1]?.node.id }),\n    Home: () => ({ focus: visible[0]?.node.id }),\n    End: () => ({ focus: visible[visible.length - 1]?.node.id }),\n    ArrowRight: () => {\n      if (!branch) return {}\n      if (!open) return { expand: current }\n      const child = visible[index + 1]\n      return child?.parentId === current ? { focus: child.node.id } : {}\n    },\n    ArrowLeft: () => {\n      if (branch && open) return { collapse: current }\n      return entry.parentId ? { focus: entry.parentId } : {}\n    },\n  }\n  return moves[key]?.() ?? null\n}\n\n/** Starts after the current row, so one letter pressed again walks through every match. */\nfunction typeaheadMatch(visible: readonly VisibleNode[], query: string, current: string) {\n  const index = visible.findIndex((entry) => entry.node.id === current)\n  const repeated = [...query].every((character) => character === query[0])\n  const needle = (repeated ? query[0] : query).toLowerCase()\n  const start = repeated ? index + 1 : Math.max(index, 0)\n  const rotated = [...visible.slice(start), ...visible.slice(0, start)]\n  return rotated.find((entry) => entry.node.label.toLowerCase().startsWith(needle))?.node.id ?? null\n}\n\n/** The `*` key: every branch at the same level under the same parent. */\nfunction expandableSiblings(visible: readonly VisibleNode[], current: string) {\n  const entry = visible.find((candidate) => candidate.node.id === current)\n  if (!entry) return []\n  return visible\n    .filter((candidate) => candidate.parentId === entry.parentId && isBranch(candidate.node))\n    .map((candidate) => candidate.node.id)\n}\n\nfunction containsNode(node: TreeNode, id: string): boolean {\n  return (node.children ?? []).some((child) => child.id === id || containsNode(child, id))\n}\n\nexport {\n  containsNode,\n  expandableSiblings,\n  flattenVisible,\n  isBranch,\n  treeMove,\n  typeaheadMatch,\n  type TreeMove,\n  type TreeNode,\n  type VisibleNode,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/tree-view-state.ts",
      "content": "import type * as React from \"react\"\nimport { useMemo, useRef, useState } from \"react\"\nimport {\n  containsNode,\n  expandableSiblings,\n  flattenVisible,\n  isBranch,\n  treeMove,\n  typeaheadMatch,\n  type TreeNode,\n  type VisibleNode,\n} from \"@/registry/miseui/ui/tree-view-math\"\n\ntype TreeSelectionMode = \"none\" | \"single\" | \"multiple\"\n\nconst TYPEAHEAD_RESET_MS = 500\n\ninterface TreeStateOptions {\n  nodes: readonly TreeNode[]\n  selectionMode: TreeSelectionMode\n  expanded?: readonly string[]\n  defaultExpanded: readonly string[]\n  onExpandedChange?: (expanded: string[], change: { id: string; expanded: boolean }) => void\n  selected?: readonly string[]\n  defaultSelected: readonly string[]\n  onSelectedChange?: (selected: string[]) => void\n}\n\nfunction useTreeOpenState({ expanded, defaultExpanded, onExpandedChange }: Pick<TreeStateOptions, \"expanded\" | \"defaultExpanded\" | \"onExpandedChange\">) {\n  const [uncontrolled, setUncontrolled] = useState(defaultExpanded)\n  const list = expanded ?? uncontrolled\n  const openSet = useMemo(() => new Set(list), [list])\n  const setOpen = (id: string, open: boolean) => {\n    if (openSet.has(id) === open) return\n    const next = open ? [...list, id] : list.filter((entry) => entry !== id)\n    setUncontrolled(next)\n    onExpandedChange?.(next, { id, expanded: open })\n  }\n  /** All at once: called one by one in a loop, each call would overwrite the last from the same stale list. */\n  const openMany = (ids: readonly string[]) => {\n    const added = ids.filter((id) => !openSet.has(id))\n    if (added.length === 0) return\n    const next = [...list, ...added]\n    setUncontrolled(next)\n    added.forEach((id) => onExpandedChange?.(next, { id, expanded: true }))\n  }\n  return { openSet, setOpen, openMany }\n}\n\nfunction useTreeSelection({ selected, defaultSelected, onSelectedChange, selectionMode }: Pick<TreeStateOptions, \"selected\" | \"defaultSelected\" | \"onSelectedChange\" | \"selectionMode\">) {\n  const [uncontrolled, setUncontrolled] = useState(defaultSelected)\n  const list = selected ?? uncontrolled\n  const select = (id: string) => {\n    if (selectionMode === \"none\") return\n    const toggled = list.includes(id) ? list.filter((entry) => entry !== id) : [...list, id]\n    const next = selectionMode === \"single\" ? [id] : toggled\n    setUncontrolled(next)\n    onSelectedChange?.(next)\n  }\n  return { selectedList: list, select }\n}\n\nfunction useTreeTypeahead(jump: (query: string) => void) {\n  const buffer = useRef(\"\")\n  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n  return (character: string) => {\n    clearTimeout(timer.current)\n    buffer.current += character\n    jump(buffer.current)\n    timer.current = setTimeout(() => {\n      buffer.current = \"\"\n    }, TYPEAHEAD_RESET_MS)\n  }\n}\n\ninterface TreeKeyContext {\n  visible: readonly VisibleNode[]\n  openSet: ReadonlySet<string>\n  focusRow: (id: string) => void\n  setOpen: (id: string, open: boolean) => void\n  openMany: (ids: readonly string[]) => void\n  activate: (node: TreeNode) => void\n  typeahead: (character: string) => void\n}\n\nfunction treeKeyDown(event: React.KeyboardEvent, { visible, openSet, focusRow, setOpen, openMany, activate, typeahead }: TreeKeyContext) {\n  const target = event.target as HTMLElement\n  const current = target.dataset.treeId\n  // Keys inside a lazy slot or a row's own control are not for the tree.\n  if (!current || target.getAttribute(\"role\") !== \"treeitem\") return\n  const entry = visible.find((candidate) => candidate.node.id === current)\n  if (!entry) return\n  const move = treeMove(event.key, visible, current, openSet)\n  const printable = event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey\n  if (!move && !printable && event.key !== \"Enter\") return\n  event.preventDefault()\n  if (move?.focus) focusRow(move.focus)\n  if (move?.expand) setOpen(move.expand, true)\n  if (move?.collapse) setOpen(move.collapse, false)\n  if (move) return\n  if (event.key === \"Enter\" || event.key === \" \") activate(entry.node)\n  else if (event.key === \"*\") openMany(expandableSiblings(visible, current))\n  else typeahead(event.key)\n}\n\nfunction useTreeState(options: TreeStateOptions, rootRef: React.RefObject<HTMLUListElement | null>) {\n  const { nodes, selectionMode } = options\n  const { openSet, setOpen, openMany } = useTreeOpenState(options)\n  const { selectedList, select } = useTreeSelection(options)\n  const visible = useMemo(() => flattenVisible(nodes, openSet), [nodes, openSet])\n  const [focusedId, setFocusedId] = useState<string | null>(null)\n  // Falls back to the first row when the focused one was collapsed away from outside, so the tree never loses its tab stop.\n  const tabStop = visible.some((entry) => entry.node.id === focusedId) ? focusedId : (visible[0]?.node.id ?? null)\n  const focusRow = (id: string) => {\n    setFocusedId(id)\n    const rows = rootRef.current?.querySelectorAll<HTMLElement>(\"[role='treeitem']\") ?? []\n    Array.from(rows).find((element) => element.dataset.treeId === id)?.focus()\n  }\n  const toggle = (node: TreeNode) => {\n    const open = openSet.has(node.id)\n    // Closing a branch around the focused row would drop focus on the body.\n    if (open && focusedId && containsNode(node, focusedId)) focusRow(node.id)\n    setOpen(node.id, !open)\n  }\n  const activate = (node: TreeNode) => {\n    if (selectionMode !== \"none\") {\n      if (!node.disabled) select(node.id)\n    } else if (isBranch(node)) toggle(node)\n  }\n  const onRowClick = (node: TreeNode) => {\n    setFocusedId(node.id)\n    if (selectionMode !== \"none\" && !node.disabled) select(node.id)\n    if (selectionMode !== \"multiple\" && isBranch(node)) toggle(node)\n  }\n  const typeahead = useTreeTypeahead((query) => {\n    const match = typeaheadMatch(visible, query, tabStop ?? \"\")\n    if (match) focusRow(match)\n  })\n  const onKeyDown = (event: React.KeyboardEvent) => treeKeyDown(event, { visible, openSet, focusRow, setOpen, openMany, activate, typeahead })\n  return { visible, openSet, selectedList, tabStop, toggle, onRowClick, onKeyDown, setFocusedId }\n}\n\ntype TreeState = ReturnType<typeof useTreeState>\n\nexport { useTreeState, type TreeSelectionMode, type TreeState }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}