{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-tree",
  "title": "Sortable Tree",
  "description": "A tree whose rows reorder and nest by drag and drop, or from the keyboard with Alt and the arrow keys, with spoken feedback.",
  "dependencies": [
    "@dnd-kit/react"
  ],
  "registryDependencies": [
    "@tealmac/tree-view"
  ],
  "files": [
    {
      "path": "registry/tealmac/sortable-tree.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { DragDropProvider, PointerSensor, useDraggable } from \"@dnd-kit/react\"\nimport { GripVertical } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  findNode,\n  getParentId,\n  indentNode,\n  moveNode,\n  outdentNode,\n  shiftNode,\n  treeRowClasses,\n  TreeRowContent,\n  useTree,\n  type DropPosition,\n  type TreeNode,\n  type TreeRow,\n  type UseTreeOptions,\n} from \"@/components/ui/tree-view\"\n\n/**\n * Where a pointer at `y` lands on a row: the top quarter drops before it, the\n * bottom quarter after it, the middle inside it.\n */\nexport function getDropPosition(\n  rect: { top: number; height: number },\n  y: number\n): DropPosition {\n  const offset = (y - rect.top) / Math.max(rect.height, 1)\n  if (offset < 0.25) return \"before\"\n  if (offset > 0.75) return \"after\"\n  return \"inside\"\n}\n\ninterface DropHint {\n  targetId: string\n  position: DropPosition\n}\n\nexport interface SortableTreeProps<T = unknown>\n  extends\n    Omit<UseTreeOptions<T>, \"nodes\" | \"onRowKeyDown\" | \"scrollToIndex\">,\n    Omit<\n      React.ComponentProps<\"div\">,\n      \"onSelect\" | \"defaultValue\" | \"dir\" | \"onChange\"\n    > {\n  nodes: TreeNode<T>[]\n  /** Called with the new tree after a drop or a keyboard move. */\n  onNodesChange: (nodes: TreeNode<T>[]) => void\n  /** Return false to refuse a move (for example, files cannot hold children). */\n  canDrop?: (\n    source: TreeNode<T>,\n    target: TreeNode<T>,\n    position: DropPosition\n  ) => boolean\n  /** Milliseconds a dragged row must hover over a closed branch to open it. */\n  hoverOpenDelay?: number\n  indent?: number\n  guides?: boolean\n  renderLabel?: (row: TreeRow<T>) => React.ReactNode\n  rowClassName?: string\n}\n\n/** A tree whose rows can be dragged to reorder or nest, or moved with Alt+arrows. */\nexport function SortableTree<T = unknown>({\n  nodes,\n  onNodesChange,\n  canDrop,\n  hoverOpenDelay = 700,\n  indent = 16,\n  guides,\n  renderLabel,\n  rowClassName,\n  className,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  selected,\n  defaultSelected,\n  onSelectedChange,\n  selectionMode,\n  onActivate,\n  onItemToggle,\n  defaultFocusedId,\n  onFocusedChange,\n  dir,\n  ...props\n}: SortableTreeProps<T>) {\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const [hint, setHint] = React.useState<DropHint | null>(null)\n  const [draggingId, setDraggingId] = React.useState<string | null>(null)\n  const refocus = React.useRef<string | null>(null)\n  const descriptionId = React.useId()\n  const hoverTimer = React.useRef<number | undefined>(undefined)\n\n  const apply = (next: TreeNode<T>[], id: string, message: string) => {\n    if (next === nodes) return false\n    refocus.current = id\n    onNodesChange(next)\n    setAnnouncement(message)\n    return true\n  }\n\n  const tree = useTree<T>({\n    nodes,\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n    selected,\n    defaultSelected,\n    onSelectedChange,\n    selectionMode,\n    onActivate,\n    onItemToggle,\n    defaultFocusedId,\n    onFocusedChange,\n    dir,\n    onRowKeyDown: (event, row) => {\n      if (!event.altKey || row.node.disabled) return\n      const rtl =\n        (dir ??\n          (event.currentTarget as HTMLElement)\n            .closest(\"[dir]\")\n            ?.getAttribute(\"dir\")) === \"rtl\"\n      const key = event.key\n      const sibs = siblingsOf(nodes, row.id)\n      const at = sibs.findIndex((n) => n.id === row.id)\n      let next: TreeNode<T>[] | null = null\n      let message = \"\"\n      // Every keyboard move is checked with `canDrop`, like a drop is.\n      let aim: {\n        target: TreeNode<T> | undefined\n        position: DropPosition\n      } | null = null\n      if (key === \"ArrowUp\" || key === \"ArrowDown\") {\n        const up = key === \"ArrowUp\"\n        aim = {\n          target: sibs[at + (up ? -1 : 1)],\n          position: up ? \"before\" : \"after\",\n        }\n        next = shiftNode(nodes, row.id, up ? -1 : 1)\n        message = `${row.node.label} moved ${up ? \"up\" : \"down\"}.`\n      } else if (key === (rtl ? \"ArrowLeft\" : \"ArrowRight\")) {\n        aim = { target: sibs[at - 1], position: \"inside\" }\n        next = indentNode(nodes, row.id)\n        message = `${row.node.label} moved into the previous item.`\n      } else if (key === (rtl ? \"ArrowRight\" : \"ArrowLeft\")) {\n        const parentId = getParentId(nodes, row.id)\n        aim = {\n          target: parentId ? findNode(nodes, parentId) : undefined,\n          position: \"after\",\n        }\n        next = outdentNode(nodes, row.id)\n        message = `${row.node.label} moved out of its parent.`\n      }\n      if (!next) return\n      event.preventDefault()\n      if (\n        aim?.target &&\n        canDrop &&\n        !canDrop(row.node, aim.target, aim.position)\n      ) {\n        setAnnouncement(`${row.node.label} cannot move there.`)\n        return\n      }\n      if (\n        apply(next, row.id, message) &&\n        key === (rtl ? \"ArrowLeft\" : \"ArrowRight\")\n      ) {\n        const prevSibling = findPreviousSiblingId(nodes, row.id)\n        if (prevSibling) tree.setExpanded(prevSibling, true)\n      }\n    },\n  })\n\n  // Put focus back on the moved row once the tree has re-rendered.\n  React.useEffect(() => {\n    const id = refocus.current\n    if (!id) return\n    const index = tree.rows.findIndex((r) => r.id === id)\n    if (index >= 0) {\n      refocus.current = null\n      tree.focusRow(index)\n    }\n  })\n\n  React.useEffect(() => () => window.clearTimeout(hoverTimer.current), [])\n\n  const rootRef = tree.rootProps.ref\n  const locate = (y: number): DropHint | null => {\n    const root = rootRef.current\n    if (!root || draggingId === null) return null\n    const source = findNode(nodes, draggingId)\n    for (const el of root.querySelectorAll<HTMLElement>(\"[data-tree-id]\")) {\n      const rect = el.getBoundingClientRect()\n      if (y < rect.top || y > rect.bottom) continue\n      const targetId = el.dataset.treeId!\n      if (targetId === draggingId) return null\n      const target = findNode(nodes, targetId)\n      if (!source || !target) return null\n      const position = getDropPosition(rect, y)\n      if (canDrop && !canDrop(source, target, position)) return null\n      if (findNode(source.children ?? [], targetId)) return null\n      return { targetId, position }\n    }\n    return null\n  }\n\n  const { ref, ...root } = tree.rootProps\n  return (\n    <DragDropProvider\n      sensors={[PointerSensor]}\n      // The default accessibility plugin would give each row role=\"button\"\n      // semantics (\"draggable\"); the tree has its own roles and instructions.\n      plugins={(defaults) =>\n        defaults.filter((plugin) => {\n          const name = (plugin as { name?: string }).name\n          return name !== \"Accessibility\"\n        })\n      }\n      onDragStart={(event) => setDraggingId(String(event.operation.source?.id))}\n      onDragMove={(event) => {\n        const y = event.operation.position.current.y\n        const next = locate(y)\n        setHint((prev) =>\n          prev?.targetId === next?.targetId && prev?.position === next?.position\n            ? prev\n            : next\n        )\n        window.clearTimeout(hoverTimer.current)\n        if (next?.position === \"inside\") {\n          const row = tree.rows.find((r) => r.id === next.targetId)\n          if (row?.expandable && !row.expanded) {\n            hoverTimer.current = window.setTimeout(\n              () => tree.setExpanded(row.id, true),\n              hoverOpenDelay\n            )\n          }\n        }\n      }}\n      onDragEnd={(event) => {\n        window.clearTimeout(hoverTimer.current)\n        const sourceId = String(event.operation.source?.id)\n        const drop = event.canceled\n          ? null\n          : locate(event.operation.position.current.y)\n        setHint(null)\n        setDraggingId(null)\n        if (!drop) return\n        const target = findNode(nodes, drop.targetId)\n        const moved = moveNode(nodes, sourceId, drop.targetId, drop.position)\n        const label = findNode(nodes, sourceId)?.label ?? sourceId\n        apply(\n          moved,\n          sourceId,\n          `${label} moved ${drop.position === \"inside\" ? \"into\" : drop.position} ${target?.label}.`\n        )\n        if (drop.position === \"inside\") tree.setExpanded(drop.targetId, true)\n      }}\n    >\n      <div\n        ref={ref}\n        {...root}\n        {...props}\n        aria-describedby={\n          cn(props[\"aria-describedby\"], descriptionId) || undefined\n        }\n        className={cn(\"flex flex-col gap-px\", className)}\n      >\n        {tree.rows.map((row) => (\n          <SortableRow\n            key={row.id}\n            row={row}\n            tree={tree}\n            indent={indent}\n            guides={guides}\n            label={renderLabel?.(row)}\n            className={rowClassName}\n            hint={hint?.targetId === row.id ? hint.position : null}\n            dragging={draggingId === row.id}\n          />\n        ))}\n      </div>\n      <span id={descriptionId} className=\"sr-only\">\n        Drag a row to move it. Or press Alt with the arrow keys: up and down\n        reorder, one arrow nests it into the previous item, the other moves it\n        out.\n      </span>\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </DragDropProvider>\n  )\n}\n\nfunction siblingsOf<T>(nodes: TreeNode<T>[], id: string): TreeNode<T>[] {\n  const parentId = getParentId(nodes, id)\n  if (parentId === undefined) return []\n  return parentId === null ? nodes : (findNode(nodes, parentId)?.children ?? [])\n}\n\nfunction findPreviousSiblingId<T>(\n  nodes: TreeNode<T>[],\n  id: string\n): string | null {\n  const search = (list: TreeNode<T>[]): string | null => {\n    const at = list.findIndex((n) => n.id === id)\n    if (at > 0) return list[at - 1].id\n    for (const n of list) {\n      const found = n.children ? search(n.children) : null\n      if (found) return found\n    }\n    return null\n  }\n  return search(nodes)\n}\n\nfunction SortableRow<T>({\n  row,\n  tree,\n  indent,\n  guides,\n  label,\n  className,\n  hint,\n  dragging,\n}: {\n  row: TreeRow<T>\n  tree: ReturnType<typeof useTree<T>>\n  indent: number\n  guides?: boolean\n  label?: React.ReactNode\n  className?: string\n  hint: DropPosition | null\n  dragging: boolean\n}) {\n  const { ref, isDragging } = useDraggable({\n    id: row.id,\n    disabled: row.node.disabled,\n  })\n  const rowProps = tree.getRowProps(row)\n  return (\n    <div\n      ref={ref}\n      {...rowProps}\n      data-dragging={isDragging || dragging ? \"\" : undefined}\n      data-drop={hint ?? undefined}\n      className={cn(\n        treeRowClasses,\n        \"data-[drop=inside]:bg-primary/15 data-[drop=inside]:ring-primary/60 data-[dragging]:opacity-60 data-[drop=inside]:ring-2\",\n        \"data-[drop=after]:after:bg-primary data-[drop=after]:after:absolute data-[drop=after]:after:inset-x-1 data-[drop=after]:after:-bottom-px data-[drop=after]:after:h-0.5 data-[drop=after]:after:rounded-full\",\n        \"data-[drop=before]:before:bg-primary data-[drop=before]:before:absolute data-[drop=before]:before:inset-x-1 data-[drop=before]:before:-top-px data-[drop=before]:before:h-0.5 data-[drop=before]:before:rounded-full\",\n        className\n      )}\n    >\n      <TreeRowContent\n        row={row}\n        controller={tree}\n        indent={indent}\n        guides={guides}\n        label={label}\n        trailing={\n          row.node.disabled ? null : (\n            <GripVertical\n              aria-hidden\n              className=\"text-muted-foreground size-4 shrink-0 cursor-grab opacity-0 group-hover/tree-row:opacity-100 group-focus-visible/tree-row:opacity-100\"\n            />\n          )\n        }\n      />\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}