Wrote drag and drop hook and tests.
This commit is contained in:
+245
-38
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user