{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-drag-reorder", "type": "registry:hook", "title": "useDragReorder", "description": "Drag-to-reorder list logic with an accessible keyboard path, behind ReorderList.", "categories": [ "hooks" ], "files": [ { "path": "hooks/use-drag-reorder.ts", "type": "registry:hook", "target": "hooks/use-drag-reorder.ts", "content": "import * as React from \"react\";\n\nexport interface DragReorderItemProps {\n draggable: true;\n onDragStart: (event: React.DragEvent) => void;\n onDragEnter: (event: React.DragEvent) => void;\n onDragOver: (event: React.DragEvent) => void;\n onDragLeave: (event: React.DragEvent) => void;\n onDrop: (event: React.DragEvent) => void;\n onDragEnd: (event: React.DragEvent) => void;\n}\n\nexport interface DragReorderHandleProps {\n onPointerDown: (event: React.PointerEvent) => void;\n /**\n * `touchAction: 'none'` is the whole trick: a gesture that starts on the\n * handle is never claimed by the scroller, so the drag runs instead of the\n * container scrolling out from under it.\n */\n style: React.CSSProperties;\n}\n\nexport interface UseDragReorderOptions {\n /** How many items the list has. Required for the pointer path's hit-testing. */\n count?: number;\n /** Resolves the row element at `index`, so a pointer drag can hit-test rows. */\n getItemElement?: (index: number) => HTMLElement | null | undefined;\n /**\n * The scroller to auto-scroll when a pointer drag nears its edge. Usually the\n * list itself when height-capped, else its nearest scrollable ancestor.\n */\n getScrollContainer?: () => HTMLElement | null | undefined;\n}\n\nexport interface UseDragReorderResult {\n /** Index of the item currently being dragged, or null. */\n draggingIndex: number | null;\n /** Index of the item currently being hovered as a drop target, or null. */\n dragOverIndex: number | null;\n /** True while a touch/pen drag is in flight (the pointer path, not HTML5). */\n pointerDragging: boolean;\n /** Returns drag handlers (and `draggable: true`) for the item at `index`. */\n getItemProps: (index: number) => DragReorderItemProps;\n /** Returns pointer handlers for the drag handle of the item at `index`. */\n getHandleProps: (index: number) => DragReorderHandleProps;\n}\n\n// How close to a scroller edge the pointer has to get before the list starts\n// auto-scrolling, and how fast it then moves (px per frame).\nconst AUTOSCROLL_EDGE_PX = 48;\nconst AUTOSCROLL_SPEED_PX = 12;\n\n/**\n * Drag-to-reorder for both input models.\n *\n * **Mouse** uses HTML5 drag-and-drop via `getItemProps` -- the whole row is\n * `draggable`, and the browser supplies the drag image and ancestor\n * auto-scroll for free.\n *\n * **Touch and pen** use Pointer Events via `getHandleProps`, because HTML5\n * drag-and-drop does not exist on touch: mobile browsers never synthesize\n * `dragstart` from a finger, so before this path the handle did nothing at all\n * and the gesture just scrolled the container. The handle carries\n * `touch-action: none`, so a drag that starts there is the drag rather than a\n * scroll, and the pointer path does its own hit-testing and edge auto-scroll\n * (both of which the HTML5 path gets from the browser).\n *\n * The hook only tracks state -- the caller renders the items and decides what\n * visual feedback to show (e.g. opacity-down on the dragged item, ring on the\n * drop target). Drop semantics: `onReorder(from, to)` is called with indices\n * into the *original* array, and the splice-based caller pattern places the\n * moved item at `to` in the resulting array.\n *\n * The pointer path needs geometry the hook cannot see, so a caller that wants\n * it passes `count`, `getItemElement`, and `getScrollContainer`. Omit them and\n * the hook still works exactly as before, mouse-only -- which is why adding\n * this was additive for callers like FS Generator that only spread\n * `getItemProps`.\n *\n * Typical usage:\n *\n * ```tsx\n * const reorder = useDragReorder((from, to) => setItems(prev => {\n * const next = [...prev]\n * const [m] = next.splice(from, 1)\n * next.splice(to, 0, m)\n * return next\n * }))\n *\n * items.map((item, idx) => (\n * \n * ::\n * {item.content}\n * \n * ))\n * ```\n */\nexport function useDragReorder(\n onReorder: (from: number, to: number) => void,\n options: UseDragReorderOptions = {},\n): UseDragReorderResult {\n const [draggingIndex, setDraggingIndex] = React.useState(null);\n const [dragOverIndex, setDragOverIndex] = React.useState(null);\n const [pointerDragging, setPointerDragging] = React.useState(false);\n\n // Mirror draggingIndex into a ref so event handlers always read the latest\n // without having to be regenerated on every drag-state change.\n const draggingRef = React.useRef(null);\n React.useEffect(() => {\n draggingRef.current = draggingIndex;\n }, [draggingIndex]);\n\n // The pointer path reads the live drop target on pointerup, where a state\n // read would be a render behind.\n const overRef = React.useRef(null);\n\n const onReorderRef = React.useRef(onReorder);\n React.useEffect(() => {\n onReorderRef.current = onReorder;\n }, [onReorder]);\n\n const optionsRef = React.useRef(options);\n React.useEffect(() => {\n optionsRef.current = options;\n });\n\n const getItemProps = React.useCallback((index: number): DragReorderItemProps => ({\n draggable: true,\n onDragStart: (event) => {\n // setData is required for Firefox to actually start the drag.\n if (event.dataTransfer) {\n event.dataTransfer.effectAllowed = \"move\";\n event.dataTransfer.setData(\"text/plain\", String(index));\n }\n setDraggingIndex(index);\n },\n onDragEnter: (event) => {\n event.preventDefault();\n setDragOverIndex(index);\n },\n onDragOver: (event) => {\n // Both dragOver preventDefault and dropEffect are required for\n // the drop event to fire reliably across browsers.\n event.preventDefault();\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = \"move\";\n }\n },\n onDragLeave: () => {\n // Intentionally a no-op. dragenter on a sibling races with dragleave\n // on the previous; clearing here causes flicker. State is cleared on\n // drop / dragend instead.\n },\n onDrop: (event) => {\n event.preventDefault();\n const from = draggingRef.current;\n const to = index;\n setDraggingIndex(null);\n setDragOverIndex(null);\n if (from === null || from === to) return;\n onReorderRef.current(from, to);\n },\n onDragEnd: () => {\n setDraggingIndex(null);\n setDragOverIndex(null);\n },\n }), []);\n\n // ---- Pointer (touch / pen) path ------------------------------------------\n\n // Latest pointer Y, read by both the move handler and the auto-scroll frame\n // (which re-hit-tests after moving the scroller, since the rows have shifted\n // under a stationary finger).\n const pointerYRef = React.useRef(0);\n const rafRef = React.useRef(null);\n\n /** Which row `clientY` currently sits over, clamped to the list. */\n const hitTest = React.useCallback((clientY: number): number | null => {\n const { count, getItemElement } = optionsRef.current;\n if (!count || !getItemElement) return null;\n let candidate: number | null = null;\n for (let i = 0; i < count; i += 1) {\n const el = getItemElement(i);\n if (!el) continue;\n const rect = el.getBoundingClientRect();\n if (candidate === null) candidate = i;\n if (clientY >= rect.top && clientY <= rect.bottom) return i;\n // Past the row's bottom edge -- keep it as the running best so a pointer\n // dragged below the last row lands on the last row rather than nowhere.\n if (clientY > rect.bottom) candidate = i;\n }\n return candidate;\n }, []);\n\n const syncOver = React.useCallback(() => {\n const to = hitTest(pointerYRef.current);\n if (to === null) return;\n overRef.current = to;\n setDragOverIndex(to);\n }, [hitTest]);\n\n const stopAutoScroll = React.useCallback(() => {\n if (rafRef.current !== null) {\n cancelAnimationFrame(rafRef.current);\n rafRef.current = null;\n }\n }, []);\n\n const autoScrollFrame = React.useCallback(() => {\n const el = optionsRef.current.getScrollContainer?.();\n if (el) {\n const rect = el.getBoundingClientRect();\n const y = pointerYRef.current;\n if (y < rect.top + AUTOSCROLL_EDGE_PX) {\n el.scrollTop -= AUTOSCROLL_SPEED_PX;\n syncOver();\n } else if (y > rect.bottom - AUTOSCROLL_EDGE_PX) {\n el.scrollTop += AUTOSCROLL_SPEED_PX;\n syncOver();\n }\n }\n rafRef.current = requestAnimationFrame(autoScrollFrame);\n }, [syncOver]);\n\n // A drag interrupted by unmount must not leave a frame loop running.\n React.useEffect(() => stopAutoScroll, [stopAutoScroll]);\n\n const getHandleProps = React.useCallback((index: number): DragReorderHandleProps => ({\n style: { touchAction: \"none\" },\n onPointerDown: (event) => {\n // Mouse keeps the HTML5 path on the row, which already gives it a drag\n // image and native ancestor auto-scroll.\n if (event.pointerType === \"mouse\") return;\n const target = event.currentTarget;\n // Capture so the drag survives the finger leaving the handle -- without\n // it the very first move ends the gesture.\n target.setPointerCapture?.(event.pointerId);\n pointerYRef.current = event.clientY;\n draggingRef.current = index;\n overRef.current = index;\n setDraggingIndex(index);\n setDragOverIndex(index);\n setPointerDragging(true);\n stopAutoScroll();\n rafRef.current = requestAnimationFrame(autoScrollFrame);\n\n const handleMove = (e: PointerEvent) => {\n if (e.pointerId !== event.pointerId) return;\n // The handle's touch-action already stops the scroller claiming this,\n // but a listener that reaches here should not also fire a click.\n e.preventDefault();\n pointerYRef.current = e.clientY;\n syncOver();\n };\n\n const finish = (e: PointerEvent) => {\n if (e.pointerId !== event.pointerId) return;\n target.releasePointerCapture?.(event.pointerId);\n target.removeEventListener(\"pointermove\", handleMove);\n target.removeEventListener(\"pointerup\", finish);\n target.removeEventListener(\"pointercancel\", cancel);\n stopAutoScroll();\n const from = draggingRef.current;\n const to = overRef.current;\n draggingRef.current = null;\n overRef.current = null;\n setDraggingIndex(null);\n setDragOverIndex(null);\n setPointerDragging(false);\n if (from === null || to === null || from === to) return;\n onReorderRef.current(from, to);\n };\n\n const cancel = (e: PointerEvent) => {\n if (e.pointerId !== event.pointerId) return;\n target.releasePointerCapture?.(event.pointerId);\n target.removeEventListener(\"pointermove\", handleMove);\n target.removeEventListener(\"pointerup\", finish);\n target.removeEventListener(\"pointercancel\", cancel);\n stopAutoScroll();\n draggingRef.current = null;\n overRef.current = null;\n setDraggingIndex(null);\n setDragOverIndex(null);\n setPointerDragging(false);\n };\n\n // Bound to the capturing element rather than the document: pointer\n // capture routes every subsequent event for this pointer here anyway,\n // and it keeps the listeners scoped to the gesture.\n target.addEventListener(\"pointermove\", handleMove);\n target.addEventListener(\"pointerup\", finish);\n target.addEventListener(\"pointercancel\", cancel);\n },\n }), [autoScrollFrame, stopAutoScroll, syncOver]);\n\n return {\n draggingIndex,\n dragOverIndex,\n pointerDragging,\n getItemProps,\n getHandleProps,\n };\n}\n" } ], "docs": "Most apps want ReorderList, which wraps this hook with rendering and arrow buttons. Reach for the hook directly when you need a different row presentation.", "meta": { "group": "hooks", "related": [ "reorder-list" ], "exports": [ "useDragReorder" ], "siteSlug": "use-drag-reorder" } }