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