import { useCallback, useEffect, useRef, useState } from "react"; /** * Drag-to-reorder that works with a finger. * * The list used to use the HTML5 drag-and-drop API, which mobile browsers never * fire from touch — so on a phone the arrangement simply could not be changed, * with nothing on screen to say why. This uses pointer events instead, which * mouse, pen and finger all produce. * * The two devices want different things, so they get different starts: * * - **With a mouse**, pressing anywhere on a row and moving a few pixels begins * the drag. That is what a mouse has always done here. * - **With a finger**, only the grip begins it. Pressing the row itself has to * go on scrolling the list, and a long press has to go on opening the menu — * both would be lost to a drag that started from anywhere. */ /** Pixels a mouse must travel before a press counts as a drag rather than a click. */ const MOUSE_THRESHOLD = 5; export interface ReorderDrag { /** The row the pointer is over, for the drop indicator. */ overId: string | null; /** The row being carried, so it can be shown as lifted. */ draggingId: string | null; /** Spread onto each row; `id` is what gets reordered. */ rowProps: (id: string) => { "data-reorder-id": string; onPointerDown: (e: React.PointerEvent) => void }; /** Spread onto the grip inside a row. */ gripProps: { "data-reorder-grip": string }; } export function useReorderDrag( enabled: boolean, reorder: (dragId: string, targetId: string) => void ): ReorderDrag { const [overId, setOverId] = useState(null); const [draggingId, setDraggingId] = useState(null); // Everything the pointer handlers need, kept out of state: they run far more // often than anything needs to be redrawn. const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null); const overRef = useRef(null); const stop = useCallback(() => { drag.current = null; overRef.current = null; setDraggingId(null); setOverId(null); document.body.style.removeProperty("user-select"); }, []); const onPointerDown = useCallback( (id: string) => (e: React.PointerEvent) => { if (!enabled || e.button > 0) return; const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]"); // A finger on the row itself is a scroll or a long press, not a drag. if (e.pointerType !== "mouse" && !fromGrip) return; drag.current = { id, x: e.clientX, y: e.clientY, active: fromGrip, fromGrip }; if (fromGrip) { setDraggingId(id); document.body.style.userSelect = "none"; // The grip owns the gesture from here, so the list must not also scroll. e.preventDefault(); } }, [enabled] ); useEffect(() => { if (!enabled) return; const move = (e: PointerEvent) => { const d = drag.current; if (!d) return; if (!d.active) { if (Math.abs(e.clientX - d.x) + Math.abs(e.clientY - d.y) < MOUSE_THRESHOLD) return; d.active = true; setDraggingId(d.id); document.body.style.userSelect = "none"; } // Which row is under the finger — asked of the document rather than // tracked with enter/leave handlers, because a finger generates no // pointerover on anything it did not press. const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null; const target = row?.dataset.reorderId ?? null; const next = target && target !== d.id ? target : null; if (next !== overRef.current) { overRef.current = next; setOverId(next); } if (e.cancelable) e.preventDefault(); }; const up = () => { const d = drag.current; const target = overRef.current; if (d?.active && target && target !== d.id) reorder(d.id, target); stop(); }; window.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", stop); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", stop); }; }, [enabled, reorder, stop]); // A drag that outlives its list would leave the page unselectable. useEffect( () => () => { document.body.style.removeProperty("user-select"); }, [] ); return { overId, draggingId, rowProps: (id: string) => ({ "data-reorder-id": id, onPointerDown: onPointerDown(id) }), gripProps: { "data-reorder-grip": "" }, }; }