From 68c7486725c0642295e24f44bb9fe90b454a23a0 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 1 Aug 2025 20:24:34 -0400 Subject: [PATCH] Wrote drag and drop hook and tests. --- src/hooks/dragAndDrop.tsx | 283 ++++++++++++++++++--- src/hooks/tests/dragAndDropTest.cy.tsx | 246 ++++++++++++------ src/hooks/tests/dragAndDropTest.module.css | 46 +++- 3 files changed, 459 insertions(+), 116 deletions(-) diff --git a/src/hooks/dragAndDrop.tsx b/src/hooks/dragAndDrop.tsx index f142e43..f2c2c7a 100644 --- a/src/hooks/dragAndDrop.tsx +++ b/src/hooks/dragAndDrop.tsx @@ -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 = + | { 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 { isDragging: boolean; - currentIndex: number | null; - targetIndex: number | null; + sourceIndex: number; + targetIndex: number; + items: T[]; + previewItems: T[]; +} + +function reducer(state: DragState, action: DragAction) { + 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 { + containerRef: RefObject; + getItemRef: (id: string) => RefObject; + setItemRef: (el: HTMLElement | null, id: string) => void; + isDragging: boolean; + sourceIndex: number; + targetIndex: number; + previewItems: T[]; +} + +type ItemRefMap = { + [key: string]: RefObject; }; -export function useDragAndDrop( - items: T[], - onReorder: (newOrder: T[]) => void, - config: DragAndDropConfig = {}, -) { - const { - longPressEnabled = false, - longPressDelay = 300, - dragHandleSelector, - } = config; - - const itemRefs = useRef<(HTMLElement | null)[]>([]); - const containerRef = useRef(null); - - const [dragState, setDragState] = useState({ +export function useDragAndDrop({ + items, + handleReorder, + disabled = false, +}: { + items: T[]; + handleReorder: (newItems: T[]) => void; + disabled?: boolean; +}): DragAndDropHook { + const containerRef = useRef(null); + const { itemRefs, getItemRef, setItemRef } = useItemRefs(items); + const itemBoundingRects = useRef([]); + 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(items: T[]) { + const itemRefs = useRef({}); + + 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 }; +} diff --git a/src/hooks/tests/dragAndDropTest.cy.tsx b/src/hooks/tests/dragAndDropTest.cy.tsx index 4519eef..cff5373 100644 --- a/src/hooks/tests/dragAndDropTest.cy.tsx +++ b/src/hooks/tests/dragAndDropTest.cy.tsx @@ -1,8 +1,9 @@ +import clsx from "clsx"; +import { motion } from "motion/react"; import { useState } from "react"; -import type { RefObject } from "react"; + import { useDragAndDrop } from "../dragAndDrop"; import styles from "./dragAndDropTest.module.css"; -import clsx from "clsx"; type Item = { id: string; @@ -11,87 +12,83 @@ type Item = { }; const initialItems: Item[] = [ - { id: "1", content: "Item 1", color: "firebrick" }, - { id: "2", content: "Item 2", color: "mediumseagreen" }, - { id: "3", content: "Item 3", color: "orange" }, - { id: "4", content: "Item 4", color: "deepskyblue" }, - { id: "5", content: "Item 5", color: "mediumorchid" }, + { id: "A", content: "Item A", color: "firebrick" }, + { id: "B", content: "Item B", color: "mediumseagreen" }, + { id: "C", content: "Item C", color: "orange" }, + { id: "D", content: "Item D", color: "deepskyblue" }, + { id: "E", content: "Item E", color: "mediumorchid" }, ]; function TestDragAndDrop() { const [items, setItems] = useState(initialItems); - const [useLongPress, setUseLongPress] = useState(false); - const [useDragHandle, setUseDragHandle] = useState(false); + const [dragEnabled, setDragEnabled] = useState(true); - const handleReorder = (newItems: Item[]) => { - setItems(newItems); - }; + const handleReorder = (newItems: Item[]) => setItems(newItems); - const { containerRef, getItemProps, isDragging, currentIndex, targetIndex } = - useDragAndDrop(items, handleReorder, { - longPressEnabled: useLongPress, - dragHandleSelector: useDragHandle ? ".drag-handle" : undefined, - }); + const { + containerRef, + setItemRef, + isDragging, + sourceIndex, + targetIndex, + previewItems, + } = useDragAndDrop({ + items, + handleReorder, + disabled: !dragEnabled, + }); return (
-
} - className={styles.itemsWrapper} - > - {items.map((item, index) => ( -
- {item.content} - {useDragHandle &&
} -
- ))} -
+
+
+ {(isDragging ? previewItems : items).map((_, index) => ( +
+ {index} +
+ ))} +
-
- -
- - - +
+ {(isDragging ? previewItems : items).map((item, index) => ( + setItemRef(el, item.id)} + data-item-id={item.id} + data-cy={`draggable-item-${item.id}`} + className={clsx(styles.item, { + [styles.draggableItem]: dragEnabled, + [styles.draggingItem]: isDragging && targetIndex === index, + })} + style={{ + backgroundColor: item.color, + }} + layout + transition={{ duration: 0.2 }} + > + {item.content} + + ))} +

+

Dragging: {isDragging ? "True" : "False"}

- Status: + Status:{" "} {isDragging && ( -

- Moving Item {currentIndex !== null ? items[currentIndex].id : ""} + <> + Moving Item {sourceIndex !== null ? items[sourceIndex].id : ""} {targetIndex !== null ? ` to position ${targetIndex}` : ""} -

+ )}

@@ -99,24 +96,127 @@ function TestDragAndDrop() {
-

Current order:

-
-          {JSON.stringify(
-            items.map((i) => i.id),
-            null,
-            2,
-          )}
-        
+

Item order: {items.map((i) => i.id)}

+

Preview order: {previewItems.map((i) => i.id)}

); } +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", () => { beforeEach(() => { cy.mount(); 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"); + }); }); diff --git a/src/hooks/tests/dragAndDropTest.module.css b/src/hooks/tests/dragAndDropTest.module.css index ca0b5f6..56f828b 100644 --- a/src/hooks/tests/dragAndDropTest.module.css +++ b/src/hooks/tests/dragAndDropTest.module.css @@ -2,22 +2,57 @@ } .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; width: 200px; height: 50px; - justify-content: space-between; align-items: center; } -.draggableItem .content { - flex: 1; +.draggableItem { + 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; } -.draggableItem .handle { +.draggableItem .grip { width: 15px; height: 30px; background-color: rgba(0, 0, 0, 0.3); @@ -34,6 +69,7 @@ .controls { display: flex; gap: 25px; + margin: 12px 0; } .status {