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
+244 -37
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 = { import {
x: number; extractEventCoordinates,
y: number; isLeftMouseButton,
}; isTouchEvent,
} from "../util";
type DragAndDropConfig = { type DragAction<T> =
longPressEnabled?: boolean; | { type: "resetItems"; items: T[] }
longPressDelay?: number; | { type: "startDrag"; sourceIndex: number; items: T[] }
dragHandleSelector?: string; | { type: "processMove"; cursor: { x: number; y: number }; rects: DOMRect[] }
}; | { type: "endDrag"; handleReorder: (newItems: T[]) => void };
type DragAndDropState = { interface DragState<T> {
isDragging: boolean; isDragging: boolean;
currentIndex: number | null; sourceIndex: number;
targetIndex: number | null; 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>( export function useDragAndDrop<T extends { id: string }>({
items: T[], items,
onReorder: (newOrder: T[]) => void, handleReorder,
config: DragAndDropConfig = {}, disabled = false,
) { }: {
const { items: T[];
longPressEnabled = false, handleReorder: (newItems: T[]) => void;
longPressDelay = 300, disabled?: boolean;
dragHandleSelector, }): DragAndDropHook<T> {
} = config; const containerRef = useRef<HTMLDivElement | null>(null);
const { itemRefs, getItemRef, setItemRef } = useItemRefs<T>(items);
const itemRefs = useRef<(HTMLElement | null)[]>([]); const itemBoundingRects = useRef<DOMRect[]>([]);
const containerRef = useRef<HTMLElement | null>(null); const [state, dispatch] = useReducer(reducer, {
const [dragState, setDragState] = useState<DragAndDropState>({
isDragging: false, isDragging: false,
currentIndex: null, sourceIndex: -1,
targetIndex: null, targetIndex: -1,
items: [...items],
previewItems: [...items],
}); });
const getItemProps = (index: number) => { // Set preview items when 'items' changes externally
return { style: {} }; 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 { return {
containerRef, containerRef,
itemRefs, getItemRef,
isDragging: dragState.isDragging, setItemRef,
currentIndex: dragState.currentIndex, isDragging: state.isDragging,
targetIndex: dragState.targetIndex, sourceIndex: state.sourceIndex,
getItemProps, 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 };
}
+169 -69
View File
@@ -1,8 +1,9 @@
import clsx from "clsx";
import { motion } from "motion/react";
import { useState } from "react"; import { useState } from "react";
import type { RefObject } from "react";
import { useDragAndDrop } from "../dragAndDrop"; import { useDragAndDrop } from "../dragAndDrop";
import styles from "./dragAndDropTest.module.css"; import styles from "./dragAndDropTest.module.css";
import clsx from "clsx";
type Item = { type Item = {
id: string; id: string;
@@ -11,87 +12,83 @@ type Item = {
}; };
const initialItems: Item[] = [ const initialItems: Item[] = [
{ id: "1", content: "Item 1", color: "firebrick" }, { id: "A", content: "Item A", color: "firebrick" },
{ id: "2", content: "Item 2", color: "mediumseagreen" }, { id: "B", content: "Item B", color: "mediumseagreen" },
{ id: "3", content: "Item 3", color: "orange" }, { id: "C", content: "Item C", color: "orange" },
{ id: "4", content: "Item 4", color: "deepskyblue" }, { id: "D", content: "Item D", color: "deepskyblue" },
{ id: "5", content: "Item 5", color: "mediumorchid" }, { id: "E", content: "Item E", color: "mediumorchid" },
]; ];
function TestDragAndDrop() { function TestDragAndDrop() {
const [items, setItems] = useState<Item[]>(initialItems); const [items, setItems] = useState<Item[]>(initialItems);
const [useLongPress, setUseLongPress] = useState(false); const [dragEnabled, setDragEnabled] = useState(true);
const [useDragHandle, setUseDragHandle] = useState(false);
const handleReorder = (newItems: Item[]) => { const handleReorder = (newItems: Item[]) => setItems(newItems);
setItems(newItems);
};
const { containerRef, getItemProps, isDragging, currentIndex, targetIndex } = const {
useDragAndDrop(items, handleReorder, { containerRef,
longPressEnabled: useLongPress, setItemRef,
dragHandleSelector: useDragHandle ? ".drag-handle" : undefined, isDragging,
sourceIndex,
targetIndex,
previewItems,
} = useDragAndDrop({
items,
handleReorder,
disabled: !dragEnabled,
}); });
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div <div className={styles.itemsWrapper}>
ref={containerRef as RefObject<HTMLDivElement>} <div className={styles.indexColumn}>
className={styles.itemsWrapper} {(isDragging ? previewItems : items).map((_, index) => (
> <div key={`index-${index}`} className={styles.indexItem}>
{items.map((item, index) => ( {index}
<div
key={item.id}
data-cy={`draggable-item-${item.id}`}
{...getItemProps(index)}
className={clsx(
styles.draggableItem,
isDragging && currentIndex === index && styles.dragging,
)}
style={{
...getItemProps(index).style,
backgroundColor: item.color,
}}
>
<span className={styles.content}>{item.content}</span>
{useDragHandle && <div className={styles.handle}></div>}
</div> </div>
))} ))}
</div> </div>
<hr /> <div ref={containerRef} className={styles.draggableItemsWrapper}>
{(isDragging ? previewItems : items).map((item, index) => (
<div className={styles.controls}> <motion.div
<label> key={item.id}
<input ref={(el) => setItemRef(el, item.id)}
type="checkbox" data-item-id={item.id}
checked={useLongPress} data-cy={`draggable-item-${item.id}`}
onChange={() => setUseLongPress(!useLongPress)} className={clsx(styles.item, {
/> [styles.draggableItem]: dragEnabled,
Use Long Press [styles.draggingItem]: isDragging && targetIndex === index,
</label> })}
style={{
<label> backgroundColor: item.color,
<input }}
type="checkbox" layout
checked={useDragHandle} transition={{ duration: 0.2 }}
onChange={() => setUseDragHandle(!useDragHandle)} >
/> <span className={styles.content}>{item.content}</span>
Use Drag Handle </motion.div>
</label> ))}
</div>
</div> </div>
<hr /> <hr />
<div className={styles.status}> <div className={styles.status}>
<button
onClick={() => setDragEnabled(!dragEnabled)}
data-cy="enable-button"
>
{dragEnabled ? "Cancel" : "Reorder Items"}
</button>
<p>Dragging: {isDragging ? "True" : "False"}</p> <p>Dragging: {isDragging ? "True" : "False"}</p>
<p> <p>
Status: Status:{" "}
{isDragging && ( {isDragging && (
<p> <>
Moving Item {currentIndex !== null ? items[currentIndex].id : ""} Moving Item {sourceIndex !== null ? items[sourceIndex].id : ""}
{targetIndex !== null ? ` to position ${targetIndex}` : ""} {targetIndex !== null ? ` to position ${targetIndex}` : ""}
</p> </>
)} )}
</p> </p>
</div> </div>
@@ -99,24 +96,127 @@ function TestDragAndDrop() {
<hr /> <hr />
<div className={styles.currentOrder}> <div className={styles.currentOrder}>
<p>Current order:</p> <p>Item order: {items.map((i) => i.id)}</p>
<pre> <p>Preview order: {previewItems.map((i) => i.id)}</p>
{JSON.stringify(
items.map((i) => i.id),
null,
2,
)}
</pre>
</div> </div>
</div> </div>
); );
} }
const triggerMouseEvent = (
testId: string,
eventType: string,
args: any[] = [],
opts: { [key: string]: any } = {},
) => {
cy.dataCy(testId).trigger(eventType, ...args, {
buttons: 1,
eventConstructor: "MouseEvent",
...opts,
});
};
const triggerTouchEvent = (
testId: string,
eventType: string,
args: any[] = [],
opts: { [key: string]: any } = {},
) => {
cy.dataCy(testId).then(($el) => {
const { left, top, width, height } = $el[0].getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const clientX = opts.clientX || centerX;
const clientY = opts.clientY || centerY;
const touchList = [
{
identifier: 0,
target: $el[0],
clientX,
clientY,
},
];
const touchOptions = {
...opts,
touches: touchList,
};
return cy.dataCy(testId).trigger(eventType, ...args, touchOptions);
});
};
const assertItemOrder = (expectedOrder: string) => {
cy.contains(`Item order: ${expectedOrder}`).should("exist");
};
const assertPreviewOrder = (expectedOrder: string) => {
cy.contains(`Preview order: ${expectedOrder}`).should("exist");
};
const assertDragState = (expectedState: boolean) => {
const expectedValue = expectedState ? "True" : "False";
cy.contains(`Dragging: ${expectedValue}`).should("exist");
};
const assertStatus = (expectedItem: string, expectedPosition: string) => {
cy.contains(
`Status: Moving ${expectedItem} to position ${expectedPosition}`,
).should("exist");
};
describe("Drag and Drop Component Test", () => { describe("Drag and Drop Component Test", () => {
beforeEach(() => { beforeEach(() => {
cy.mount(<TestDragAndDrop />); cy.mount(<TestDragAndDrop />);
cy.viewport(500, 600); cy.viewport(500, 600);
}); });
it("should drag and drop", () => {}); it("should drag and drop on mouse events", () => {
assertItemOrder("ABCDE");
assertPreviewOrder("ABCDE");
assertDragState(false);
triggerMouseEvent("draggable-item-A", "mousedown");
assertDragState(true);
assertStatus("Item A", "0");
triggerMouseEvent("draggable-item-B", "mousemove");
assertStatus("Item A", "1");
assertItemOrder("ABCDE");
assertPreviewOrder("BACDE");
triggerMouseEvent("draggable-item-A", "mouseup");
assertDragState(false);
assertItemOrder("BACDE");
assertPreviewOrder("BACDE");
});
it("should drag and drop on touch events", () => {
assertItemOrder("ABCDE");
assertPreviewOrder("ABCDE");
assertDragState(false);
triggerTouchEvent("draggable-item-A", "touchstart");
assertDragState(true);
assertStatus("Item A", "0");
triggerTouchEvent("draggable-item-B", "touchmove");
assertStatus("Item A", "1");
assertItemOrder("ABCDE");
assertPreviewOrder("BACDE");
triggerTouchEvent("draggable-item-A", "touchend");
assertDragState(false);
assertItemOrder("BACDE");
assertPreviewOrder("BACDE");
});
it("should disable dragging", () => {
cy.dataCy("enable-button").click();
triggerMouseEvent("draggable-item-A", "mousedown");
assertDragState(false);
triggerMouseEvent("draggable-item-A", "mouseup");
});
}); });
+41 -5
View File
@@ -2,22 +2,57 @@
} }
.itemsWrapper { .itemsWrapper {
display: flex;
flex-direction: row;
} }
.draggableItem { .indexColumn {
display: flex;
flex-direction: column;
}
.indexItem {
width: 40px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: #eee;
font-weight: bold;
color: #555;
}
.draggableItemsWrapper {
display: flex;
flex-direction: column;
}
.item {
display: flex; display: flex;
width: 200px; width: 200px;
height: 50px; height: 50px;
justify-content: space-between;
align-items: center; align-items: center;
} }
.draggableItem .content { .draggableItem {
flex: 1; width: 196px;
height: 46px;
margin: 2px;
}
.draggingItem {
width: 204px;
height: 54px;
margin: -2px;
box-shadow: 0px 0px 10px #777;
z-index: 2;
}
.item .content {
margin-left: 20px; margin-left: 20px;
} }
.draggableItem .handle { .draggableItem .grip {
width: 15px; width: 15px;
height: 30px; height: 30px;
background-color: rgba(0, 0, 0, 0.3); background-color: rgba(0, 0, 0, 0.3);
@@ -34,6 +69,7 @@
.controls { .controls {
display: flex; display: flex;
gap: 25px; gap: 25px;
margin: 12px 0;
} }
.status { .status {