Skip to content

Commit 11a2216

Browse files
committed
chore(util): add a helper function for handling "drag & reorder"
1 parent f1af167 commit 11a2216

File tree

1 file changed

+385
-0
lines changed

1 file changed

+385
-0
lines changed

src/util/drag-to-reorder.ts

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
/**
2+
* Detail payload emitted when a drag reordering gesture starts.
3+
* `index` represents the position of the item among the filtered reorderable nodes.
4+
*/
5+
export interface DragToReorderStartDetail {
6+
index: number;
7+
item: HTMLElement;
8+
id?: string;
9+
}
10+
11+
/**
12+
* Detail payload representing a preview move while the pointer is still held.
13+
* Consumers should update their temporary DOM state to mirror the visual reorder.
14+
*/
15+
export interface DragToReorderPreviewDetail {
16+
fromIndex: number;
17+
toIndex: number;
18+
item: HTMLElement;
19+
id?: string;
20+
}
21+
22+
/**
23+
* Detail payload describing the final outcome of a drag interaction.
24+
* `cancelled` indicates whether the gesture ended normally or aborted mid-way.
25+
*/
26+
export interface DragToReorderFinalizeDetail {
27+
fromIndex: number;
28+
toIndex: number;
29+
item: HTMLElement;
30+
id?: string;
31+
cancelled: boolean;
32+
}
33+
34+
/**
35+
* Configuration for the drag-to-reorder helper.
36+
* `itemSelector` and `dragHandleSelector` should both resolve inside `container`.
37+
*/
38+
export interface DragToReorderOptions {
39+
container: HTMLElement;
40+
itemSelector: string;
41+
dragHandleSelector: string;
42+
draggingClass?: string;
43+
/** Class toggled on the container while a drag is active. */
44+
containerDraggingClass?: string;
45+
/** Class applied briefly when an item is dropped to highlight the new position. */
46+
dropElevationClass?: string;
47+
/** Duration in milliseconds before removing the drop elevation class (defaults to 1000ms). */
48+
dropElevationDuration?: number;
49+
getItemId?: (item: HTMLElement) => string | undefined;
50+
onStart?: (detail: DragToReorderStartDetail) => void;
51+
onPreview: (detail: DragToReorderPreviewDetail) => void;
52+
onFinalize: (detail: DragToReorderFinalizeDetail) => void;
53+
}
54+
55+
export interface DragToReorderController {
56+
destroy(): void;
57+
}
58+
59+
interface ActiveDragState {
60+
pointerId: number;
61+
handle: HTMLElement;
62+
item: HTMLElement;
63+
originalIndex: number;
64+
currentIndex: number;
65+
id?: string;
66+
}
67+
68+
const DEFAULT_DRAGGING_CLASS = 'is-being-dragged';
69+
const DEFAULT_CONTAINER_CLASS = 'has-an-item-which-is-being-dragged';
70+
const DEFAULT_DROP_ELEVATION_CLASS = 'is-elevated';
71+
const DEFAULT_DROP_ELEVATION_DURATION = 1000;
72+
73+
/**
74+
* Drag to reorder utility
75+
*
76+
* Lightweight pointer-driven drag helper for list-like layouts.
77+
* Keeps responsibilities split: this utility handles pointer + DOM bookkeeping,
78+
* while the caller owns rendering state updates through the supplied callbacks.
79+
*
80+
* ## Usage notes
81+
* - This helper uses low-level `pointer*` events instead of the native HTML Drag & Drop API,
82+
* which makes behavior consistent across mouse, touch, and stylus input; and avoids
83+
* many of the quirks and limitations of the native API.
84+
* - The helper relies on CSS selectors to identify both the draggable items and the
85+
* drag handles within them. This allows consumers to define complex item structures
86+
* with specific drag handles (e.g. an icon button) without requiring extra markup
87+
* or event listeners.
88+
* - It assumes items are stacked vertically. The drop target is derived from the
89+
* pointer's Y position relative to each element's midpoint, so horizontal
90+
* arrangements are not currently supported.
91+
* - A CSS class (defaults to `is-being-dragged`) is applied to the active item. Consumers
92+
* should style this class inside their component Shadow DOM (e.g. add elevation) to
93+
* communicate movement. The container simultaneously receives `has-an-item-which-is-being-dragged`
94+
* (override via `containerDraggingClass`) in case you want to highlight the entire drop zone.
95+
* - While dragging the item also gets `is-elevated` (override via `dropElevationClass`). The class
96+
* remains for one second after drop to give styles time to gracefully transition back.
97+
* - The helper temporarily sets `document.body.style.userSelect` and `.cursor` to prevent
98+
* text selection and enforce a grabbing cursor during the interaction. Components do not need
99+
* to manage these resets themselves — they are restored automatically when the drag ends.
100+
* - Call `dragToReorder({ ... })` in `componentDidLoad` (or equivalent) and keep a reference to
101+
* the returned controller so you can invoke `destroy()` when your component unmounts.
102+
* - Use the `onPreview` callback to mirror the current visual ordering (e.g. reorder rows in state)
103+
* and `onFinalize` to persist the change or revert when `cancelled` is true.
104+
*/
105+
106+
/**
107+
* Enable drag-and-drop style reordering for list-like DOM structures.
108+
*
109+
* @param options configuration for drag behavior and callbacks
110+
*/
111+
export function dragToReorder(
112+
options: DragToReorderOptions
113+
): DragToReorderController {
114+
if (!options?.container) {
115+
throw new Error(
116+
'Error in `dragToReorder`: Required option `container` is missing.'
117+
);
118+
}
119+
120+
if (!options.itemSelector) {
121+
throw new Error(
122+
'Error in `dragToReorder`: Required option `itemSelector` is missing.'
123+
);
124+
}
125+
126+
if (!options.dragHandleSelector) {
127+
throw new Error(
128+
'Error in `dragToReorder`: Required option `dragHandleSelector` is missing.'
129+
);
130+
}
131+
132+
if (!options.onPreview) {
133+
throw new Error(
134+
'Error in `dragToReorder`: Required option `onPreview` is missing.'
135+
);
136+
}
137+
138+
if (!options.onFinalize) {
139+
throw new Error(
140+
'Error in `dragToReorder`: Required option `onFinalize` is missing.'
141+
);
142+
}
143+
144+
const draggingClass = options.draggingClass || DEFAULT_DRAGGING_CLASS;
145+
const containerActiveClass =
146+
options.containerDraggingClass || DEFAULT_CONTAINER_CLASS;
147+
const dropElevationClass =
148+
options.dropElevationClass || DEFAULT_DROP_ELEVATION_CLASS;
149+
const dropElevationDuration =
150+
options.dropElevationDuration ?? DEFAULT_DROP_ELEVATION_DURATION;
151+
const getItemId =
152+
options.getItemId ||
153+
((item: HTMLElement) => item.dataset.reorderId || undefined);
154+
155+
let activeDrag: ActiveDragState | undefined;
156+
let previousUserSelect: string | undefined;
157+
let previousCursor: string | undefined;
158+
let dropElevationTimeout: ReturnType<typeof setTimeout> | undefined;
159+
let dropElevationTarget: HTMLElement | undefined;
160+
161+
const getItemElements = () => {
162+
return [
163+
...options.container.querySelectorAll(options.itemSelector),
164+
] as HTMLElement[];
165+
};
166+
167+
// Activate dragging only when pressing a recognized handle inside the container
168+
const pointerDownListener = (event: PointerEvent) => {
169+
if (
170+
event.pointerType === 'mouse' &&
171+
'button' in event &&
172+
event.button !== 0
173+
) {
174+
return;
175+
}
176+
177+
const target = event.target as HTMLElement;
178+
if (!target) {
179+
return;
180+
}
181+
182+
const handle = target.closest(options.dragHandleSelector);
183+
if (!(handle instanceof HTMLElement)) {
184+
return;
185+
}
186+
187+
const item = handle.closest(options.itemSelector);
188+
if (!(item instanceof HTMLElement)) {
189+
return;
190+
}
191+
192+
const items = getItemElements();
193+
const originalIndex = items.indexOf(item);
194+
if (originalIndex === -1) {
195+
return;
196+
}
197+
198+
event.stopPropagation();
199+
event.preventDefault();
200+
201+
if (dropElevationTimeout !== undefined) {
202+
clearTimeout(dropElevationTimeout);
203+
dropElevationTimeout = undefined;
204+
}
205+
if (dropElevationTarget) {
206+
dropElevationTarget.classList.remove(dropElevationClass);
207+
dropElevationTarget = undefined;
208+
}
209+
210+
activeDrag = {
211+
pointerId: event.pointerId,
212+
handle,
213+
item,
214+
originalIndex,
215+
currentIndex: originalIndex,
216+
id: getItemId(item),
217+
};
218+
219+
handle.setPointerCapture?.(event.pointerId);
220+
previousUserSelect = document.body.style.userSelect;
221+
document.body.style.userSelect = 'none';
222+
previousCursor = document.body.style.cursor;
223+
document.body.style.cursor = 'grabbing';
224+
item.classList.add(dropElevationClass, draggingClass);
225+
options.container.classList.add(containerActiveClass);
226+
227+
options.onStart?.({
228+
index: originalIndex,
229+
item,
230+
id: activeDrag.id,
231+
});
232+
233+
// Listen on the document to keep tracking even when the pointer leaves the list
234+
document.addEventListener('pointermove', pointerMoveListener, {
235+
passive: false,
236+
});
237+
document.addEventListener('pointerup', pointerUpListener);
238+
document.addEventListener('pointercancel', pointerCancelListener);
239+
};
240+
241+
// Calculate which item the pointer hovers over and inform the consumer
242+
const pointerMoveListener = (event: PointerEvent) => {
243+
if (!activeDrag) {
244+
return;
245+
}
246+
247+
event.preventDefault();
248+
249+
const items = getItemElements();
250+
if (items.length === 0) {
251+
return;
252+
}
253+
254+
const pointerY = event.clientY;
255+
let targetIndex = items.length - 1;
256+
257+
let index = 0;
258+
for (const element of items) {
259+
const rect = element.getBoundingClientRect();
260+
const threshold = rect.top + rect.height / 2;
261+
if (pointerY < threshold) {
262+
targetIndex = index;
263+
break;
264+
}
265+
index += 1;
266+
}
267+
268+
if (targetIndex < 0) {
269+
targetIndex = 0;
270+
}
271+
272+
if (targetIndex === activeDrag.currentIndex) {
273+
return;
274+
}
275+
276+
options.onPreview({
277+
fromIndex: activeDrag.currentIndex,
278+
toIndex: targetIndex,
279+
item: activeDrag.item,
280+
id: activeDrag.id,
281+
});
282+
283+
activeDrag.currentIndex = targetIndex;
284+
};
285+
286+
// Clean up listeners, restore styles, and let the consumer decide how to persist
287+
const finalizeDrag = (cancelled: boolean) => {
288+
if (!activeDrag) {
289+
return;
290+
}
291+
292+
const state = activeDrag;
293+
activeDrag = undefined;
294+
295+
state.handle.releasePointerCapture?.(state.pointerId);
296+
state.item.classList.remove(draggingClass);
297+
options.container.classList.remove(containerActiveClass);
298+
299+
document.body.style.userSelect = previousUserSelect ?? '';
300+
previousUserSelect = undefined;
301+
document.body.style.cursor = previousCursor ?? '';
302+
previousCursor = undefined;
303+
304+
document.removeEventListener('pointermove', pointerMoveListener);
305+
document.removeEventListener('pointerup', pointerUpListener);
306+
document.removeEventListener('pointercancel', pointerCancelListener);
307+
308+
options.onFinalize({
309+
fromIndex: state.originalIndex,
310+
toIndex: state.currentIndex,
311+
item: state.item,
312+
id: state.id,
313+
cancelled,
314+
});
315+
316+
if (dropElevationTimeout !== undefined) {
317+
clearTimeout(dropElevationTimeout);
318+
dropElevationTimeout = undefined;
319+
}
320+
321+
if (cancelled || dropElevationDuration <= 0) {
322+
state.item.classList.remove(dropElevationClass);
323+
if (dropElevationTarget === state.item) {
324+
dropElevationTarget = undefined;
325+
}
326+
return;
327+
}
328+
329+
dropElevationTarget = state.item;
330+
const itemToReset = state.item;
331+
dropElevationTimeout = globalThis.setTimeout(() => {
332+
itemToReset.classList.remove(dropElevationClass);
333+
if (dropElevationTarget === itemToReset) {
334+
dropElevationTarget = undefined;
335+
}
336+
dropElevationTimeout = undefined;
337+
}, dropElevationDuration);
338+
};
339+
340+
// Pointer released normally – treat as a completed reorder
341+
const pointerUpListener = (event: PointerEvent) => {
342+
if (!activeDrag || event.pointerId !== activeDrag.pointerId) {
343+
return;
344+
}
345+
346+
event.preventDefault();
347+
finalizeDrag(false);
348+
};
349+
350+
// Browser or OS cancelled the pointer sequence – revert to the snapshot state
351+
const pointerCancelListener = (event: PointerEvent) => {
352+
if (!activeDrag || event.pointerId !== activeDrag.pointerId) {
353+
return;
354+
}
355+
356+
finalizeDrag(true);
357+
};
358+
359+
options.container.addEventListener('pointerdown', pointerDownListener);
360+
361+
// Allow the caller to tear down the helper, useful when the list unmounts
362+
const destroy = () => {
363+
options.container.removeEventListener(
364+
'pointerdown',
365+
pointerDownListener
366+
);
367+
if (activeDrag) {
368+
finalizeDrag(true);
369+
}
370+
371+
if (dropElevationTimeout !== undefined) {
372+
clearTimeout(dropElevationTimeout);
373+
dropElevationTimeout = undefined;
374+
}
375+
376+
if (dropElevationTarget) {
377+
dropElevationTarget.classList.remove(dropElevationClass);
378+
dropElevationTarget = undefined;
379+
}
380+
};
381+
382+
return {
383+
destroy,
384+
};
385+
}

0 commit comments

Comments
 (0)