dnd-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Javi Aguilar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,769 @@
1
+ # dnd-manager
2
+
3
+ Simple, performant, and data-driven Drag-and-Drop library for vanilla JS, and compatible with any framework.
4
+
5
+ This document provides practical examples of using `DragDropManager` in both vanilla
6
+ JavaScript and React applications.
7
+
8
+ ## Table of Contents
9
+
10
+ 1. [Vanilla JavaScript Example](#vanilla-javascript-example)
11
+ 2. [React Example](#react-example)
12
+
13
+ ---
14
+
15
+ ## Vanilla JavaScript Example
16
+
17
+ This example creates a draggable grid with cells that can be reordered. Clicking a cell logs its
18
+ data to the console, and dragging shows a preview element following the cursor.
19
+
20
+ **HTML:**
21
+
22
+ ```html
23
+ <!DOCTYPE html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="UTF-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
+ <title>Vanilla JS DragDrop Grid</title>
29
+ <style>
30
+ body {
31
+ margin: 0;
32
+ padding: 20px;
33
+ font-family:
34
+ system-ui,
35
+ -apple-system,
36
+ sans-serif;
37
+ background: #f9fafb;
38
+ }
39
+
40
+ .grid {
41
+ display: grid;
42
+ grid-template-columns: repeat(3, 120px);
43
+ gap: 8px;
44
+ padding: 20px;
45
+ }
46
+
47
+ .cell {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ font-weight: 600;
52
+ border-radius: 8px;
53
+ padding: 20px;
54
+ min-height: 80px;
55
+ transition:
56
+ transform 0.2s,
57
+ opacity 0.2s,
58
+ box-shadow 0.2s;
59
+ user-select: none;
60
+ touch-action: none;
61
+ }
62
+
63
+ .cell:not([data-empty]) {
64
+ color: white;
65
+ cursor: grab;
66
+ }
67
+
68
+ .cell[data-empty] {
69
+ background: #f3f4f6;
70
+ border: 2px dashed #d1d5db;
71
+ }
72
+
73
+ /* Dragging states */
74
+ .cell[data-dragging='true'] {
75
+ opacity: 0.4;
76
+ cursor: grabbing !important;
77
+ }
78
+
79
+ .cell[data-hovered='true'] {
80
+ transform: scale(1.05);
81
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
82
+ }
83
+
84
+ /* Drag preview */
85
+ #drag-preview {
86
+ position: fixed;
87
+ pointer-events: none;
88
+ z-index: 9999;
89
+ opacity: 0;
90
+ transform: translate(-50%, -50%);
91
+ transition: opacity 0.2s;
92
+ padding: 20px;
93
+ border-radius: 8px;
94
+ color: white;
95
+ font-weight: 600;
96
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <h1>Vanilla JS Drag & Drop Grid</h1>
102
+ <p>Drag cells to reorder, click to log to console</p>
103
+
104
+ <div id="grid-container" class="grid">
105
+ <!-- Row 0 -->
106
+ <div
107
+ class="cell"
108
+ data-kind="cell"
109
+ data-row="0"
110
+ data-col="0"
111
+ data-item-id="1"
112
+ data-name="Item 1"
113
+ style="background: #ef4444"
114
+ >
115
+ Item 1
116
+ </div>
117
+ <div
118
+ class="cell"
119
+ data-kind="cell"
120
+ data-row="0"
121
+ data-col="1"
122
+ data-item-id="2"
123
+ data-name="Item 2"
124
+ style="background: #3b82f6"
125
+ >
126
+ Item 2
127
+ </div>
128
+ <div
129
+ class="cell"
130
+ data-kind="cell"
131
+ data-row="0"
132
+ data-col="2"
133
+ data-item-id="3"
134
+ data-name="Item 3"
135
+ style="background: #10b981"
136
+ >
137
+ Item 3
138
+ </div>
139
+
140
+ <!-- Row 1 -->
141
+ <div
142
+ class="cell"
143
+ data-kind="cell"
144
+ data-row="1"
145
+ data-col="0"
146
+ data-item-id="4"
147
+ data-name="Item 4"
148
+ style="background: #f59e0b"
149
+ >
150
+ Item 4
151
+ </div>
152
+ <div class="cell" data-kind="cell" data-row="1" data-col="1" data-empty></div>
153
+ <div
154
+ class="cell"
155
+ data-kind="cell"
156
+ data-row="1"
157
+ data-col="2"
158
+ data-item-id="5"
159
+ data-name="Item 5"
160
+ style="background: #8b5cf6"
161
+ >
162
+ Item 5
163
+ </div>
164
+
165
+ <!-- Row 2 -->
166
+ <div
167
+ class="cell"
168
+ data-kind="cell"
169
+ data-row="2"
170
+ data-col="0"
171
+ data-item-id="6"
172
+ data-name="Item 6"
173
+ style="background: #ec4899"
174
+ >
175
+ Item 6
176
+ </div>
177
+ <div
178
+ class="cell"
179
+ data-kind="cell"
180
+ data-row="2"
181
+ data-col="1"
182
+ data-item-id="7"
183
+ data-name="Item 7"
184
+ style="background: #14b8a6"
185
+ >
186
+ Item 7
187
+ </div>
188
+ <div class="cell" data-kind="cell" data-row="2" data-col="2" data-empty></div>
189
+ </div>
190
+
191
+ <!-- Drag preview element -->
192
+ <div id="drag-preview"></div>
193
+
194
+ <script type="module" src="./app.ts"></script>
195
+ </body>
196
+ </html>
197
+ ```
198
+
199
+ **TypeScript (app.ts):**
200
+
201
+ ```typescript
202
+ import { DragDropManager, type DragDropCallbacks } from './drag-drop-manager'
203
+
204
+ // Types
205
+ type GridItem = {
206
+ id: string
207
+ name: string
208
+ color: string
209
+ }
210
+
211
+ type GridPosition = {
212
+ row: number
213
+ col: number
214
+ }
215
+
216
+ // Initialize
217
+ function initDragDropGrid(): void {
218
+ const container = document.getElementById('grid-container')
219
+ const preview = document.getElementById('drag-preview')
220
+
221
+ if (!container || !preview) return
222
+
223
+ // Setup DragDropManager callbacks
224
+ const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
225
+ canDrag: (element, position) => {
226
+ // Can only drag non-empty cells
227
+ return element.dataset.empty === undefined
228
+ },
229
+
230
+ getItemPosition: (element, kind) => {
231
+ const row = element.dataset.row
232
+ const col = element.dataset.col
233
+ if (row === undefined || col === undefined) return null
234
+
235
+ return {
236
+ row: parseInt(row, 10),
237
+ col: parseInt(col, 10),
238
+ }
239
+ },
240
+
241
+ getItemData: (element, position) => {
242
+ const itemId = element.dataset.itemId
243
+ const name = element.dataset.name
244
+ const color = element.style.background
245
+
246
+ if (!itemId || !name || !color) return null
247
+
248
+ return {
249
+ id: itemId,
250
+ name: name,
251
+ color: color,
252
+ }
253
+ },
254
+
255
+ onDragStart: (element, position, item) => {
256
+ console.log('🚀 Drag started:', item)
257
+
258
+ // Copy element dimensions and styles to preview
259
+ const rect = element.getBoundingClientRect()
260
+ preview.textContent = item.name
261
+ preview.style.background = item.color
262
+ preview.style.width = `${rect.width}px`
263
+ preview.style.height = `${rect.height}px`
264
+ preview.style.opacity = '0.9'
265
+ },
266
+
267
+ onDragMove: (pos, hoveredElement) => {
268
+ // Update preview position
269
+ preview.style.left = `${pos.x}px`
270
+ preview.style.top = `${pos.y}px`
271
+ },
272
+
273
+ onDrop: (sourcePos, targetPos, sourceItem) => {
274
+ console.log('📦 Dropped:', {
275
+ from: sourcePos,
276
+ to: targetPos,
277
+ item: sourceItem,
278
+ })
279
+
280
+ // Get source and target elements
281
+ const sourceElement = container.querySelector(
282
+ `[data-row="${sourcePos.row}"][data-col="${sourcePos.col}"]`,
283
+ ) as HTMLElement
284
+ const targetElement = container.querySelector(
285
+ `[data-row="${targetPos.row}"][data-col="${targetPos.col}"]`,
286
+ ) as HTMLElement
287
+
288
+ if (!sourceElement || !targetElement) return
289
+
290
+ // Swap element contents and data attributes
291
+ const tempContent = sourceElement.innerHTML
292
+ const tempId = sourceElement.dataset.itemId
293
+ const tempName = sourceElement.dataset.name
294
+ const tempColor = sourceElement.style.background
295
+ const tempEmpty = sourceElement.dataset.empty
296
+
297
+ // Update source with target data
298
+ sourceElement.innerHTML = targetElement.innerHTML
299
+ sourceElement.dataset.itemId = targetElement.dataset.itemId || ''
300
+ sourceElement.dataset.name = targetElement.dataset.name || ''
301
+ sourceElement.style.background = targetElement.style.background
302
+ if (targetElement.dataset.empty !== undefined) {
303
+ sourceElement.dataset.empty = ''
304
+ } else {
305
+ delete sourceElement.dataset.empty
306
+ }
307
+
308
+ // Update target with source data
309
+ targetElement.innerHTML = tempContent
310
+ targetElement.dataset.itemId = tempId || ''
311
+ targetElement.dataset.name = tempName || ''
312
+ targetElement.style.background = tempColor
313
+ if (tempEmpty !== undefined) {
314
+ targetElement.dataset.empty = ''
315
+ } else {
316
+ delete targetElement.dataset.empty
317
+ }
318
+ },
319
+
320
+ onDragEnd: () => {
321
+ // Hide preview
322
+ preview.style.opacity = '0'
323
+ },
324
+
325
+ onClick: (element, position) => {
326
+ const itemId = element.dataset.itemId
327
+ const name = element.dataset.name
328
+ const color = element.style.background
329
+
330
+ if (itemId && name) {
331
+ console.log('👆 Clicked:', { id: itemId, name, color })
332
+ }
333
+ },
334
+ }
335
+
336
+ // Create DragDropManager instance
337
+ const dragDropManager = new DragDropManager<GridItem, GridPosition>(
338
+ container,
339
+ {
340
+ draggableKind: 'cell',
341
+ droppableKind: 'cell',
342
+ dragThreshold: 10,
343
+ clickThreshold: 10,
344
+ scrollThreshold: 100,
345
+ scrollSpeed: 10,
346
+ },
347
+ callbacks,
348
+ )
349
+
350
+ // Cleanup on page unload
351
+ window.addEventListener('beforeunload', () => {
352
+ dragDropManager.destroy()
353
+ })
354
+ }
355
+
356
+ // Start when DOM is ready
357
+ document.addEventListener('DOMContentLoaded', initDragDropGrid)
358
+ ```
359
+
360
+ ---
361
+
362
+ ## React Example
363
+
364
+ This example shows the same draggable grid implemented as a React component with TypeScript.
365
+
366
+ ```typescript
367
+ import { useEffect, useRef, useState } from 'react'
368
+ import { DragDropManager, type DragDropCallbacks } from './drag-drop-manager'
369
+
370
+ // Types
371
+ type GridItem = {
372
+ id: string
373
+ name: string
374
+ color: string
375
+ }
376
+
377
+ type GridPosition = {
378
+ row: number
379
+ col: number
380
+ }
381
+
382
+ // Initial data
383
+ const INITIAL_DATA: (GridItem | null)[][] = [
384
+ [
385
+ { id: '1', name: 'Item 1', color: '#ef4444' },
386
+ { id: '2', name: 'Item 2', color: '#3b82f6' },
387
+ { id: '3', name: 'Item 3', color: '#10b981' },
388
+ ],
389
+ [
390
+ { id: '4', name: 'Item 4', color: '#f59e0b' },
391
+ null,
392
+ { id: '5', name: 'Item 5', color: '#8b5cf6' },
393
+ ],
394
+ [
395
+ { id: '6', name: 'Item 6', color: '#ec4899' },
396
+ { id: '7', name: 'Item 7', color: '#14b8a6' },
397
+ null,
398
+ ],
399
+ ]
400
+
401
+ // Cell component
402
+ type CellProps = {
403
+ item: GridItem | null
404
+ row: number
405
+ col: number
406
+ } & React.ComponentProps<'div'>
407
+
408
+ function GridCell({ item, row, col, ...props }: CellProps) {
409
+ if (item) {
410
+ return (
411
+ <div
412
+ data-kind="cell"
413
+ data-row={row}
414
+ data-col={col}
415
+ data-item-id={item.id}
416
+ className="grid-cell grid-cell--filled"
417
+ style={{
418
+ background: item.color,
419
+ }}
420
+ {...props}
421
+ >
422
+ {item.name}
423
+ </div>
424
+ )
425
+ }
426
+
427
+ return (
428
+ <div
429
+ data-kind="cell"
430
+ data-row={row}
431
+ data-col={col}
432
+ data-empty="true"
433
+ className="grid-cell grid-cell--empty"
434
+ {...props}
435
+ />
436
+ )
437
+ }
438
+
439
+ // Drag preview component
440
+ type DragPreviewProps = {
441
+ item: GridItem | null
442
+ position: { x: number; y: number } | null
443
+ width?: number
444
+ height?: number
445
+ }
446
+
447
+ function DragPreview({ item, position, width, height }: DragPreviewProps) {
448
+ if (!item || !position) return null
449
+
450
+ return (
451
+ <div
452
+ className="drag-preview"
453
+ style={{
454
+ left: position.x,
455
+ top: position.y,
456
+ background: item.color,
457
+ width: width ? `${width}px` : undefined,
458
+ height: height ? `${height}px` : undefined,
459
+ }}
460
+ >
461
+ {item.name}
462
+ </div>
463
+ )
464
+ }
465
+
466
+ // Main component
467
+ export function DraggableGrid() {
468
+ const containerRef = useRef<HTMLDivElement>(null)
469
+ const [gridData, setGridData] = useState<(GridItem | null)[][]>(INITIAL_DATA)
470
+ const [dragPreview, setDragPreview] = useState<{
471
+ item: GridItem | null
472
+ position: { x: number; y: number } | null
473
+ width?: number
474
+ height?: number
475
+ }>({ item: null, position: null })
476
+
477
+ useEffect(() => {
478
+ if (!containerRef.current) return
479
+
480
+ // Setup callbacks
481
+ const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
482
+ canDrag: (element, position) => {
483
+ return element.dataset.empty === undefined
484
+ },
485
+
486
+ getItemPosition: (element, kind) => {
487
+ const row = element.dataset.row
488
+ const col = element.dataset.col
489
+ if (row === undefined || col === undefined) return null
490
+
491
+ return {
492
+ row: parseInt(row, 10),
493
+ col: parseInt(col, 10),
494
+ }
495
+ },
496
+
497
+ getItemData: (element, position) => {
498
+ return gridData[position.row][position.col]
499
+ },
500
+
501
+ onDragStart: (element, position, item) => {
502
+ console.log('🚀 Drag started:', item)
503
+
504
+ // Store element dimensions for preview
505
+ const rect = element.getBoundingClientRect()
506
+ setDragPreview({
507
+ item,
508
+ position: null,
509
+ width: rect.width,
510
+ height: rect.height,
511
+ })
512
+ },
513
+
514
+ onDragMove: (pos, hoveredElement) => {
515
+ setDragPreview((prev) => ({
516
+ ...prev,
517
+ position: pos,
518
+ }))
519
+ },
520
+
521
+ onDrop: (sourcePos, targetPos, sourceItem) => {
522
+ console.log('📦 Dropped:', {
523
+ from: sourcePos,
524
+ to: targetPos,
525
+ item: sourceItem,
526
+ })
527
+
528
+ // Swap items
529
+ setGridData((prevData) => {
530
+ const newData = prevData.map((row) => [...row])
531
+ const targetItem = newData[targetPos.row][targetPos.col]
532
+ newData[targetPos.row][targetPos.col] = sourceItem
533
+ newData[sourcePos.row][sourcePos.col] = targetItem
534
+ return newData
535
+ })
536
+ },
537
+
538
+ onDragEnd: () => {
539
+ setDragPreview({ item: null, position: null, width: undefined, height: undefined })
540
+ },
541
+
542
+ onClick: (element, position) => {
543
+ const item = gridData[position.row][position.col]
544
+ if (item) {
545
+ console.log('👆 Clicked:', item)
546
+ }
547
+ },
548
+ }
549
+
550
+ // Create manager
551
+ const manager = new DragDropManager<GridItem, GridPosition>(
552
+ containerRef,
553
+ {
554
+ draggableKind: 'cell',
555
+ droppableKind: 'cell',
556
+ dragThreshold: 10,
557
+ clickThreshold: 10,
558
+ scrollThreshold: 100,
559
+ scrollSpeed: 10,
560
+ },
561
+ callbacks,
562
+ )
563
+
564
+ // Cleanup
565
+ return () => {
566
+ manager.destroy()
567
+ }
568
+ }, [gridData])
569
+
570
+ return (
571
+ <div className="draggable-grid-container">
572
+ <h1>React Drag & Drop Grid</h1>
573
+ <p>Drag cells to reorder, click to log to console</p>
574
+
575
+ <div ref={containerRef} className="grid">
576
+ {gridData.map((row, rowIndex) =>
577
+ row.map((item, colIndex) => (
578
+ <GridCell key={`${rowIndex}-${colIndex}`} item={item} row={rowIndex} col={colIndex} />
579
+ )),
580
+ )}
581
+ </div>
582
+
583
+ <DragPreview
584
+ item={dragPreview.item}
585
+ position={dragPreview.position}
586
+ width={dragPreview.width}
587
+ height={dragPreview.height}
588
+ />
589
+ </div>
590
+ )
591
+ }
592
+ ```
593
+
594
+ **CSS (Tailwind or plain CSS):**
595
+
596
+ ```css
597
+ /* Container */
598
+ .draggable-grid-container {
599
+ padding: 20px;
600
+ font-family:
601
+ system-ui,
602
+ -apple-system,
603
+ sans-serif;
604
+ }
605
+
606
+ /* Grid */
607
+ .grid {
608
+ display: grid;
609
+ grid-template-columns: repeat(3, 120px);
610
+ gap: 8px;
611
+ padding: 20px;
612
+ }
613
+
614
+ /* Grid cells */
615
+ .grid-cell {
616
+ display: flex;
617
+ align-items: center;
618
+ justify-content: center;
619
+ font-weight: 600;
620
+ border-radius: 8px;
621
+ transition:
622
+ transform 0.2s,
623
+ opacity 0.2s,
624
+ box-shadow 0.2s;
625
+ user-select: none;
626
+ touch-action: none;
627
+ }
628
+
629
+ .grid-cell--filled {
630
+ padding: 20px;
631
+ color: white;
632
+ cursor: grab;
633
+ min-height: 80px;
634
+ }
635
+
636
+ .grid-cell--empty {
637
+ background: #f3f4f6;
638
+ border: 2px dashed #d1d5db;
639
+ min-height: 80px;
640
+ }
641
+
642
+ /* Dragging state */
643
+ .grid-cell[data-dragging='true'] {
644
+ opacity: 0.4;
645
+ cursor: grabbing !important;
646
+ }
647
+
648
+ /* Hover state */
649
+ .grid-cell[data-hovered='true'] {
650
+ transform: scale(1.05);
651
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
652
+ }
653
+
654
+ /* Drag preview */
655
+ .drag-preview {
656
+ position: fixed;
657
+ pointer-events: none;
658
+ z-index: 9999;
659
+ transform: translate(-50%, -50%);
660
+ padding: 20px;
661
+ border-radius: 8px;
662
+ color: white;
663
+ font-weight: 600;
664
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
665
+ opacity: 0.9;
666
+ }
667
+ ```
668
+
669
+ **With Tailwind CSS:**
670
+
671
+ ```typescript
672
+ // Update cell component to use Tailwind classes
673
+ function GridCell({ item, row, col, ...props }: CellProps) {
674
+ if (item) {
675
+ return (
676
+ <div
677
+ data-kind="cell"
678
+ data-row={row}
679
+ data-col={col}
680
+ data-item-id={item.id}
681
+ className="flex items-center justify-center p-5 rounded-lg cursor-grab
682
+ font-semibold text-white transition-all duration-200
683
+ data-[dragging=true]:opacity-40 data-[dragging=true]:cursor-grabbing
684
+ data-[hovered=true]:scale-105 data-[hovered=true]:ring-4
685
+ data-[hovered=true]:ring-blue-500/50"
686
+ style={{ background: item.color }}
687
+ {...props}
688
+ >
689
+ {item.name}
690
+ </div>
691
+ )
692
+ }
693
+
694
+ return (
695
+ <div
696
+ data-kind="cell"
697
+ data-row={row}
698
+ data-col={col}
699
+ data-empty="true"
700
+ className="bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg min-h-[80px]"
701
+ {...props}
702
+ />
703
+ )
704
+ }
705
+
706
+ // Drag preview with Tailwind
707
+ function DragPreview({ item, position, width, height }: DragPreviewProps) {
708
+ if (!item || !position) return null
709
+
710
+ return (
711
+ <div
712
+ className="fixed pointer-events-none z-[9999] -translate-x-1/2 -translate-y-1/2
713
+ p-5 rounded-lg text-white font-semibold shadow-2xl opacity-90"
714
+ style={{
715
+ left: position.x,
716
+ top: position.y,
717
+ background: item.color,
718
+ width: width ? `${width}px` : undefined,
719
+ height: height ? `${height}px` : undefined,
720
+ }}
721
+ >
722
+ {item.name}
723
+ </div>
724
+ )
725
+ }
726
+ ```
727
+
728
+ ---
729
+
730
+ ## Covered Features
731
+
732
+ ### Both Examples Include:
733
+
734
+ 1. **Grid Layout** - 3x3 grid with draggable cells
735
+ 2. **Click Detection** - Logs item data to console on click
736
+ 3. **Drag Preview** - Visual feedback following cursor during drag, matching original element size
737
+ 4. **Swap Behavior** - Items swap positions on drop
738
+ 5. **Empty Cells** - Non-draggable placeholder cells
739
+ 6. **Visual Feedback** - Opacity changes, hover states, and scaling
740
+ 7. **TypeScript Types** - Full type safety with generics
741
+
742
+ ### DragDropManager Features Used:
743
+
744
+ - `canDrag` - Prevents dragging empty cells
745
+ - `getItemPosition` - Extracts grid coordinates from data attributes
746
+ - `getItemData` - Retrieves item data from grid state
747
+ - `onDragStart` - Shows preview and updates source appearance
748
+ - Uses `getBoundingClientRect()` to copy element dimensions to preview
749
+ - `onDragMove` - Updates preview position in real-time
750
+ - `onDrop` - Swaps items and updates state
751
+ - `onDragEnd` - Hides preview
752
+ - `onClick` - Handles click/tap events
753
+
754
+ ### Configuration:
755
+
756
+ - `dragThreshold: 10` - Prevents accidental drags
757
+ - `clickThreshold: 10` - Distinguishes clicks from drags
758
+ - `scrollThreshold: 100` - Auto-scroll near viewport edges
759
+ - `scrollSpeed: 10` - Smooth scroll speed
760
+
761
+ ---
762
+
763
+ ## Notes
764
+
765
+ - **Performance**: The manager uses `requestAnimationFrame` for smooth updates at 60fps
766
+ - **Mobile-Friendly**: Uses pointer events which work with touch, mouse, and pen
767
+ - **Accessibility**: Uses data attributes that don't interfere with screen readers
768
+ - **React Integration**: Manager is created in `useEffect` with proper cleanup
769
+ - **State Management**: React example uses `useState` to trigger re-renders after drops
@@ -0,0 +1,131 @@
1
+ //#region src/drag-drop-manager.d.ts
2
+ /**
3
+ * Framework-agnostic Drag and Drop Manager
4
+ *
5
+ * A data-driven drag-and-drop utility that works with React and vanilla JS.
6
+ * Uses data attributes and direct DOM manipulation for optimal performance.
7
+ *
8
+ * @template TItem - The type of data associated with draggable items
9
+ * @template TPosition - The type representing position/coordinates (e.g., {x, y}, {box, cell}, etc.)
10
+ */
11
+ type DragDropConfig = {
12
+ /**
13
+ * Minimum distance in pixels the pointer must move before a drag operation starts.
14
+ * Prevents accidental drags from small movements and allows normal scrolling on mobile.
15
+ * Checked during `pointermove` before drag has started.
16
+ * @default 10
17
+ * @example
18
+ * dragThreshold: 15 // Harder to start drag (better for mobile scroll)
19
+ */
20
+ dragThreshold?: number;
21
+ /**
22
+ * Maximum distance in pixels between pointerdown and pointerup to treat as a click/tap.
23
+ * Used to distinguish intentional clicks from cancelled drag attempts.
24
+ * Only checked on `pointerup` if drag never started.
25
+ * @default 10
26
+ * @example
27
+ * clickThreshold: 5 // Stricter click detection
28
+ * clickThreshold: 15 // More forgiving for mobile taps
29
+ */
30
+ clickThreshold?: number;
31
+ /**
32
+ * Distance in pixels from viewport edge that triggers auto-scrolling during drag.
33
+ * When dragging near screen edges, the window automatically scrolls.
34
+ * @default 100
35
+ * @example
36
+ * scrollThreshold: 50 // Start scrolling closer to edges
37
+ * scrollThreshold: 150 // Larger safe zone before scrolling
38
+ */
39
+ scrollThreshold?: number;
40
+ /**
41
+ * Scroll speed in pixels per animation frame during auto-scroll.
42
+ * Higher values = faster scrolling when dragging near viewport edges.
43
+ * @default 10
44
+ * @example
45
+ * scrollSpeed: 5 // Slower, smoother scroll
46
+ * scrollSpeed: 20 // Faster scroll
47
+ */
48
+ scrollSpeed?: number;
49
+ /**
50
+ * The `data-kind` attribute value for draggable elements.
51
+ * Elements with this attribute can be picked up and dragged.
52
+ * @example
53
+ * draggableKind: 'cell' // <div data-kind="cell">
54
+ * draggableKind: 'card' // <div data-kind="card">
55
+ * draggableKind: 'list-item' // <div data-kind="list-item">
56
+ */
57
+ draggableKind: string;
58
+ /**
59
+ * The `data-kind` attribute value for droppable target elements.
60
+ * Elements with this attribute can receive dropped items.
61
+ * Often the same as `draggableKind` for reorderable lists.
62
+ * @example
63
+ * droppableKind: 'cell' // Can drop on cells
64
+ * droppableKind: 'zone' // Can drop on zones
65
+ */
66
+ droppableKind: string;
67
+ };
68
+ type PointerPosition = {
69
+ x: number;
70
+ y: number;
71
+ };
72
+ type DragDropCallbacks<TItem, TPosition> = {
73
+ /** Called when determining if an element can be dragged */canDrag?: (element: HTMLElement, position: TPosition) => boolean; /** Called when drag starts (after threshold is met) */
74
+ onDragStart?: (element: HTMLElement, position: TPosition, item: TItem) => void; /** Called continuously during drag */
75
+ onDragMove?: (pos: PointerPosition, hoveredElement: HTMLElement | null) => void; /** Called when item is dropped on a valid target */
76
+ onDrop?: (sourcePosition: TPosition, targetPosition: TPosition, sourceItem: TItem) => void; /** Called when drag ends (regardless of success) */
77
+ onDragEnd?: () => void; /** Called on simple click/tap (no drag) */
78
+ onClick?: (element: HTMLElement, position: TPosition) => void; /** Called to extract item data from an element */
79
+ getItemData?: (element: HTMLElement, position: TPosition) => TItem | null; /** Called to get the custom position/coordinates of an element */
80
+ getItemPosition: (element: HTMLElement, kind: string) => TPosition | null;
81
+ };
82
+ type DragState<TItem, TPosition> = {
83
+ startPosition: PointerPosition | null;
84
+ sourceElement: HTMLElement | null;
85
+ sourcePosition: TPosition | null;
86
+ sourceItem: TItem | null;
87
+ isDragging: boolean;
88
+ lastHoveredElement: HTMLElement | null;
89
+ };
90
+ declare class DragDropManager<TItem = unknown, TPosition = unknown> {
91
+ private container;
92
+ private config;
93
+ private callbacks;
94
+ private state;
95
+ private rafId;
96
+ private lastPointerPos;
97
+ private boundHandlers;
98
+ constructor(containerRef: {
99
+ current: HTMLElement | null;
100
+ } | string | HTMLElement, config: DragDropConfig, callbacks: DragDropCallbacks<TItem, TPosition>);
101
+ private attachListeners;
102
+ private handlePointerDown;
103
+ private handlePointerMove;
104
+ private handlePointerUp;
105
+ private handlePointerCancel;
106
+ private handleAutoScroll;
107
+ private cleanup;
108
+ /**
109
+ * Find closest ancestor element with matching data-kind attribute
110
+ */
111
+ private findElementByKind;
112
+ /**
113
+ * Calculate distance between two points
114
+ */
115
+ private getDistance;
116
+ /**
117
+ * Get current drag state (read-only)
118
+ */
119
+ getState(): Readonly<DragState<TItem, TPosition>>;
120
+ /**
121
+ * Check if currently dragging
122
+ */
123
+ isDragging(): boolean;
124
+ /**
125
+ * Cleanup and remove event listeners
126
+ */
127
+ destroy(): void;
128
+ }
129
+ //#endregion
130
+ export { type DragDropCallbacks, type DragDropConfig, DragDropManager, type PointerPosition };
131
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,200 @@
1
+ //#region src/drag-drop-manager.ts
2
+ var DragDropManager = class {
3
+ container;
4
+ config;
5
+ callbacks;
6
+ state = {
7
+ startPosition: null,
8
+ sourceElement: null,
9
+ sourcePosition: null,
10
+ sourceItem: null,
11
+ isDragging: false,
12
+ lastHoveredElement: null
13
+ };
14
+ rafId = null;
15
+ lastPointerPos = null;
16
+ boundHandlers;
17
+ constructor(containerRef, config, callbacks) {
18
+ if (typeof containerRef === "string") {
19
+ const element = document.querySelector(containerRef);
20
+ if (!element) throw new Error(`DragDropManager: Could not find element with selector "${containerRef}"`);
21
+ this.container = element;
22
+ } else if (typeof containerRef === "object" && "current" in containerRef) {
23
+ if (!containerRef.current) throw new Error("DragDropManager: Ref.current is null");
24
+ this.container = containerRef.current;
25
+ } else this.container = containerRef;
26
+ this.config = {
27
+ dragThreshold: config.dragThreshold ?? 10,
28
+ clickThreshold: config.clickThreshold ?? 10,
29
+ scrollThreshold: config.scrollThreshold ?? 100,
30
+ scrollSpeed: config.scrollSpeed ?? 10,
31
+ draggableKind: config.draggableKind,
32
+ droppableKind: config.droppableKind
33
+ };
34
+ this.callbacks = callbacks;
35
+ this.boundHandlers = {
36
+ pointerDown: this.handlePointerDown.bind(this),
37
+ pointerMove: this.handlePointerMove.bind(this),
38
+ pointerUp: this.handlePointerUp.bind(this),
39
+ pointerCancel: this.handlePointerCancel.bind(this)
40
+ };
41
+ this.attachListeners();
42
+ }
43
+ attachListeners() {
44
+ this.container.addEventListener("pointerdown", this.boundHandlers.pointerDown);
45
+ this.container.addEventListener("pointermove", this.boundHandlers.pointerMove);
46
+ this.container.addEventListener("pointerup", this.boundHandlers.pointerUp);
47
+ this.container.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
48
+ }
49
+ handlePointerDown(e) {
50
+ const target = e.target;
51
+ const draggableElement = this.findElementByKind(target, this.config.draggableKind);
52
+ if (!draggableElement) return;
53
+ const position = this.callbacks.getItemPosition(draggableElement, this.config.draggableKind);
54
+ if (!position) return;
55
+ if (draggableElement.dataset.empty !== void 0) return;
56
+ if (this.callbacks.canDrag && !this.callbacks.canDrag(draggableElement, position)) return;
57
+ const item = this.callbacks.getItemData?.(draggableElement, position) ?? null;
58
+ if (!item) return;
59
+ e.preventDefault();
60
+ this.state.startPosition = {
61
+ x: e.clientX,
62
+ y: e.clientY
63
+ };
64
+ this.state.sourceElement = draggableElement;
65
+ this.state.sourcePosition = position;
66
+ this.state.sourceItem = item;
67
+ }
68
+ handlePointerMove(e) {
69
+ const currentPos = {
70
+ x: e.clientX,
71
+ y: e.clientY
72
+ };
73
+ if (this.state.startPosition && !this.state.isDragging) {
74
+ if (this.getDistance(this.state.startPosition, currentPos) >= this.config.dragThreshold) {
75
+ this.state.isDragging = true;
76
+ if (this.state.sourceElement && this.state.sourcePosition && this.state.sourceItem) {
77
+ this.state.sourceElement.setAttribute("data-dragging", "true");
78
+ this.callbacks.onDragStart?.(this.state.sourceElement, this.state.sourcePosition, this.state.sourceItem);
79
+ }
80
+ }
81
+ return;
82
+ }
83
+ if (!this.state.isDragging) return;
84
+ this.lastPointerPos = currentPos;
85
+ if (this.rafId === null) this.rafId = requestAnimationFrame(() => {
86
+ this.rafId = null;
87
+ if (!this.lastPointerPos) return;
88
+ const pos = this.lastPointerPos;
89
+ const elementUnderCursor = document.elementFromPoint(pos.x, pos.y);
90
+ const droppableElement = this.findElementByKind(elementUnderCursor, this.config.droppableKind);
91
+ if (droppableElement !== this.state.lastHoveredElement) {
92
+ if (this.state.lastHoveredElement) this.state.lastHoveredElement.removeAttribute("data-hovered");
93
+ if (droppableElement) {
94
+ droppableElement.setAttribute("data-hovered", "true");
95
+ this.state.lastHoveredElement = droppableElement;
96
+ } else this.state.lastHoveredElement = null;
97
+ }
98
+ this.handleAutoScroll(pos);
99
+ this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement);
100
+ });
101
+ }
102
+ handlePointerUp(e) {
103
+ if (this.state.startPosition && !this.state.isDragging) {
104
+ const currentPos = {
105
+ x: e.clientX,
106
+ y: e.clientY
107
+ };
108
+ if (this.getDistance(this.state.startPosition, currentPos) < this.config.clickThreshold && this.state.sourceElement && this.state.sourcePosition) this.callbacks.onClick?.(this.state.sourceElement, this.state.sourcePosition);
109
+ this.cleanup();
110
+ return;
111
+ }
112
+ if (!this.state.isDragging || !this.state.sourcePosition || !this.state.sourceItem) {
113
+ this.cleanup();
114
+ return;
115
+ }
116
+ const target = document.elementFromPoint(e.clientX, e.clientY);
117
+ const droppableElement = this.findElementByKind(target, this.config.droppableKind);
118
+ if (droppableElement) {
119
+ const targetPosition = this.callbacks.getItemPosition(droppableElement, this.config.droppableKind);
120
+ if (targetPosition) this.callbacks.onDrop?.(this.state.sourcePosition, targetPosition, this.state.sourceItem);
121
+ }
122
+ this.cleanup();
123
+ }
124
+ handlePointerCancel() {
125
+ this.cleanup();
126
+ }
127
+ handleAutoScroll(pos) {
128
+ const { scrollThreshold, scrollSpeed } = this.config;
129
+ const viewportWidth = window.innerWidth;
130
+ const viewportHeight = window.innerHeight;
131
+ let scrollX = 0;
132
+ let scrollY = 0;
133
+ if (pos.x < scrollThreshold) scrollX = -scrollSpeed;
134
+ else if (pos.x > viewportWidth - scrollThreshold) scrollX = scrollSpeed;
135
+ if (pos.y < scrollThreshold) scrollY = -scrollSpeed;
136
+ else if (pos.y > viewportHeight - scrollThreshold) scrollY = scrollSpeed;
137
+ if (scrollX !== 0 || scrollY !== 0) window.scrollBy(scrollX, scrollY);
138
+ }
139
+ cleanup() {
140
+ if (this.state.sourceElement) this.state.sourceElement.removeAttribute("data-dragging");
141
+ if (this.state.lastHoveredElement) this.state.lastHoveredElement.removeAttribute("data-hovered");
142
+ if (this.rafId !== null) {
143
+ cancelAnimationFrame(this.rafId);
144
+ this.rafId = null;
145
+ }
146
+ if (this.state.isDragging) this.callbacks.onDragEnd?.();
147
+ this.state = {
148
+ startPosition: null,
149
+ sourceElement: null,
150
+ sourcePosition: null,
151
+ sourceItem: null,
152
+ isDragging: false,
153
+ lastHoveredElement: null
154
+ };
155
+ this.lastPointerPos = null;
156
+ }
157
+ /**
158
+ * Find closest ancestor element with matching data-kind attribute
159
+ */
160
+ findElementByKind(element, kind) {
161
+ let current = element;
162
+ while (current && current !== this.container) {
163
+ if (current.dataset.kind === kind) return current;
164
+ current = current.parentElement;
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Calculate distance between two points
170
+ */
171
+ getDistance(p1, p2) {
172
+ return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
173
+ }
174
+ /**
175
+ * Get current drag state (read-only)
176
+ */
177
+ getState() {
178
+ return { ...this.state };
179
+ }
180
+ /**
181
+ * Check if currently dragging
182
+ */
183
+ isDragging() {
184
+ return this.state.isDragging;
185
+ }
186
+ /**
187
+ * Cleanup and remove event listeners
188
+ */
189
+ destroy() {
190
+ this.cleanup();
191
+ this.container.removeEventListener("pointerdown", this.boundHandlers.pointerDown);
192
+ this.container.removeEventListener("pointermove", this.boundHandlers.pointerMove);
193
+ this.container.removeEventListener("pointerup", this.boundHandlers.pointerUp);
194
+ this.container.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
195
+ }
196
+ };
197
+
198
+ //#endregion
199
+ export { DragDropManager };
200
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/drag-drop-manager.ts"],"sourcesContent":["/**\n * Framework-agnostic Drag and Drop Manager\n *\n * A data-driven drag-and-drop utility that works with React and vanilla JS.\n * Uses data attributes and direct DOM manipulation for optimal performance.\n *\n * @template TItem - The type of data associated with draggable items\n * @template TPosition - The type representing position/coordinates (e.g., {x, y}, {box, cell}, etc.)\n */\n\nexport type DragDropConfig = {\n /**\n * Minimum distance in pixels the pointer must move before a drag operation starts.\n * Prevents accidental drags from small movements and allows normal scrolling on mobile.\n * Checked during `pointermove` before drag has started.\n * @default 10\n * @example\n * dragThreshold: 15 // Harder to start drag (better for mobile scroll)\n */\n dragThreshold?: number\n\n /**\n * Maximum distance in pixels between pointerdown and pointerup to treat as a click/tap.\n * Used to distinguish intentional clicks from cancelled drag attempts.\n * Only checked on `pointerup` if drag never started.\n * @default 10\n * @example\n * clickThreshold: 5 // Stricter click detection\n * clickThreshold: 15 // More forgiving for mobile taps\n */\n clickThreshold?: number\n\n /**\n * Distance in pixels from viewport edge that triggers auto-scrolling during drag.\n * When dragging near screen edges, the window automatically scrolls.\n * @default 100\n * @example\n * scrollThreshold: 50 // Start scrolling closer to edges\n * scrollThreshold: 150 // Larger safe zone before scrolling\n */\n scrollThreshold?: number\n\n /**\n * Scroll speed in pixels per animation frame during auto-scroll.\n * Higher values = faster scrolling when dragging near viewport edges.\n * @default 10\n * @example\n * scrollSpeed: 5 // Slower, smoother scroll\n * scrollSpeed: 20 // Faster scroll\n */\n scrollSpeed?: number\n\n /**\n * The `data-kind` attribute value for draggable elements.\n * Elements with this attribute can be picked up and dragged.\n * @example\n * draggableKind: 'cell' // <div data-kind=\"cell\">\n * draggableKind: 'card' // <div data-kind=\"card\">\n * draggableKind: 'list-item' // <div data-kind=\"list-item\">\n */\n draggableKind: string\n\n /**\n * The `data-kind` attribute value for droppable target elements.\n * Elements with this attribute can receive dropped items.\n * Often the same as `draggableKind` for reorderable lists.\n * @example\n * droppableKind: 'cell' // Can drop on cells\n * droppableKind: 'zone' // Can drop on zones\n */\n droppableKind: string\n}\n\nexport type PointerPosition = {\n x: number\n y: number\n}\n\nexport type DragDropCallbacks<TItem, TPosition> = {\n /** Called when determining if an element can be dragged */\n canDrag?: (element: HTMLElement, position: TPosition) => boolean\n /** Called when drag starts (after threshold is met) */\n onDragStart?: (element: HTMLElement, position: TPosition, item: TItem) => void\n /** Called continuously during drag */\n onDragMove?: (pos: PointerPosition, hoveredElement: HTMLElement | null) => void\n /** Called when item is dropped on a valid target */\n onDrop?: (sourcePosition: TPosition, targetPosition: TPosition, sourceItem: TItem) => void\n /** Called when drag ends (regardless of success) */\n onDragEnd?: () => void\n /** Called on simple click/tap (no drag) */\n onClick?: (element: HTMLElement, position: TPosition) => void\n /** Called to extract item data from an element */\n getItemData?: (element: HTMLElement, position: TPosition) => TItem | null\n /** Called to get the custom position/coordinates of an element */\n getItemPosition: (element: HTMLElement, kind: string) => TPosition | null\n}\n\ntype DragState<TItem, TPosition> = {\n startPosition: PointerPosition | null\n sourceElement: HTMLElement | null\n sourcePosition: TPosition | null\n sourceItem: TItem | null\n isDragging: boolean\n lastHoveredElement: HTMLElement | null\n}\n\nexport class DragDropManager<TItem = unknown, TPosition = unknown> {\n private container: HTMLElement\n private config: Required<DragDropConfig>\n private callbacks: DragDropCallbacks<TItem, TPosition>\n private state: DragState<TItem, TPosition> = {\n startPosition: null,\n sourceElement: null,\n sourcePosition: null,\n sourceItem: null,\n isDragging: false,\n lastHoveredElement: null,\n }\n private rafId: number | null = null\n private lastPointerPos: PointerPosition | null = null\n private boundHandlers: {\n pointerDown: (e: PointerEvent) => void\n pointerMove: (e: PointerEvent) => void\n pointerUp: (e: PointerEvent) => void\n pointerCancel: (e: PointerEvent) => void\n }\n\n constructor(\n containerRef: { current: HTMLElement | null } | string | HTMLElement,\n config: DragDropConfig,\n callbacks: DragDropCallbacks<TItem, TPosition>,\n ) {\n // Resolve container element\n if (typeof containerRef === 'string') {\n const element = document.querySelector<HTMLElement>(containerRef)\n if (!element) {\n throw new Error(`DragDropManager: Could not find element with selector \"${containerRef}\"`)\n }\n this.container = element\n } else if (typeof containerRef === 'object' && 'current' in containerRef) {\n if (!containerRef.current) {\n throw new Error('DragDropManager: Ref.current is null')\n }\n this.container = containerRef.current\n } else {\n this.container = containerRef as HTMLElement\n }\n\n this.config = {\n dragThreshold: config.dragThreshold ?? 10,\n clickThreshold: config.clickThreshold ?? 10,\n scrollThreshold: config.scrollThreshold ?? 100,\n scrollSpeed: config.scrollSpeed ?? 10,\n draggableKind: config.draggableKind,\n droppableKind: config.droppableKind,\n }\n\n this.callbacks = callbacks\n\n // Bind handlers to preserve context\n this.boundHandlers = {\n pointerDown: this.handlePointerDown.bind(this),\n pointerMove: this.handlePointerMove.bind(this),\n pointerUp: this.handlePointerUp.bind(this),\n pointerCancel: this.handlePointerCancel.bind(this),\n }\n\n this.attachListeners()\n }\n\n private attachListeners(): void {\n this.container.addEventListener('pointerdown', this.boundHandlers.pointerDown)\n this.container.addEventListener('pointermove', this.boundHandlers.pointerMove)\n this.container.addEventListener('pointerup', this.boundHandlers.pointerUp)\n this.container.addEventListener('pointercancel', this.boundHandlers.pointerCancel)\n }\n\n private handlePointerDown(e: PointerEvent): void {\n const target = e.target as HTMLElement\n const draggableElement = this.findElementByKind(target, this.config.draggableKind)\n\n if (!draggableElement) return\n\n const position = this.callbacks.getItemPosition(draggableElement, this.config.draggableKind)\n if (!position) return\n\n // Check if element is empty (convention: data-empty=\"any-value\")\n const isEmpty = draggableElement.dataset.empty !== undefined\n if (isEmpty) return\n\n // Check canDrag callback\n if (this.callbacks.canDrag && !this.callbacks.canDrag(draggableElement, position)) {\n return\n }\n\n // Get item data\n const item = this.callbacks.getItemData?.(draggableElement, position) ?? null\n if (!item) return\n\n // Prevent text selection during potential drag\n e.preventDefault()\n\n // Store start position and element info\n this.state.startPosition = { x: e.clientX, y: e.clientY }\n this.state.sourceElement = draggableElement\n this.state.sourcePosition = position\n this.state.sourceItem = item\n }\n\n private handlePointerMove(e: PointerEvent): void {\n const currentPos: PointerPosition = { x: e.clientX, y: e.clientY }\n\n // Check if we should start dragging (threshold not yet met)\n if (this.state.startPosition && !this.state.isDragging) {\n const distance = this.getDistance(this.state.startPosition, currentPos)\n\n if (distance >= this.config.dragThreshold) {\n // Start drag\n this.state.isDragging = true\n\n if (this.state.sourceElement && this.state.sourcePosition && this.state.sourceItem) {\n // Set data-dragging attribute on source\n this.state.sourceElement.setAttribute('data-dragging', 'true')\n\n // Notify drag start\n this.callbacks.onDragStart?.(\n this.state.sourceElement,\n this.state.sourcePosition,\n this.state.sourceItem,\n )\n }\n }\n return\n }\n\n if (!this.state.isDragging) return\n\n // Store latest position for RAF\n this.lastPointerPos = currentPos\n\n // Throttle updates to 60fps using RAF\n if (this.rafId === null) {\n this.rafId = requestAnimationFrame(() => {\n this.rafId = null\n if (!this.lastPointerPos) return\n\n const pos = this.lastPointerPos\n\n // Find hovered droppable element\n const elementUnderCursor = document.elementFromPoint(pos.x, pos.y) as HTMLElement\n const droppableElement = this.findElementByKind(\n elementUnderCursor,\n this.config.droppableKind,\n )\n\n // Update hover feedback\n if (droppableElement !== this.state.lastHoveredElement) {\n // Remove hover from previous element\n if (this.state.lastHoveredElement) {\n this.state.lastHoveredElement.removeAttribute('data-hovered')\n }\n\n // Add hover to current element (including the source element).\n // This enables \"drag over itself\" states (e.g. combined data-dragging + data-hovered styling).\n if (droppableElement) {\n droppableElement.setAttribute('data-hovered', 'true')\n this.state.lastHoveredElement = droppableElement\n } else {\n this.state.lastHoveredElement = null\n }\n }\n\n // Auto-scroll\n this.handleAutoScroll(pos)\n\n // Notify drag move\n this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement)\n })\n }\n }\n\n private handlePointerUp(e: PointerEvent): void {\n // Handle click/tap if we never started dragging\n if (this.state.startPosition && !this.state.isDragging) {\n const currentPos = { x: e.clientX, y: e.clientY }\n const distance = this.getDistance(this.state.startPosition, currentPos)\n\n // If distance is very small, treat as click\n if (\n distance < this.config.clickThreshold &&\n this.state.sourceElement &&\n this.state.sourcePosition\n ) {\n this.callbacks.onClick?.(this.state.sourceElement, this.state.sourcePosition)\n }\n\n this.cleanup()\n return\n }\n\n if (!this.state.isDragging || !this.state.sourcePosition || !this.state.sourceItem) {\n this.cleanup()\n return\n }\n\n // Find drop target\n const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement\n const droppableElement = this.findElementByKind(target, this.config.droppableKind)\n\n if (droppableElement) {\n const targetPosition = this.callbacks.getItemPosition(\n droppableElement,\n this.config.droppableKind,\n )\n\n if (targetPosition) {\n // Notify drop\n this.callbacks.onDrop?.(this.state.sourcePosition, targetPosition, this.state.sourceItem)\n }\n }\n\n this.cleanup()\n }\n\n private handlePointerCancel(): void {\n this.cleanup()\n }\n\n private handleAutoScroll(pos: PointerPosition): void {\n const { scrollThreshold, scrollSpeed } = this.config\n const viewportWidth = window.innerWidth\n const viewportHeight = window.innerHeight\n\n let scrollX = 0\n let scrollY = 0\n\n // Check horizontal scroll\n if (pos.x < scrollThreshold) {\n scrollX = -scrollSpeed\n } else if (pos.x > viewportWidth - scrollThreshold) {\n scrollX = scrollSpeed\n }\n\n // Check vertical scroll\n if (pos.y < scrollThreshold) {\n scrollY = -scrollSpeed\n } else if (pos.y > viewportHeight - scrollThreshold) {\n scrollY = scrollSpeed\n }\n\n if (scrollX !== 0 || scrollY !== 0) {\n window.scrollBy(scrollX, scrollY)\n }\n }\n\n private cleanup(): void {\n // Remove data-dragging attribute\n if (this.state.sourceElement) {\n this.state.sourceElement.removeAttribute('data-dragging')\n }\n\n // Remove hover attribute\n if (this.state.lastHoveredElement) {\n this.state.lastHoveredElement.removeAttribute('data-hovered')\n }\n\n // Cancel RAF\n if (this.rafId !== null) {\n cancelAnimationFrame(this.rafId)\n this.rafId = null\n }\n\n // Notify drag end\n if (this.state.isDragging) {\n this.callbacks.onDragEnd?.()\n }\n\n // Reset state\n this.state = {\n startPosition: null,\n sourceElement: null,\n sourcePosition: null,\n sourceItem: null,\n isDragging: false,\n lastHoveredElement: null,\n }\n this.lastPointerPos = null\n }\n\n /**\n * Find closest ancestor element with matching data-kind attribute\n */\n private findElementByKind(element: HTMLElement | null, kind: string): HTMLElement | null {\n let current = element\n while (current && current !== this.container) {\n if (current.dataset.kind === kind) {\n return current\n }\n current = current.parentElement\n }\n return null\n }\n\n /**\n * Calculate distance between two points\n */\n private getDistance(p1: PointerPosition, p2: PointerPosition): number {\n return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))\n }\n\n /**\n * Get current drag state (read-only)\n */\n public getState(): Readonly<DragState<TItem, TPosition>> {\n return { ...this.state }\n }\n\n /**\n * Check if currently dragging\n */\n public isDragging(): boolean {\n return this.state.isDragging\n }\n\n /**\n * Cleanup and remove event listeners\n */\n public destroy(): void {\n this.cleanup()\n this.container.removeEventListener('pointerdown', this.boundHandlers.pointerDown)\n this.container.removeEventListener('pointermove', this.boundHandlers.pointerMove)\n this.container.removeEventListener('pointerup', this.boundHandlers.pointerUp)\n this.container.removeEventListener('pointercancel', this.boundHandlers.pointerCancel)\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "dnd-manager",
3
+ "version": "1.0.0",
4
+ "homepage": "https://itsjavi.com/dnd-manager",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/itsjavi/dnd-manager.git"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.mjs"
15
+ }
16
+ },
17
+ "main": "./dist/index.mjs",
18
+ "module": "./dist/index.mjs",
19
+ "types": "./dist/index.d.mts",
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsdown src/index.ts --dts --sourcemap --format esm --clean",
27
+ "format": "pnpx sort-package-json && pnpm exec prettier --cache --write .",
28
+ "prepublishOnly": "pnpm typecheck && pnpm build && publint",
29
+ "test": "vitest --run",
30
+ "test:watch": "vitest --watch",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "devDependencies": {
34
+ "prettier": "^3.8.1",
35
+ "publint": "^0.3.17",
36
+ "sort-package-json": "^3.6.1",
37
+ "tsdown": "^0.20.3",
38
+ "typescript": "^5.9.3",
39
+ "vitest": "^4.0.18"
40
+ }
41
+ }