{
  "name": "use-drag-reorder",
  "type": "registry:hook",
  "title": "useDragReorder",
  "description": "ONE reorder behavior for visible lists: HTML5 drag with a private MIME type (card drags never trigger file-drop zones), axis-free drop edges, and the keyboard path (moveBy ±1, bind ⌥↑/⌥↓).",
  "dependencies": [],
  "files": [
    {
      "path": "hooks/useDragReorder.ts",
      "type": "registry:hook",
      "content": "'use client'\n\n/**\n * useDragReorder — ONE reorder behavior for visible lists (spec §5;\n * grammar G9: drag is for order, membership is a picker). HTML5 drag with a\n * private MIME type so card drags never trigger file-drop zones (and file\n * drags never reorder), plus the keyboard alternative (`moveBy(id, ±1)`,\n * bind to ⌥↑/⌥↓) which doubles as the a11y path.\n *\n * Axis-free: the drop edge is computed against the target's box on whichever\n * axis the two items actually differ — the same hook serves a horizontal\n * rail, a stacked list, and a wrapping grid without configuration.\n *\n * Use when: reordering rows/cards within one visible list.\n * Don't use when: moving items BETWEEN collections (picker/⌘K, per G9) or\n * dropping files in (upload zones own that).\n */\n\nimport { useCallback, useState } from 'react'\n\nconst REORDER_MIME = 'application/x-candlelight-reorder'\n\nexport type DropEdge = 'before' | 'after'\n\nexport interface DropTarget {\n  id: string\n  edge: DropEdge\n}\n\n/** Pure: new order after dropping `sourceId` on `targetId`'s `edge`. */\nexport function reorderIds(ids: string[], sourceId: string, targetId: string, edge: DropEdge): string[] {\n  if (sourceId === targetId || !ids.includes(sourceId) || !ids.includes(targetId)) return ids\n  const without = ids.filter((id) => id !== sourceId)\n  const targetIndex = without.indexOf(targetId)\n  const insertAt = edge === 'before' ? targetIndex : targetIndex + 1\n  return [...without.slice(0, insertAt), sourceId, ...without.slice(insertAt)]\n}\n\n/** Pure: move `id` by `delta` steps, clamped to the list. */\nexport function moveIdBy(ids: string[], id: string, delta: number): string[] {\n  const index = ids.indexOf(id)\n  if (index === -1) return ids\n  const nextIndex = Math.min(ids.length - 1, Math.max(0, index + delta))\n  if (nextIndex === index) return ids\n  const without = ids.filter((entry) => entry !== id)\n  return [...without.slice(0, nextIndex), id, ...without.slice(nextIndex)]\n}\n\ninterface UseDragReorderOptions {\n  ids: string[]\n  /** Receives the complete new order (callers run optimistic mutations). */\n  onReorder: (next: string[]) => void\n  disabled?: boolean\n}\n\nexport function useDragReorder({ ids, onReorder, disabled = false }: UseDragReorderOptions) {\n  const [dragging, setDragging] = useState<string | null>(null)\n  const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)\n\n  const reset = useCallback(() => {\n    setDragging(null)\n    setDropTarget(null)\n  }, [])\n\n  const moveBy = useCallback(\n    (id: string, delta: number) => {\n      if (disabled) return\n      const next = moveIdBy(ids, id, delta)\n      if (next !== ids) onReorder(next)\n    },\n    [disabled, ids, onReorder]\n  )\n\n  const getItemProps = useCallback(\n    (id: string) => {\n      if (disabled) return {}\n      return {\n        draggable: true,\n        onDragStart: (event: React.DragEvent) => {\n          event.dataTransfer.effectAllowed = 'move'\n          event.dataTransfer.setData(REORDER_MIME, id)\n          // Some engines need text/plain for the drag to start at all.\n          event.dataTransfer.setData('text/plain', id)\n          setDragging(id)\n        },\n        onDragOver: (event: React.DragEvent) => {\n          if (!event.dataTransfer.types.includes(REORDER_MIME)) return\n          event.preventDefault()\n          event.dataTransfer.dropEffect = 'move'\n          if (id === dragging) {\n            setDropTarget(null)\n            return\n          }\n          // Pick the axis the pointer actually travels: compare against the\n          // target's center on x and y, weighted by the box's proportions.\n          const rect = event.currentTarget.getBoundingClientRect()\n          const dx = (event.clientX - (rect.left + rect.width / 2)) / Math.max(rect.width, 1)\n          const dy = (event.clientY - (rect.top + rect.height / 2)) / Math.max(rect.height, 1)\n          const edge: DropEdge = (Math.abs(dx) >= Math.abs(dy) ? dx : dy) < 0 ? 'before' : 'after'\n          setDropTarget((current) =>\n            current?.id === id && current.edge === edge ? current : { id, edge }\n          )\n        },\n        onDragLeave: (event: React.DragEvent) => {\n          // Only clear when truly leaving the item (not entering a child).\n          if (event.currentTarget.contains(event.relatedTarget as Node)) return\n          setDropTarget((current) => (current?.id === id ? null : current))\n        },\n        onDrop: (event: React.DragEvent) => {\n          if (!event.dataTransfer.types.includes(REORDER_MIME)) return\n          event.preventDefault()\n          event.stopPropagation()\n          const sourceId = event.dataTransfer.getData(REORDER_MIME)\n          const target = dropTarget?.id === id ? dropTarget : null\n          reset()\n          if (!sourceId || !target) return\n          const next = reorderIds(ids, sourceId, target.id, target.edge)\n          if (next !== ids) onReorder(next)\n        },\n        onDragEnd: reset,\n      }\n    },\n    [disabled, dragging, dropTarget, ids, onReorder, reset]\n  )\n\n  return { getItemProps, dragging, dropTarget, moveBy }\n}\n",
      "target": "hooks/useDragReorder.ts"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "hook",
      "gRules": [
        "G9"
      ],
      "useWhen": "Reordering rows/cards within one visible list.",
      "dontUseWhen": "Moving items BETWEEN collections (picker/⌘K, per G9) or file-drop zones.",
      "states": [
        "dragging",
        "drop target before/after edge"
      ],
      "keyboard": [
        {
          "keys": "⌥↑/⌥↓",
          "does": "moveBy(id, ±1) — the a11y path is the same code path"
        }
      ],
      "acceptanceTests": [
        "A file drag never reorders; a card drag never opens a drop zone (private MIME).",
        "The drop edge follows the axis the two items actually differ on — rail, list, and grid share one hook."
      ]
    }
  }
}