Wrote drag and drop hook and tests.

This commit is contained in:
Jay
2025-08-01 20:24:34 -04:00
parent c6118c8b0d
commit 68c7486725
3 changed files with 459 additions and 116 deletions
+245 -38
View File
@@ -1,52 +1,259 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useCallback, useEffect, useReducer, useRef } from "react";
import type { RefObject } from "react";
type Position = {
x: number;
y: number;
};
import {
extractEventCoordinates,
isLeftMouseButton,
isTouchEvent,
} from "../util";
type DragAndDropConfig = {
longPressEnabled?: boolean;
longPressDelay?: number;
dragHandleSelector?: string;
};
type DragAction<T> =
| { type: "resetItems"; items: T[] }
| { type: "startDrag"; sourceIndex: number; items: T[] }
| { type: "processMove"; cursor: { x: number; y: number }; rects: DOMRect[] }
| { type: "endDrag"; handleReorder: (newItems: T[]) => void };
type DragAndDropState = {
interface DragState<T> {
isDragging: boolean;
currentIndex: number | null;
targetIndex: number | null;
sourceIndex: number;
targetIndex: number;
items: T[];
previewItems: T[];
}
function reducer<T>(state: DragState<T>, action: DragAction<T>) {
switch (action.type) {
case "resetItems":
const items = action.items;
return {
...state,
items: [...items],
previewItems: [...items],
};
case "startDrag":
return {
...state,
isDragging: true,
sourceIndex: action.sourceIndex,
targetIndex: action.sourceIndex,
items: [...action.items],
previewItems: [...action.items],
};
case "processMove":
if (!state.isDragging) return state;
const { cursor, rects } = action;
let newTargetIndex = state.targetIndex;
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (
cursor.x >= rect.left &&
cursor.x <= rect.right &&
cursor.y >= rect.top &&
cursor.y <= rect.bottom
) {
newTargetIndex = i;
break;
}
}
if (newTargetIndex === state.targetIndex) return state;
const newPreviewItems = [...state.items];
const [movedItem] = newPreviewItems.splice(state.sourceIndex, 1);
newPreviewItems.splice(newTargetIndex, 0, movedItem);
return {
...state,
targetIndex: newTargetIndex,
previewItems: newPreviewItems,
};
case "endDrag":
if (state.sourceIndex !== state.targetIndex) {
action.handleReorder(state.previewItems);
}
return {
...state,
isDragging: false,
sourceIndex: -1,
targetIndex: -1,
};
default:
return state;
}
}
interface DragAndDropHook<T> {
containerRef: RefObject<HTMLDivElement | null>;
getItemRef: (id: string) => RefObject<HTMLElement | null>;
setItemRef: (el: HTMLElement | null, id: string) => void;
isDragging: boolean;
sourceIndex: number;
targetIndex: number;
previewItems: T[];
}
type ItemRefMap = {
[key: string]: RefObject<HTMLElement | null>;
};
export function useDragAndDrop<T>(
items: T[],
onReorder: (newOrder: T[]) => void,
config: DragAndDropConfig = {},
) {
const {
longPressEnabled = false,
longPressDelay = 300,
dragHandleSelector,
} = config;
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const containerRef = useRef<HTMLElement | null>(null);
const [dragState, setDragState] = useState<DragAndDropState>({
export function useDragAndDrop<T extends { id: string }>({
items,
handleReorder,
disabled = false,
}: {
items: T[];
handleReorder: (newItems: T[]) => void;
disabled?: boolean;
}): DragAndDropHook<T> {
const containerRef = useRef<HTMLDivElement | null>(null);
const { itemRefs, getItemRef, setItemRef } = useItemRefs<T>(items);
const itemBoundingRects = useRef<DOMRect[]>([]);
const [state, dispatch] = useReducer(reducer, {
isDragging: false,
currentIndex: null,
targetIndex: null,
sourceIndex: -1,
targetIndex: -1,
items: [...items],
previewItems: [...items],
});
const getItemProps = (index: number) => {
return { style: {} };
};
// Set preview items when 'items' changes externally
useEffect(() => {
dispatch({ type: "resetItems", items });
}, [items]);
// Event Handlers
function getItemElement(event: MouseEvent | TouchEvent) {
const target = event.target as HTMLElement;
const itemElement = target.closest("[data-item-id]") as HTMLElement | null;
return itemElement;
}
function getItemIndex(el: HTMLElement) {
const itemId = el.dataset.itemId;
const index = items.findIndex((item) => item.id === itemId);
return index;
}
function captureItemBoundaries() {
itemBoundingRects.current = items.map((item) => {
const el = itemRefs.current[item.id]?.current;
return el ? el.getBoundingClientRect() : new DOMRect();
});
}
const handleDragMove = useCallback(
(event: MouseEvent | TouchEvent) => {
event.preventDefault();
const { clientX, clientY } = extractEventCoordinates(event);
dispatch({
type: "processMove",
cursor: { x: clientX, y: clientY },
rects: itemBoundingRects.current,
});
},
[dispatch],
);
const handleDragEnd = useCallback(() => {
document.removeEventListener("mousemove", handleDragMove);
document.removeEventListener("touchmove", handleDragMove);
document.removeEventListener("mouseup", handleDragEnd);
document.removeEventListener("touchend", handleDragEnd);
document.removeEventListener("touchcancel", handleDragEnd);
dispatch({ type: "endDrag", handleReorder });
}, [dispatch, handleDragMove, handleReorder]);
const handleDragStart = useCallback(
(event: MouseEvent | TouchEvent) => {
if (!isTouchEvent(event) && !isLeftMouseButton(event.buttons)) {
return;
}
event.preventDefault();
const itemElement = getItemElement(event);
if (itemElement) {
const sourceIndex = getItemIndex(itemElement);
if (sourceIndex !== -1) {
captureItemBoundaries();
dispatch({ type: "startDrag", sourceIndex, items });
}
}
document.addEventListener("mousemove", handleDragMove);
document.addEventListener("touchmove", handleDragMove);
document.addEventListener("mouseup", handleDragEnd);
document.addEventListener("touchend", handleDragEnd);
document.addEventListener("touchcancel", handleDragEnd);
},
[items, dispatch, handleDragEnd, handleDragMove],
);
// Set/cleanup event handlers
useEffect(() => {
const elements = Object.values(itemRefs.current)
.map((ref) => ref.current)
.filter((el) => el !== null);
if (!disabled) {
elements.forEach((el) => {
if (el) {
el.addEventListener("mousedown", handleDragStart);
el.addEventListener("touchstart", handleDragStart);
}
});
return () => {
elements.forEach((el) => {
if (el) {
el.removeEventListener("mousedown", handleDragStart);
el.removeEventListener("touchstart", handleDragStart);
}
});
};
}
}, [items, handleDragStart, disabled]);
return {
containerRef,
itemRefs,
isDragging: dragState.isDragging,
currentIndex: dragState.currentIndex,
targetIndex: dragState.targetIndex,
getItemProps,
getItemRef,
setItemRef,
isDragging: state.isDragging,
sourceIndex: state.sourceIndex,
targetIndex: state.targetIndex,
previewItems: state.previewItems,
};
}
function useItemRefs<T extends { id: string }>(items: T[]) {
const itemRefs = useRef<ItemRefMap>({});
useEffect(() => {
itemRefs.current = items.reduce((acc, item) => {
acc[item.id] = itemRefs.current[item.id] || { current: null };
return acc;
}, {} as ItemRefMap);
}, [items]);
const getItemRef = useCallback((id: string) => {
return itemRefs.current[id] || { current: null };
}, []);
const setItemRef = useCallback((el: HTMLElement | null, id: string) => {
if (!itemRefs.current[id]) {
itemRefs.current[id] = { current: null };
}
itemRefs.current[id].current = el;
}, []);
return { itemRefs, getItemRef, setItemRef };
}