DraggableList.tsx
⎇
Raw
1import { type Accessor, type Setter, createSignal, onMount, onCleanup, type JSX, For, createMemo } from "solid-js";
2
3type DropPosition = "before" | "after" | null;
4
5interface DraggableListProps<T> {
6 items: Accessor<T[]>;
7 setItems: Setter<T[]>;
8 children: (
9 item: T,
10 index: number,
11 handlers: {
12 dragHandlers: {
13 draggable: boolean;
14 onDragStart: (e: DragEvent) => void;
15 onTouchStart: (e: TouchEvent) => void;
16 };
17 isDragging: Accessor<boolean>;
18 dropPosition: Accessor<DropPosition>;
19 },
20 ) => JSX.Element;
21}
22
23export function DraggableList<T>(props: DraggableListProps<T>) {
24 const [draggingItem, setDraggingItem] = createSignal<T | null>(null);
25 const [dragStartIndex, setDragStartIndex] = createSignal<number | null>(null);
26 const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
27
28 let listRef!: HTMLDivElement;
29 let rafId: number | null = null;
30 let lastClientY = 0;
31 let scrollInterval: undefined | number;
32
33 function scheduleUpdate(clientY: number) {
34 lastClientY = clientY;
35 if (rafId != null) return;
36 rafId = requestAnimationFrame(() => {
37 updateDragOverIndex(lastClientY);
38 rafId = null;
39 });
40 }
41
42 function startAutoScrollLoop() {
43 function step() {
44 const rect = listRef.getBoundingClientRect();
45 const edgeThreshold = 75;
46 const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5);
47
48 if (lastClientY < rect.top + edgeThreshold) {
49 const distance = rect.top + edgeThreshold - lastClientY;
50 listRef.scrollTop -= scrollAmount(distance);
51 } else if (lastClientY > rect.bottom - edgeThreshold) {
52 const distance = lastClientY - (rect.bottom - edgeThreshold);
53 listRef.scrollTop += scrollAmount(distance);
54 }
55
56 scheduleUpdate(lastClientY);
57 }
58
59 //@ts-ignore
60 scrollInterval = setInterval(step, 10);
61 }
62
63 function stopAutoScrollLoop() {
64 clearInterval(scrollInterval);
65 scrollInterval = undefined;
66 }
67
68 function moveDraggedItem() {
69 const items = props.items();
70 const movedItem = draggingItem();
71 const startIdx = dragStartIndex();
72 const dropIdx = dragOverIndex();
73
74 if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) {
75 const newItems = [...items];
76 newItems.splice(startIdx, 1);
77 const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx;
78 newItems.splice(adjustedIndex, 0, movedItem);
79 props.setItems(newItems);
80 }
81 }
82
83 function endDrag(move: boolean) {
84 if (rafId) cancelAnimationFrame(rafId);
85 rafId = null;
86 stopAutoScrollLoop();
87 if (move) moveDraggedItem();
88 setDraggingItem(null);
89 setDragStartIndex(null);
90 setDragOverIndex(null);
91 }
92
93 function updateDragOverIndex(clientY: number) {
94 const items = Array.from(listRef.querySelectorAll(".draggable-item")) as HTMLElement[];
95 for (let i = 0; i < items.length; i++) {
96 const rect = items[i].getBoundingClientRect();
97 if (clientY < rect.top + rect.height / 2) {
98 setDragOverIndex(i);
99 return;
100 }
101 }
102 setDragOverIndex(items.length);
103 }
104
105 function handleDragStart(item: T, index: number, dragElement: HTMLElement | null, e: DragEvent) {
106 if (!dragElement) return;
107 setDraggingItem(() => item);
108 setDragStartIndex(index);
109 if (!e.dataTransfer) return;
110 e.dataTransfer.effectAllowed = "move";
111 e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
112 }
113
114 function handleTouchStart(item: T, index: number, e: TouchEvent) {
115 setDraggingItem(() => item);
116 setDragStartIndex(index);
117 lastClientY = e.touches[0].clientY;
118 }
119
120 function handleTouchMove(e: TouchEvent) {
121 if (!draggingItem()) return;
122 if (!scrollInterval) startAutoScrollLoop();
123 e.preventDefault();
124 scheduleUpdate(e.touches[0].clientY);
125 }
126
127 function handleDragOver(e: DragEvent) {
128 e.preventDefault();
129 scheduleUpdate(e.clientY);
130 if (!e.dataTransfer) return;
131 e.dataTransfer.dropEffect = "move";
132 }
133
134 function handleDrop(e: DragEvent) {
135 e.preventDefault();
136 endDrag(true);
137 }
138
139 onMount(() => {
140 const cancelDrag = () => endDrag(false);
141 const doDrag = () => endDrag(true);
142
143 listRef.addEventListener("touchmove", handleTouchMove, { passive: false });
144 listRef.addEventListener("touchend", doDrag);
145 listRef.addEventListener("touchcancel", cancelDrag);
146
147 onCleanup(() => {
148 listRef?.removeEventListener("touchmove", handleTouchMove);
149 listRef?.removeEventListener("touchend", doDrag);
150 listRef?.removeEventListener("touchcancel", cancelDrag);
151 stopAutoScrollLoop();
152 });
153 });
154
155 return (
156 <div
157 class="draggable-list"
158 ref={listRef}
159 onDragOver={handleDragOver}
160 onDrop={handleDrop}
161 onDragEnd={() => endDrag(true)}
162 >
163 <For each={props.items()}>
164 {(item, index) => {
165 const isDragging = createMemo(() => draggingItem === item);
166 const dropPosition = createMemo(() => {
167 const overIdx = dragOverIndex();
168 return overIdx === index() ? "before" : overIdx === index() + 1 ? "after" : null;
169 });
170
171 return props.children(item, index(), {
172 dragHandlers: {
173 draggable: true,
174 onDragStart: (e) => handleDragStart(item, index(), (e.currentTarget as HTMLElement)?.parentElement, e),
175 onTouchStart: (e) => handleTouchStart(item, index(), e),
176 },
177 isDragging: isDragging,
178 dropPosition: dropPosition,
179 });
180 }}
181 </For>
182 <div class="drop-area-end" />
183 </div>
184 );
185}
186