{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:component","title":"Tree view","description":"The sidebar of pages, folders or files: carry a row up, down and sideways to nest it, and folders open as you hold them.","author":"Hairline UI","dependencies":["lucide-react"],"files":[{"path":"registry/tree-view.tsx","type":"registry:component","content":"// Hairline UI · tree-view · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronRight, File, Folder, FolderOpen, Plus, Trash2 } from \"lucide-react\";\nimport { useRowMotion } from \"./use-row-motion\";\nimport { useTreeDrag, type TreeTarget } from \"./use-tree-drag\";\n\n/* ─────────────────────────────────────────────────────────\n * TREE VIEW: the sidebar of pages, folders or files\n *\n *   open      a row's children slide out from under it while\n *             the rows below make room; closing folds them away\n *   carry     drag a row (and everything inside it): up and\n *             down picks the gap, sideways picks the depth, and\n *             a line starting at that depth shows where it lands\n *   into      the middle of a row means \"inside it\"; a closed\n *             one opens itself if you hold it there\n *   refuse    a rule (canDrop) turns the line amber and says\n *             why under the row you carry; let go and it flies\n *             home\n *   land      the row glides from where you let go into its\n *             place; a save that fails takes it back\n *   edit      F2 or double-click renames in place, + adds a row\n *             inside, Delete removes with Undo\n *   keys      arrows move and open, Alt+arrows move the row\n *             (up, down, in, out)\n *\n * Nodes are a flat list of { id, parent, title }, siblings in\n * the order they stand: the shape a database keeps.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TreeNode {\n  id: string;\n  parent: string | null;\n  title: string;\n  /** Can't hold other rows (a file, say). */\n  leaf?: boolean;\n}\n\nexport interface TreeViewProps<T extends TreeNode> extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\" | \"onSelect\"> {\n  nodes?: T[];\n  defaultNodes?: T[];\n  onNodesChange?: (nodes: T[]) => void;\n  /** Ids of the open rows. */\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (ids: string[]) => void;\n  selected?: string | null;\n  defaultSelected?: string | null;\n  /** A row chosen by click, Enter or Space. */\n  onSelect?: (node: T) => void;\n  /** The row's icon. Defaults to a folder (open or closed) or a file. */\n  icon?: (node: T, state: { expanded: boolean; hasChildren: boolean }) => React.ReactNode;\n  /** Why `parent` won't take `node` (null is the top level), or nothing. */\n  canDrop?: (node: T, parent: T | null) => string | null | undefined | void;\n  /** A row moved. Throw to put it back. */\n  onMove?: (node: T, parent: T | null, index: number) => Promise<void> | void;\n  /** Shows rename (F2, double-click). Throw to put the old title back. */\n  onRename?: (node: T, title: string) => Promise<void> | void;\n  /** Shows + on rows that hold others (and at the foot). Return the new node. */\n  onCreate?: (parent: T | null, title: string) => T | Promise<T>;\n  /** Shows delete. Runs once the chance to undo is over; throw to bring the row back. */\n  onDelete?: (node: T) => Promise<void> | void;\n  /** The noun for new rows: \"page\", \"file\". */\n  addLabel?: string;\n  undoFor?: number;\n  /** Pixels per level. */\n  indent?: number;\n  label?: string;\n}\n\ntype Row<T> =\n  | { kind: \"node\"; node: T; depth: number; hasChildren: boolean; open: boolean; pos: number; size: number }\n  | { kind: \"empty\"; parent: string; depth: number }\n  | { kind: \"draft\"; parent: string | null; depth: number };\n\ntype Note = { text: string; tone: \"warn\" | \"error\"; key: number };\ntype Undo<T> = { key: number; node: T; removed: T[]; nodes: T[] };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\nconst ROW_H = 30;\nconst INSET = 26;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst NO_IDS: string[] = [];\nconst NO_RANKS: ReadonlyMap<string, number> = new Map();\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Every node under `id`, at any depth. */\nfunction under<T extends TreeNode>(nodes: T[], id: string) {\n  const out = new Set<string>();\n  const walk = (p: string) => nodes.forEach((n) => n.parent === p && !out.has(n.id) && (out.add(n.id), walk(n.id)));\n  walk(id);\n  return out;\n}\n\n/** Moves a node into `parent`: first (after null), after a sibling, or at the end. */\nfunction place<T extends TreeNode>(nodes: T[], id: string, parent: string | null, after: string | null | \"end\"): T[] {\n  const node = nodes.find((n) => n.id === id);\n  if (!node) return nodes;\n  const rest = nodes.filter((n) => n.id !== id);\n  const sibs = rest.filter((n) => n.parent === parent);\n  let at: number;\n  if (after === \"end\") at = sibs.length ? rest.indexOf(sibs[sibs.length - 1]) + 1 : rest.length;\n  else if (after === null) at = sibs.length ? rest.indexOf(sibs[0]) : rest.length;\n  else at = rest.findIndex((n) => n.id === after) + 1;\n  return [...rest.slice(0, at), { ...node, parent } as T, ...rest.slice(at)];\n}\n\n/** A sentence that changes crossfades whole. */\nfunction Line({ text, className = \"\" }: { text: string; className?: string }) {\n  const [shown, setShown] = React.useState({ text, key: 0 });\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  if (text !== shown.text) {\n    if (shown.text) setLeaving((l) => [...l, shown]);\n    setShown({ text, key: shown.key + 1 });\n  }\n  return (\n    <span className={`relative block ${className}`}>\n      <span\n        key={shown.key}\n        className=\"block truncate\"\n        ref={(el) => {\n          if (!el || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate(\n            [\n              { opacity: 0, transform: \"translateY(0.35em)\", filter: \"blur(4px)\" },\n              { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            ],\n            { duration: 380, delay: 60, easing: EASE, fill: \"backwards\" },\n          );\n        }}\n      >\n        {shown.text || \" \"}\n      </span>\n      {leaving.map((l) => (\n        <span\n          key={l.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 block truncate\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate(\n              [\n                { opacity: 1, filter: \"blur(0px)\" },\n                { opacity: 0, filter: \"blur(4px)\" },\n              ],\n              { duration: 180, easing: EASE, fill: \"forwards\" },\n            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\ntype RowActions<T> = {\n  click: (e: React.MouseEvent, node: T) => void;\n  toggle: (node: T) => void;\n  key: (e: React.KeyboardEvent, node: T) => void;\n  focus: (node: T) => void;\n  rename: (node: T) => void;\n  commitRename: (node: T, title: string) => void;\n  cancelRename: () => void;\n  add: (node: T) => void;\n  remove: (node: T) => void;\n  icon: (node: T, state: { expanded: boolean; hasChildren: boolean }) => React.ReactNode;\n};\n\n/** One row, memoised on plain values: opening a folder or carrying a row re-renders only the rows it changes. */\nfunction TreeRowView<T extends TreeNode>({\n  node,\n  depth,\n  hasChildren,\n  open,\n  pos,\n  size,\n  selected,\n  tab,\n  dimmed,\n  renaming,\n  canAdd,\n  canDelete,\n  canRename,\n  indent,\n  reduced,\n  actions,\n}: {\n  node: T;\n  depth: number;\n  hasChildren: boolean;\n  open: boolean;\n  pos: number;\n  size: number;\n  selected: boolean;\n  tab: boolean;\n  dimmed: boolean;\n  renaming: boolean;\n  canAdd: boolean;\n  canDelete: boolean;\n  canRename: boolean;\n  indent: number;\n  reduced: boolean;\n  actions: React.RefObject<RowActions<T>>;\n}) {\n  const holds = !node.leaf;\n  return (\n    <li\n      data-row-id={node.id}\n      data-depth={depth}\n      data-parent={node.parent ?? \"\"}\n      data-can-hold={holds ? \"1\" : \"0\"}\n      data-expanded={open ? \"1\" : \"0\"}\n      data-has-children={hasChildren ? \"1\" : \"0\"}\n      role=\"treeitem\"\n      aria-level={depth + 1}\n      aria-setsize={size}\n      aria-posinset={pos}\n      aria-expanded={holds ? open : undefined}\n      aria-selected={selected}\n      tabIndex={tab ? 0 : -1}\n      onKeyDown={(e) => actions.current.key(e, node)}\n      onFocus={(e) => e.target === e.currentTarget && actions.current.focus(node)}\n      onClick={(e) => actions.current.click(e, node)}\n      onDoubleClick={() => canRename && actions.current.rename(node)}\n      className={`group/row relative shrink-0 cursor-default select-none rounded-[8px] outline-none [-webkit-touch-callout:none] ${FOCUS}`}\n      style={{ height: ROW_H, opacity: dimmed ? 0.4 : 1, transition: reduced ? \"none\" : `opacity 160ms ${EASE}` }}\n    >\n      <div\n        data-row-content\n        className={`flex h-full items-center gap-1.5 rounded-[8px] pr-1 text-[13.5px] transition-colors duration-150 ${selected ? \"bg-accent font-medium text-foreground\" : \"text-foreground/85 hover:bg-accent/60\"}`}\n        style={{ paddingLeft: 4 + depth * indent }}\n      >\n        {holds ? (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-hidden=\"true\"\n            onClick={(e) => {\n              e.stopPropagation();\n              actions.current.toggle(node);\n            }}\n            className={`flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-[6px] text-muted-foreground hover:bg-accent hover:text-foreground ${hasChildren ? \"\" : \"opacity-40\"}`}\n          >\n            <ChevronRight className=\"size-3.5\" strokeWidth={2} style={{ transform: open ? \"rotate(90deg)\" : \"none\", transition: reduced ? \"none\" : `transform 260ms ${EASE}` }} />\n          </button>\n        ) : (\n          <span className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n        )}\n        <span className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4\" aria-hidden=\"true\">\n          {actions.current.icon(node, { expanded: open, hasChildren })}\n        </span>\n        {renaming ? (\n          <input\n            autoFocus\n            defaultValue={node.title}\n            aria-label={`Rename ${node.title}`}\n            onFocus={(e) => e.currentTarget.select()}\n            onClick={(e) => e.stopPropagation()}\n            onKeyDown={(e) => {\n              e.stopPropagation();\n              if (e.key === \"Enter\") actions.current.commitRename(node, e.currentTarget.value);\n              if (e.key === \"Escape\") actions.current.cancelRename();\n            }}\n            onBlur={(e) => actions.current.commitRename(node, e.currentTarget.value)}\n            className=\"-ml-1 h-6 min-w-0 flex-1 rounded-[6px] bg-background px-1 text-[13.5px] font-normal outline-none shadow-[inset_0_0_0_1px_var(--ring)] [@media(pointer:coarse)]:text-[16px]\"\n          />\n        ) : (\n          <span className=\"min-w-0 flex-1 truncate\">{node.title}</span>\n        )}\n        {!renaming && (canAdd || canDelete) && (\n          // Shown on hover and focus; on touch screens, on the chosen row.\n          <span className={`flex shrink-0 items-center opacity-0 transition-opacity duration-150 group-hover/row:opacity-100 group-focus-within/row:opacity-100 ${selected ? \"[@media(pointer:coarse)]:opacity-100\" : \"[@media(pointer:coarse)]:hidden\"}`}>\n            {canDelete && (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={`Delete ${node.title}`}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  actions.current.remove(node);\n                }}\n                className=\"flex size-6 cursor-pointer items-center justify-center rounded-[6px] text-muted-foreground hover:bg-accent hover:text-foreground\"\n              >\n                <Trash2 className=\"size-3.5\" strokeWidth={1.75} />\n              </button>\n            )}\n            {canAdd && holds && (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={`Add inside ${node.title}`}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  actions.current.add(node);\n                }}\n                className=\"flex size-6 cursor-pointer items-center justify-center rounded-[6px] text-muted-foreground hover:bg-accent hover:text-foreground\"\n              >\n                <Plus className=\"size-3.5\" strokeWidth={1.75} />\n              </button>\n            )}\n          </span>\n        )}\n      </div>\n    </li>\n  );\n}\n\nconst TreeRow = React.memo(TreeRowView) as typeof TreeRowView;\n\nconst defaultIcon = (node: TreeNode, { expanded, hasChildren }: { expanded: boolean; hasChildren: boolean }) =>\n  node.leaf ? <File strokeWidth={1.75} /> : expanded && hasChildren ? <FolderOpen strokeWidth={1.75} /> : <Folder strokeWidth={1.75} />;\n\nexport function TreeView<T extends TreeNode>({\n  nodes: controlled,\n  defaultNodes,\n  onNodesChange,\n  expanded: expandedProp,\n  defaultExpanded = NO_IDS,\n  onExpandedChange,\n  selected: selectedProp,\n  defaultSelected = null,\n  onSelect,\n  icon = defaultIcon,\n  canDrop,\n  onMove,\n  onRename,\n  onCreate,\n  onDelete,\n  addLabel = \"page\",\n  undoFor = 5000,\n  indent = 14,\n  label = \"Pages\",\n  className = \"\",\n  ...props\n}: TreeViewProps<T>) {\n  const reduced = useReducedMotion();\n  const uid = React.useId();\n  const [own, setOwn] = React.useState<T[]>(defaultNodes ?? []);\n  const nodes = controlled ?? own;\n  const nodesRef = React.useRef(nodes);\n  nodesRef.current = nodes;\n  const [openOwn, setOpenOwn] = React.useState<string[]>(defaultExpanded);\n  const openIds = expandedProp ?? openOwn;\n  const open = React.useMemo(() => new Set(openIds), [openIds]);\n  const openRef = React.useRef(open);\n  openRef.current = open;\n  const [selOwn, setSelOwn] = React.useState<string | null>(defaultSelected);\n  const selected = selectedProp === undefined ? selOwn : selectedProp;\n  const [focusId, setFocusId] = React.useState<string | null>(null);\n  const [lifted, setLifted] = React.useState<string | null>(null);\n  const [renaming, setRenaming] = React.useState<string | null>(null);\n  const [draft, setDraft] = React.useState<{ parent: string | null; key: number } | null>(null);\n  const [note, setNote] = React.useState<Note | null>(null);\n  const [undo, setUndo] = React.useState<Undo<T> | null>(null);\n  const [holdUndo, setHoldUndo] = React.useState(false);\n  const [announce, setAnnounce] = React.useState(\"\");\n\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const previewRef = React.useRef<HTMLDivElement>(null);\n  const reasonRef = React.useRef<HTMLParagraphElement>(null);\n  const lineRef = React.useRef<HTMLDivElement>(null);\n  const ringRef = React.useRef<HTMLDivElement>(null);\n  /** Rows that should glide in from somewhere other than their old place (a drop, a depth change). */\n  const landing = React.useRef<{ id?: string; from?: { x: number; y: number }; depths: Map<string, { depth: number; top: number }> } | null>(null);\n  const sprung = React.useRef<Set<string>>(new Set());\n  const refocus = React.useRef<string | null>(null);\n  const typed = React.useRef({ text: \"\", at: 0 });\n  const renamingRef = React.useRef<string | null>(null);\n\n  const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text));\n  const warn = (text: string, tone: Note[\"tone\"] = \"warn\") => {\n    const key = Date.now();\n    setNote({ text, tone, key });\n    window.setTimeout(() => setNote((n) => (n?.key === key ? null : n)), 3600);\n  };\n  const byId = React.useCallback((id: string | null) => (id ? nodesRef.current.find((n) => n.id === id) ?? null : null), []);\n\n  const commit = (next: T[]) => {\n    nodesRef.current = next;\n    if (controlled === undefined) setOwn(next);\n    onNodesChange?.(next);\n  };\n  const setOpenIds = (next: Set<string>) => {\n    openRef.current = next;\n    const ids = [...next];\n    if (expandedProp === undefined) setOpenOwn(ids);\n    onExpandedChange?.(ids);\n  };\n  const select = (node: T) => {\n    if (selectedProp === undefined) setSelOwn(node.id);\n    onSelect?.(node);\n  };\n\n  // ── The rows in view ───────────────────────────────────\n  const rows = React.useMemo(() => {\n    const kids = new Map<string | null, T[]>();\n    for (const n of nodes) {\n      const list = kids.get(n.parent);\n      if (list) list.push(n);\n      else kids.set(n.parent, [n]);\n    }\n    const out: Row<T>[] = [];\n    const walk = (parent: string | null, depth: number) => {\n      const list = kids.get(parent) ?? [];\n      list.forEach((node, i) => {\n        const hasChildren = (kids.get(node.id)?.length ?? 0) > 0;\n        const isOpen = !node.leaf && open.has(node.id);\n        out.push({ kind: \"node\", node, depth, hasChildren, open: isOpen, pos: i + 1, size: list.length });\n        if (!isOpen) return;\n        walk(node.id, depth + 1);\n        if (draft?.parent === node.id) out.push({ kind: \"draft\", parent: node.id, depth: depth + 1 });\n        else if (!hasChildren) out.push({ kind: \"empty\", parent: node.id, depth: depth + 1 });\n      });\n    };\n    walk(null, 0);\n    if (draft && draft.parent === null) out.push({ kind: \"draft\", parent: null, depth: 0 });\n    return out;\n  }, [nodes, open, draft]);\n  const nodeRows = rows.filter((r): r is Extract<Row<T>, { kind: \"node\" }> => r.kind === \"node\");\n  const tabId = (focusId && nodeRows.some((r) => r.node.id === focusId) ? focusId : null) ?? (selected && nodeRows.some((r) => r.node.id === selected) ? selected : nodeRows[0]?.node.id);\n\n  useRowMotion(listRef, { reduced, ranks: NO_RANKS, ghosts: ghostRef });\n\n  // After a drop or a keyboard move: the moved row comes from where it was let go, rows that changed depth slide sideways.\n  React.useLayoutEffect(() => {\n    const l = landing.current;\n    const list = listRef.current;\n    landing.current = null;\n    if (!l || !list || reduced) return;\n    list.querySelectorAll<HTMLElement>(\"[data-row-id][data-depth]\").forEach((el) => {\n      const id = el.dataset.rowId!;\n      const was = l.depths.get(id);\n      if (!was) return;\n      const depth = Number(el.dataset.depth);\n      if (id === l.id && l.from) {\n        el.animate([{ transform: `translate(${l.from.x + (was.depth - depth) * indent}px, ${l.from.y - was.top}px)` }, { transform: \"translate(0px, 0px)\" }], { duration: GLIDE, easing: EASE, composite: \"add\" });\n        return;\n      }\n      if (was.depth !== depth) el.querySelector<HTMLElement>(\"[data-row-content]\")?.animate([{ transform: `translateX(${(was.depth - depth) * indent}px)` }, { transform: \"translateX(0px)\" }], { duration: GLIDE, easing: EASE, composite: \"add\" });\n    });\n  });\n\n  // Keep focus on a row that moved (React re-inserts it) or was renamed.\n  React.useLayoutEffect(() => {\n    const id = refocus.current;\n    if (!id) return;\n    refocus.current = null;\n    listRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${CSS.escape(id)}\"]`)?.focus({ preventScroll: false });\n  });\n\n  const snapshot = () => {\n    const depths = new Map<string, { depth: number; top: number }>();\n    listRef.current?.querySelectorAll<HTMLElement>(\"[data-row-id][data-depth]\").forEach((el) => depths.set(el.dataset.rowId!, { depth: Number(el.dataset.depth), top: el.offsetTop }));\n    return depths;\n  };\n\n  /** Why a row can't go into parent, or null. */\n  const refusal = (node: T, parent: T | null) => {\n    if (parent?.leaf) return `${parent.title} can't hold other rows`;\n    if (parent && (parent.id === node.id || under(nodesRef.current, node.id).has(parent.id))) return `Can't move ${node.title} inside itself`;\n    return (canDrop?.(node, parent) as string | null | undefined) || null;\n  };\n\n  /** Moves a row (optimistically) and saves it; a failed save puts everything back. */\n  const moveTo = async (node: T, parent: string | null, after: string | null | \"end\", from?: { x: number; y: number }) => {\n    const previous = nodesRef.current;\n    const next = place(previous, node.id, parent, after);\n    landing.current = { id: node.id, from, depths: snapshot() };\n    // The row stays in view: its new parent opens. Rows that only opened while it was carried close again.\n    const ids = new Set(openRef.current);\n    sprung.current.forEach((id) => id !== parent && ids.delete(id));\n    sprung.current.clear();\n    if (parent) ids.add(parent);\n    setOpenIds(ids);\n    commit(next);\n    const parentNode = byId(parent);\n    const sibs = next.filter((n) => n.parent === parent);\n    const index = sibs.findIndex((n) => n.id === node.id);\n    say(`Moved ${node.title} ${parentNode ? `into ${parentNode.title}` : \"to the top level\"}, ${index + 1} of ${sibs.length}.`);\n    if (!onMove) return;\n    try {\n      await onMove({ ...node, parent } as T, parentNode, index);\n    } catch {\n      landing.current = { depths: snapshot() };\n      commit(place(nodesRef.current, node.id, node.parent, (() => {\n        const before = previous.filter((n) => n.parent === node.parent);\n        const i = before.findIndex((n) => n.id === node.id);\n        return i > 0 ? before[i - 1].id : null;\n      })()));\n      warn(`Couldn't move ${node.title}. It's back.`, \"error\");\n      say(`Couldn't move ${node.title}. It's back where it was.`);\n      window.setTimeout(() => {\n        if (reduced) return;\n        listRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${CSS.escape(node.id)}\"] [data-row-content]`)?.animate(\n          [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n          { duration: 380, easing: \"ease-out\" },\n        );\n      }, GLIDE);\n    }\n  };\n\n  // ── Carrying ───────────────────────────────────────────\n  const drag = useTreeDrag({\n    list: listRef,\n    scroller: scrollerRef,\n    preview: previewRef,\n    reason: reasonRef,\n    line: lineRef,\n    ring: ringRef,\n    indent,\n    inset: INSET,\n    check: (id, target) => {\n      const node = byId(id) as T | null;\n      if (!node) return { same: true, refused: null };\n      const parent = byId(target.parent) as T | null;\n      const next = place(nodesRef.current, id, target.parent, target.into ? \"end\" : target.after);\n      const was = nodesRef.current.filter((n) => n.parent === node.parent).findIndex((n) => n.id === id);\n      const now = next.filter((n) => n.parent === target.parent).findIndex((n) => n.id === id);\n      return { same: target.parent === node.parent && was === now, refused: refusal(node, parent) };\n    },\n    onLift: (id) => {\n      setLifted(id);\n      setRenaming(null);\n      const node = byId(id);\n      if (node) say(`Carrying ${node.title}.`);\n    },\n    onSpring: (id) => {\n      sprung.current.add(id);\n      const ids = new Set(openRef.current);\n      ids.add(id);\n      setOpenIds(ids);\n    },\n    onDrop: (id, target, from) => {\n      setLifted(null);\n      const node = byId(id) as T | null;\n      if (node) moveTo(node, target.parent, target.into ? \"end\" : target.after, from);\n    },\n    onCancel: (id, refused) => {\n      setLifted(null);\n      if (sprung.current.size) {\n        const ids = new Set(openRef.current);\n        sprung.current.forEach((x) => ids.delete(x));\n        sprung.current.clear();\n        setOpenIds(ids);\n      }\n      if (refused) {\n        warn(refused);\n        say(`${byId(id)?.title ?? \"The row\"} went back. ${refused}.`);\n      }\n    },\n    reduced,\n    disabled: !!renaming || !!draft,\n  });\n\n  // The copy appears under the pointer; rows that opened mid-carry are measured again.\n  React.useLayoutEffect(() => {\n    if (lifted) drag.sync();\n  }, [lifted, drag]);\n  React.useLayoutEffect(() => {\n    drag.remeasure();\n  }, [open, drag]);\n\n  // ── Opening, renaming, adding, deleting ────────────────\n  const toggle = (node: T, to?: boolean) => {\n    if (node.leaf) return;\n    const ids = new Set(openRef.current);\n    const on = to ?? !ids.has(node.id);\n    if (on) ids.add(node.id);\n    else ids.delete(node.id);\n    setOpenIds(ids);\n  };\n\n  const commitRename = async (node: T, value: string) => {\n    if (renamingRef.current !== node.id) return;\n    renamingRef.current = null;\n    setRenaming(null);\n    refocus.current = node.id;\n    const title = value.trim();\n    if (!title || title === node.title) return;\n    commit(nodesRef.current.map((n) => (n.id === node.id ? ({ ...n, title } as T) : n)));\n    say(`Renamed to ${title}.`);\n    try {\n      await onRename?.(node, title);\n    } catch {\n      commit(nodesRef.current.map((n) => (n.id === node.id ? ({ ...n, title: node.title } as T) : n)));\n      warn(`Couldn't rename ${node.title}.`, \"error\");\n    }\n  };\n\n  const startRename = (node: T) => {\n    if (!onRename) return;\n    renamingRef.current = node.id;\n    setRenaming(node.id);\n  };\n\n  const addInside = (parent: T | null) => {\n    if (!onCreate) return;\n    if (parent) toggle(parent, true);\n    setDraft({ parent: parent?.id ?? null, key: Date.now() });\n  };\n\n  const finishDraft = async (title: string) => {\n    const d = draft;\n    if (!d) return;\n    const name = title.trim();\n    setDraft(null);\n    if (!name || !onCreate) return;\n    try {\n      const made = await onCreate(byId(d.parent) as T | null, name);\n      const node = { ...made, parent: d.parent } as T;\n      commit([...nodesRef.current, node]);\n      select(node);\n      refocus.current = made.id;\n      say(`Added ${made.title}.`);\n    } catch {\n      warn(`Couldn't add “${name}”.`, \"error\");\n    }\n  };\n\n  const remove = (node: T) => {\n    if (!onDelete) return;\n    if (undo) finishUndo(undo);\n    const gone = new Set([node.id, ...under(nodesRef.current, node.id)]);\n    const previous = nodesRef.current;\n    const i = nodeRows.findIndex((r) => r.node.id === node.id);\n    const nextFocus = nodeRows.slice(i + 1).find((r) => !gone.has(r.node.id)) ?? nodeRows.slice(0, i).reverse()[0];\n    commit(previous.filter((n) => !gone.has(n.id)));\n    setUndo({ key: Date.now(), node, removed: previous.filter((n) => gone.has(n.id)), nodes: previous });\n    if (nextFocus) refocus.current = nextFocus.node.id;\n    say(`Deleted ${node.title}${gone.size > 1 ? ` and ${gone.size - 1} inside it` : \"\"}. Undo is below.`);\n  };\n\n  const takeBack = () => {\n    if (!undo) return;\n    const ids = new Set(undo.removed.map((n) => n.id));\n    // Back in their old places among whatever is there now.\n    const now = nodesRef.current;\n    const restored = undo.nodes.filter((n) => ids.has(n.id) || now.some((m) => m.id === n.id)).map((n) => (ids.has(n.id) ? n : now.find((m) => m.id === n.id)!));\n    commit([...restored, ...now.filter((n) => !restored.some((r) => r.id === n.id))]);\n    setUndo(null);\n    refocus.current = undo.node.id;\n    say(`${undo.node.title} is back.`);\n  };\n\n  const finishUndo = async (u: Undo<T>) => {\n    setUndo((x) => (x?.key === u.key ? null : x));\n    try {\n      await onDelete?.(u.node);\n    } catch {\n      commit([...nodesRef.current, ...u.removed]);\n      warn(`Couldn't delete ${u.node.title}. It's back.`, \"error\");\n    }\n  };\n\n  // ── Keys ───────────────────────────────────────────────\n  const focusRow = (id: string | undefined) => {\n    if (!id) return;\n    setFocusId(id);\n    listRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${CSS.escape(id)}\"]`)?.focus();\n  };\n\n  const onRowKey = (e: React.KeyboardEvent, node: T) => {\n    if (e.target !== e.currentTarget) return;\n    const i = nodeRows.findIndex((r) => r.node.id === node.id);\n    const row = nodeRows[i];\n    if (!row) return;\n    const sibs = nodesRef.current.filter((n) => n.parent === node.parent);\n    const si = sibs.findIndex((n) => n.id === node.id);\n\n    if (e.altKey && e.key.startsWith(\"Arrow\")) {\n      e.preventDefault();\n      let to: { parent: string | null; after: string | null | \"end\" } | null = null;\n      if (e.key === \"ArrowUp\" && si > 0) to = { parent: node.parent, after: si > 1 ? sibs[si - 2].id : null };\n      if (e.key === \"ArrowDown\" && si < sibs.length - 1) to = { parent: node.parent, after: sibs[si + 1].id };\n      if (e.key === \"ArrowRight\" && si > 0 && !sibs[si - 1].leaf) to = { parent: sibs[si - 1].id, after: \"end\" };\n      if (e.key === \"ArrowLeft\" && node.parent) to = { parent: byId(node.parent)?.parent ?? null, after: node.parent };\n      if (!to) return;\n      const why = refusal(node, byId(to.parent) as T | null);\n      if (why) {\n        warn(why);\n        say(why);\n        return;\n      }\n      refocus.current = node.id;\n      moveTo(node, to.parent, to.after);\n      return;\n    }\n\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        focusRow(nodeRows[i + 1]?.node.id);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        focusRow(nodeRows[i - 1]?.node.id);\n        return;\n      case \"Home\":\n        e.preventDefault();\n        focusRow(nodeRows[0]?.node.id);\n        return;\n      case \"End\":\n        e.preventDefault();\n        focusRow(nodeRows[nodeRows.length - 1]?.node.id);\n        return;\n      case \"ArrowRight\":\n        e.preventDefault();\n        if (!node.leaf && !row.open) toggle(node, true);\n        else if (row.open && row.hasChildren) focusRow(nodeRows[i + 1]?.node.id);\n        return;\n      case \"ArrowLeft\":\n        e.preventDefault();\n        if (row.open) toggle(node, false);\n        else if (node.parent) focusRow(node.parent);\n        return;\n      case \"Enter\":\n      case \" \":\n        e.preventDefault();\n        select(node);\n        return;\n      case \"F2\":\n        e.preventDefault();\n        startRename(node);\n        return;\n      case \"Delete\":\n      case \"Backspace\":\n        if (!onDelete) return;\n        e.preventDefault();\n        remove(node);\n        return;\n    }\n    // Type to jump: the next row whose title starts with what was typed.\n    if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n      const now = Date.now();\n      typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      const t = typed.current.text;\n      const order = [...nodeRows.slice(i + (t.length === 1 ? 1 : 0)), ...nodeRows.slice(0, i + (t.length === 1 ? 1 : 0))];\n      focusRow(order.find((r) => r.node.title.toLowerCase().startsWith(t))?.node.id);\n    }\n  };\n\n  // What the memoised rows call, through one ref, so a row never re-renders just because these were re-created.\n  const actions = React.useRef<RowActions<T>>(null!);\n  actions.current = {\n    click: (e, node) => {\n      if (drag.swallowClick() || renaming === node.id) return;\n      if (e.detail > 1) return;\n      setFocusId(node.id);\n      select(node);\n    },\n    toggle: (node) => toggle(node),\n    key: onRowKey,\n    focus: (node) => setFocusId(node.id),\n    rename: startRename,\n    commitRename,\n    cancelRename: () => {\n      if (renamingRef.current) refocus.current = renamingRef.current;\n      renamingRef.current = null;\n      setRenaming(null);\n    },\n    add: (node) => addInside(node),\n    remove,\n    icon: icon as RowActions<T>[\"icon\"],\n  };\n\n  const liftedRow = lifted ? nodeRows.find((r) => r.node.id === lifted) : undefined;\n  const liftedCount = lifted ? under(nodes, lifted).size : 0;\n\n  return (\n    <div className={`@container flex flex-col text-foreground ${className}`} {...props}>\n      <div ref={scrollerRef} className=\"relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [scrollbar-width:thin]\">\n        <ul ref={listRef} role=\"tree\" aria-label={label} aria-describedby={`${uid}-how`} {...drag.handlers} className=\"relative flex flex-col py-0.5\">\n          {rows.map((r) => {\n            if (r.kind === \"empty\")\n              return (\n                <li key={`${r.parent}::empty`} data-row-id={`${r.parent}::empty`} role=\"none\" className=\"flex shrink-0 items-center text-[12.5px] text-muted-foreground\" style={{ height: ROW_H, paddingLeft: INSET + r.depth * indent }}>\n                  Nothing inside\n                </li>\n              );\n            if (r.kind === \"draft\")\n              return (\n                <li key={`draft-${draft?.key}`} data-row-id=\"__draft\" role=\"none\" className=\"shrink-0\" style={{ height: ROW_H }}>\n                  <div className=\"flex h-full items-center gap-1.5 pr-1\" style={{ paddingLeft: 4 + r.depth * indent }}>\n                    <span className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n                    <span className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4\" aria-hidden=\"true\">\n                      <File strokeWidth={1.75} />\n                    </span>\n                    <input\n                      autoFocus\n                      placeholder={`New ${addLabel}`}\n                      aria-label={`New ${addLabel}${r.parent ? ` in ${byId(r.parent)?.title}` : \"\"}`}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") finishDraft(e.currentTarget.value);\n                        if (e.key === \"Escape\") {\n                          setDraft(null);\n                          if (r.parent) refocus.current = r.parent;\n                        }\n                      }}\n                      onBlur={(e) => finishDraft(e.currentTarget.value)}\n                      className=\"-ml-1 h-6 min-w-0 flex-1 rounded-[6px] bg-background px-1 text-[13.5px] outline-none shadow-[inset_0_0_0_1px_var(--ring)] placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n                    />\n                  </div>\n                </li>\n              );\n            const id = r.node.id;\n            return (\n              <TreeRow\n                key={id}\n                node={r.node}\n                depth={r.depth}\n                hasChildren={r.hasChildren}\n                open={r.open}\n                pos={r.pos}\n                size={r.size}\n                selected={selected === id}\n                tab={tabId === id}\n                dimmed={!!lifted && (lifted === id || (liftedRow ? isUnder(rows, liftedRow, r) : false))}\n                renaming={renaming === id}\n                canAdd={!!onCreate}\n                canDelete={!!onDelete}\n                canRename={!!onRename}\n                indent={indent}\n                reduced={reduced}\n                actions={actions}\n              />\n            );\n          })}\n\n          {/* Where it lands: a line from the depth it would take, or a ring round the row it would go inside. */}\n          <div ref={lineRef} aria-hidden=\"true\" className=\"pointer-events-none absolute left-0 top-0 h-0.5 opacity-0\" style={{ transition: reduced ? \"none\" : \"transform 90ms ease-out, opacity 120ms ease-out\" }}>\n            <span className=\"absolute inset-0 rounded-full bg-current\" />\n            <span className=\"absolute -left-1 -top-[3px] size-2 rounded-full border-[1.5px] border-current bg-background\" />\n          </div>\n          <div ref={ringRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[8px] opacity-0\" style={{ boxShadow: \"inset 0 0 0 1.5px currentColor\", background: \"color-mix(in oklab, currentColor 8%, transparent)\", transition: reduced ? \"none\" : \"opacity 120ms ease-out\" }} />\n\n          {/* The copy you carry. */}\n          {liftedRow && (\n            <div ref={previewRef} aria-hidden=\"true\" className=\"pointer-events-none absolute left-0 top-0 z-10 w-full will-change-transform\">\n              <div className=\"flex items-center gap-1.5 rounded-[8px] bg-background pr-2 text-[13.5px] font-medium shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_22%,transparent)]\" style={{ height: ROW_H, marginLeft: liftedRow.depth * indent, paddingLeft: 4, width: `min(100% - ${liftedRow.depth * indent}px, 260px)` }}>\n                <span className=\"size-5 shrink-0\" />\n                <span className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4\">{icon(liftedRow.node, { expanded: false, hasChildren: liftedRow.hasChildren })}</span>\n                <span className=\"min-w-0 flex-1 truncate\">{liftedRow.node.title}</span>\n                {liftedCount > 0 && <span className=\"shrink-0 rounded-full px-1.5 text-[11px] font-normal tabular-nums text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">+{liftedCount}</span>}\n              </div>\n              <p ref={reasonRef} hidden className=\"mt-1 text-[11.5px] font-normal text-amber-700 dark:text-amber-400\" style={{ marginLeft: liftedRow.depth * indent + 30 }} />\n            </div>\n          )}\n\n          {/* Rows that went: their own elements, folding away where they stood (see use-row-motion). */}\n          <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n        </ul>\n      </div>\n\n      {onCreate && (\n        <button\n          type=\"button\"\n          onClick={() => addInside(null)}\n          className={`mt-1 flex h-[30px] shrink-0 cursor-pointer items-center gap-1.5 rounded-[8px] pl-1 text-[13px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground ${FOCUS}`}\n        >\n          <span className=\"flex size-5 items-center justify-center\" aria-hidden=\"true\">\n            <Plus className=\"size-3.5\" strokeWidth={1.75} />\n          </span>\n          New {addLabel}\n        </button>\n      )}\n\n      {/* One line under the tree: Undo while a delete can still be taken back, or what went wrong. */}\n      <div className=\"grid shrink-0 transition-[grid-template-rows] duration-300\" style={{ gridTemplateRows: undo || note ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }}>\n        <div className=\"min-h-0 overflow-hidden\">\n          {undo ? (\n            <div className=\"relative mt-1.5 flex h-8 items-center gap-2 rounded-[8px] pl-2.5 pr-1 text-[12.5px] shadow-[inset_0_0_0_1px_var(--border)]\" onPointerEnter={() => setHoldUndo(true)} onPointerLeave={() => setHoldUndo(false)}>\n              <span className=\"min-w-0 flex-1 truncate\">Deleted “{undo.node.title}”</span>\n              <button type=\"button\" onClick={takeBack} className={`inline-flex h-6 shrink-0 cursor-pointer items-center rounded-[6px] px-2 font-medium hover:bg-accent ${FOCUS}`}>\n                Undo\n              </button>\n              <span\n                key={undo.key}\n                aria-hidden=\"true\"\n                className=\"absolute inset-x-2 bottom-0 h-px origin-left bg-foreground/40\"\n                style={{ animation: `ui-progress ${undoFor}ms linear reverse forwards`, animationPlayState: holdUndo ? \"paused\" : \"running\" }}\n                onAnimationEnd={() => finishUndo(undo)}\n              />\n            </div>\n          ) : (\n            <Line text={note?.text ?? \"\"} className={`px-2 pt-1.5 text-[12px] ${note?.tone === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-amber-700 dark:text-amber-400\"}`} />\n          )}\n        </div>\n      </div>\n\n      <p id={`${uid}-how`} className=\"sr-only\">\n        Arrow keys move between rows and open them. Alt with the arrow keys moves a row up, down, into the row above or out of its parent. F2 renames, Delete deletes. Drag a row to move it; on a touch screen, hold it first.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n\n/** Is `row` inside `parent` (a contiguous run of deeper rows after it)? */\nfunction isUnder<T extends TreeNode>(rows: Row<T>[], parent: Row<T>, row: Row<T>) {\n  const a = rows.indexOf(parent);\n  const b = rows.indexOf(row);\n  if (b <= a) return false;\n  for (let k = a + 1; k <= b; k++) if (rows[k].depth <= parent.depth) return false;\n  return true;\n}\n"},{"path":"registry/use-tree-drag.ts","type":"registry:component","content":"// Hairline UI · tree-view · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TREE DRAG: carry a row (and everything inside it) to a new\n * place and a new depth\n *\n *   lift      after 4px (or a hold on touch) a copy of the row\n *             follows the pointer; the row and its children dim\n *             where they are, so nothing under the pointer moves\n *   slot      up and down picks the gap between two rows; the\n *             line marking it starts at the depth it will land\n *   depth     sideways picks the depth, one level per indent,\n *             within what that gap allows\n *   into      the middle of a row that holds others means\n *             \"inside it, at the end\": the row takes a ring;\n *             a closed one opens itself after a moment\n *   scroll    near the edges of the scroller (or the window),\n *             they scroll\n *\n * Nothing re-renders while you carry: the copy, the line and the\n * ring are moved by transforms, and rows are measured once at\n * pickup (again only when a folder opens). Rows are found by\n * data-row-id with data-depth, data-parent, data-can-hold,\n * data-expanded and data-has-children.\n * ───────────────────────────────────────────────────────── */\n\n/** Where a carried row would land: in `parent`, after the sibling `after` (null: first), or `into` it at the end. */\nexport type TreeTarget = { parent: string | null; after: string | null; into: boolean };\n\ntype RowBox = { id: string; top: number; height: number; depth: number; parent: string | null; canHold: boolean; expanded: boolean; hasChildren: boolean };\n\ntype Options = {\n  list: React.RefObject<HTMLElement | null>;\n  scroller: React.RefObject<HTMLElement | null>;\n  preview: React.RefObject<HTMLElement | null>;\n  reason: React.RefObject<HTMLElement | null>;\n  line: React.RefObject<HTMLElement | null>;\n  ring: React.RefObject<HTMLElement | null>;\n  indent: number;\n  /** Where depth 0's content starts, px from the list's left. */\n  inset: number;\n  /** Would this change nothing, and why does the parent refuse it (or null)? */\n  check: (id: string, target: TreeTarget) => { same: boolean; refused: string | null };\n  onLift: (id: string) => void;\n  /** A closed row held over for a moment: open it. */\n  onSpring: (id: string) => void;\n  /** Dropped. `from` is where the copy was (x: how far it was carried sideways, y: its top in list coordinates). */\n  onDrop: (id: string, target: TreeTarget, from: { x: number; y: number }) => void;\n  /** Put back (refused, or dropped where it was). Called once the copy is home. */\n  onCancel: (id: string, refused: string | null) => void;\n  reduced: boolean;\n  disabled?: boolean;\n};\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst HOLD = 300;\nconst SPRING = 650;\nconst EDGE = 36;\n\nexport function useTreeDrag(o: Options) {\n  const opts = React.useRef(o);\n  opts.current = o;\n  const drag = React.useRef<{\n    id: string;\n    pointerId: number;\n    pointerType: string;\n    /** Takes the pointer once the carry starts (not before, or the row's click would go to it). */\n    captor: HTMLElement;\n    x0: number;\n    y0: number;\n    x: number;\n    y: number;\n    /** The list's top on screen at pickup, and the scroll positions then. */\n    top0: number;\n    scroll0: number;\n    page0: number;\n    rows: RowBox[];\n    block: Set<string>;\n    depth0: number;\n    rowTop: number;\n    active: boolean;\n    timer: number;\n    key: string;\n    target: TreeTarget | null;\n    refused: string | null;\n    spring: string | null;\n    springTimer: number;\n    box: { top: number; bottom: number };\n  } | null>(null);\n  const loop = React.useRef(0);\n  const scroll = React.useRef({ top: 0, page: 0 });\n  const swallowClick = React.useRef(false);\n\n  /** The pointer's y in list coordinates, from positions kept since pickup (no layout reads). */\n  const listY = () => {\n    const d = drag.current!;\n    return d.y - d.top0 + (scroll.current.top - d.scroll0) + (scroll.current.page - d.page0);\n  };\n\n  const write = () => {\n    const d = drag.current;\n    const p = opts.current.preview.current;\n    if (!d?.active || !p) return;\n    p.style.transform = `translate3d(${d.x - d.x0}px, ${d.rowTop + listY() - (d.y0 - d.top0)}px, 0)`;\n  };\n\n  const measure = () => {\n    const d = drag.current;\n    const list = opts.current.list.current;\n    if (!d || !list) return;\n    d.rows = [...list.querySelectorAll<HTMLElement>(\"[data-row-id][data-depth]\")].map((el) => ({\n      id: el.dataset.rowId!,\n      top: el.offsetTop,\n      height: el.offsetHeight,\n      depth: Number(el.dataset.depth),\n      parent: el.dataset.parent || null,\n      canHold: el.dataset.canHold === \"1\",\n      expanded: el.dataset.expanded === \"1\",\n      hasChildren: el.dataset.hasChildren === \"1\",\n    }));\n    // The carried row and every row under it that's deeper: they travel together and can't be dropped into.\n    const i = d.rows.findIndex((r) => r.id === d.id);\n    d.block = new Set();\n    if (i < 0) return;\n    d.rowTop = d.rows[i].top;\n    d.depth0 = d.rows[i].depth;\n    for (let j = i; j < d.rows.length && (j === i || d.rows[j].depth > d.depth0); j++) d.block.add(d.rows[j].id);\n  };\n\n  const show = (el: HTMLElement | null, on: boolean) => {\n    if (el) el.style.opacity = on ? \"1\" : \"0\";\n  };\n\n  const arrange = () => {\n    const d = drag.current!;\n    const { indent, inset, line, ring, reason } = opts.current;\n    const y = listY();\n    const others = d.rows.filter((r) => !d.block.has(r.id));\n    const own = d.rows.filter((r) => d.block.has(r.id));\n    const ownTop = own[0]?.top ?? 0;\n    const ownBottom = own.length ? own[own.length - 1].top + own[own.length - 1].height : 0;\n\n    let target: TreeTarget | null = null;\n    let into: RowBox | null = null;\n    let lineY = 0;\n    let depth = 0;\n    if (!(y >= ownTop && y < ownBottom)) {\n      const i = others.findIndex((r) => y < r.top + r.height);\n      let slot = others.length;\n      if (i >= 0) {\n        const r = others[i];\n        const at = (y - r.top) / r.height;\n        if (y < r.top || at < (r.canHold ? 0.25 : 0.5)) slot = i;\n        else if (r.canHold && at <= 0.75) into = r;\n        else slot = i + 1;\n      }\n      if (into) target = { parent: into.id, after: null, into: true };\n      else {\n        const above = others[slot - 1];\n        const below = others[slot];\n        const max = above ? above.depth + (above.canHold ? 1 : 0) : 0;\n        const min = below ? below.depth : 0;\n        depth = Math.max(min, Math.min(max, d.depth0 + Math.round((d.x - d.x0) / indent)));\n        if (!above) target = { parent: null, after: null, into: false };\n        else if (depth > above.depth) target = { parent: above.id, after: null, into: false };\n        else {\n          // Climb from the row above to its ancestor at that depth: the carried row goes right after it.\n          let k = others.indexOf(above);\n          let x = above;\n          while (x.depth > depth && k > 0) {\n            const want = x.depth - 1;\n            while (k > 0 && others[k].depth > want) k--;\n            x = others[k];\n          }\n          target = { parent: x.parent, after: x.id, into: false };\n        }\n        lineY = above ? above.top + above.height : below ? below.top : 0;\n      }\n    }\n\n    const key = target ? `${target.parent}|${target.after}|${target.into}|${depth}|${lineY}` : \"none\";\n    if (key === d.key) return;\n    d.key = key;\n    const verdict = target ? opts.current.check(d.id, target) : { same: true, refused: null };\n    d.target = target && !verdict.same ? target : null;\n    d.refused = d.target ? verdict.refused : null;\n\n    // The line between rows, or the ring round the row it would go inside.\n    const l = line.current;\n    const g = ring.current;\n    const amber = \"color-mix(in oklab, #f59e0b 80%, var(--foreground))\";\n    if (l) {\n      show(l, !!d.target && !into);\n      if (d.target && !into) {\n        const x = inset + depth * indent;\n        l.style.transform = `translate3d(${x}px, ${lineY - 1}px, 0)`;\n        l.style.width = `calc(100% - ${x + 6}px)`;\n        l.style.color = d.refused ? amber : \"var(--ring)\";\n      }\n    }\n    if (g) {\n      show(g, !!d.target && !!into);\n      if (d.target && into) {\n        g.style.transform = `translate3d(0, ${into.top}px, 0)`;\n        g.style.height = `${into.height}px`;\n        g.style.color = d.refused ? amber : \"var(--ring)\";\n      }\n    }\n    const r = reason.current;\n    if (r) {\n      r.textContent = d.refused ?? \"\";\n      r.hidden = !d.refused;\n    }\n\n    // Held over a closed row that has rows inside: it opens after a moment.\n    const spring = d.target && into && into.hasChildren && !into.expanded && !d.refused ? into.id : null;\n    if (spring !== d.spring) {\n      window.clearTimeout(d.springTimer);\n      d.spring = spring;\n      if (spring)\n        d.springTimer = window.setTimeout(() => {\n          if (drag.current?.spring === spring) opts.current.onSpring(spring);\n        }, SPRING);\n    }\n  };\n\n  const run = () => {\n    cancelAnimationFrame(loop.current);\n    const tick = () => {\n      const d = drag.current;\n      if (!d?.active) return;\n      const sc = opts.current.scroller.current;\n      const inner = d.y < d.box.top + EDGE ? -(d.box.top + EDGE - d.y) / EDGE : d.y > d.box.bottom - EDGE ? (d.y - (d.box.bottom - EDGE)) / EDGE : 0;\n      const page = d.y < EDGE ? -(EDGE - d.y) / EDGE : d.y > window.innerHeight - EDGE ? (d.y - (window.innerHeight - EDGE)) / EDGE : 0;\n      if (inner && sc && sc.scrollHeight > sc.clientHeight) sc.scrollTop = scroll.current.top + Math.max(-1, Math.min(1, inner)) * 10;\n      else if (page) window.scrollBy(0, Math.max(-1, Math.min(1, page)) * 10);\n      if (inner || page) {\n        write();\n        arrange();\n      }\n      loop.current = requestAnimationFrame(tick);\n    };\n    loop.current = requestAnimationFrame(tick);\n  };\n\n  const activate = () => {\n    const d = drag.current;\n    const list = opts.current.list.current;\n    if (!d || d.active || !list) return;\n    d.active = true;\n    if (d.pointerType === \"touch\") navigator.vibrate?.(8);\n    else\n      try {\n        d.captor.setPointerCapture(d.pointerId);\n      } catch {}\n    const sc = opts.current.scroller.current;\n    const box = (sc && sc.scrollHeight > sc.clientHeight ? sc : list).getBoundingClientRect();\n    d.box = { top: Math.max(0, box.top), bottom: Math.min(window.innerHeight, box.bottom) };\n    d.top0 = list.getBoundingClientRect().top;\n    measure();\n    opts.current.onLift(d.id);\n    run();\n  };\n\n  const finish = () => {\n    const d = drag.current;\n    window.clearTimeout(d?.timer);\n    window.clearTimeout(d?.springTimer);\n    cancelAnimationFrame(loop.current);\n    show(opts.current.line.current, false);\n    show(opts.current.ring.current, false);\n  };\n\n  const end = (dropped: boolean) => {\n    const d = drag.current;\n    if (!d) return;\n    finish();\n    if (!d.active) {\n      drag.current = null;\n      return;\n    }\n    swallowClick.current = true;\n    window.setTimeout(() => (swallowClick.current = false), 0);\n    if (dropped && d.target && !d.refused) {\n      const from = { x: d.x - d.x0, y: d.rowTop + listY() - (d.y0 - d.top0) };\n      drag.current = null;\n      opts.current.onDrop(d.id, d.target, from);\n      return;\n    }\n    // Back home: the copy glides to the row it came from, then the row takes over.\n    const refused = dropped ? d.refused : null;\n    const p = opts.current.preview.current;\n    drag.current = null;\n    if (p && !opts.current.reduced) {\n      const a = p.animate([{ transform: p.style.transform }, { transform: `translate3d(0px, ${d.rowTop}px, 0)` }], { duration: 300, easing: THROW, fill: \"forwards\" });\n      a.onfinish = a.oncancel = () => opts.current.onCancel(d.id, refused);\n    } else opts.current.onCancel(d.id, refused);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {\n    if (opts.current.disabled || drag.current || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const t = e.target as HTMLElement;\n    if (t.closest(\"button, input, a, textarea, [data-no-drag]\")) return;\n    const row = t.closest<HTMLElement>(\"[data-row-id][data-depth]\");\n    if (!row) return;\n    scroll.current = { top: opts.current.scroller.current?.scrollTop ?? 0, page: window.scrollY };\n    drag.current = {\n      id: row.dataset.rowId!,\n      pointerId: e.pointerId,\n      pointerType: e.pointerType,\n      captor: e.currentTarget,\n      x0: e.clientX,\n      y0: e.clientY,\n      x: e.clientX,\n      y: e.clientY,\n      top0: 0,\n      scroll0: scroll.current.top,\n      page0: scroll.current.page,\n      rows: [],\n      block: new Set(),\n      depth0: 0,\n      rowTop: 0,\n      active: false,\n      timer: 0,\n      key: \"\",\n      target: null,\n      refused: null,\n      spring: null,\n      springTimer: 0,\n      box: { top: 0, bottom: 0 },\n    };\n    if (e.pointerType === \"touch\") drag.current.timer = window.setTimeout(activate, HOLD);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {\n    const d = drag.current;\n    if (!d || d.pointerId !== e.pointerId) return;\n    if (!d.active) {\n      const moved = Math.hypot(e.clientX - d.x0, e.clientY - d.y0);\n      if (d.pointerType === \"touch\") {\n        if (moved > 8) end(false);\n        return;\n      }\n      if (moved < 4) return;\n      activate();\n    }\n    d.x = e.clientX;\n    d.y = e.clientY;\n    write();\n    arrange();\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLElement>) => {\n    if (drag.current?.pointerId === e.pointerId) end(true);\n  };\n  const onPointerCancel = (e: React.PointerEvent<HTMLElement>) => {\n    if (drag.current?.pointerId === e.pointerId) end(false);\n  };\n\n  // While a row is held on touch the page mustn't scroll under it; scroll positions are kept from events.\n  React.useEffect(() => {\n    const list = o.list.current;\n    const sc = o.scroller.current;\n    if (!list) return;\n    const touchMove = (e: TouchEvent) => {\n      if (drag.current?.active && e.cancelable) e.preventDefault();\n    };\n    const menu = (e: Event) => {\n      if (drag.current) e.preventDefault();\n    };\n    const onScroll = () => {\n      scroll.current.top = sc?.scrollTop ?? 0;\n    };\n    const onPage = () => {\n      scroll.current.page = window.scrollY;\n    };\n    list.addEventListener(\"touchmove\", touchMove, { passive: false });\n    list.addEventListener(\"contextmenu\", menu);\n    sc?.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"scroll\", onPage, { passive: true });\n    return () => {\n      list.removeEventListener(\"touchmove\", touchMove);\n      list.removeEventListener(\"contextmenu\", menu);\n      sc?.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"scroll\", onPage);\n    };\n  }, [o.list, o.scroller]);\n\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(loop.current);\n      window.clearTimeout(drag.current?.timer);\n      window.clearTimeout(drag.current?.springTimer);\n    },\n    [],\n  );\n\n  return {\n    handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },\n    /** After a row opened mid-carry: measure the rows again and find the slot under the pointer. */\n    remeasure: () => {\n      const d = drag.current;\n      if (!d?.active) return;\n      measure();\n      d.key = \"\";\n      arrange();\n    },\n    /** Puts the copy under the pointer (once it has rendered). */\n    sync: write,\n    /** A carry just ended: the click that follows it isn't a click. */\n    swallowClick: () => swallowClick.current,\n    ease: EASE,\n  };\n}\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · tree-view · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone folds away where it stood. Its\n *             copy is the row's own element, detached by React\n *             and moved into a layer React never renders into,\n *             so a leaving row costs no render at all\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\ntype Seen = { top: number; height: number; el: HTMLElement };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },\n) {\n  const seen = React.useRef(new Map<string, Seen>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, Seen>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));\n    return out;\n  }, [container]);\n\n  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */\n  const leave = (was: Seen, dy: number) => {\n    const layer = ghosts.current;\n    const el = was.el;\n    if (!layer || el.isConnected) return;\n    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any\n    // glide still on the element.\n    el.removeAttribute(\"data-row-id\");\n    el.removeAttribute(\"tabindex\");\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.inert = true;\n    Object.assign(el.style, { position: \"absolute\", left: \"0\", right: \"0\", top: `${was.top}px`, height: `${was.height}px`, margin: \"0\", overflow: \"hidden\" });\n    layer.appendChild(el);\n    const a = el.animate(\n      dy\n        ? [\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n          ]\n        : [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n          ],\n      { duration: dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n    );\n    a.onfinish = a.oncancel = () => el.remove();\n  };\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = seen.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    seen.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    after.forEach((now, id) => {\n      const row = now.el;\n      const was = before.get(id);\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        row.animate(\n          [\n            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n    before.forEach((was, id) => {\n      if (after.has(id)) return;\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);\n    });\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      seen.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\n    },\n  };\n}\n"}],"css":{"@keyframes ui-progress":{"from":{"transform":"scaleX(0)"},"to":{"transform":"scaleX(1)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/tree-view. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}