dnd-manager 1.1.0 → 1.3.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/CHANGELOG.md +25 -0
- package/LICENSE +0 -0
- package/README.md +286 -761
- package/dist/index.d.mts +162 -20
- package/dist/index.mjs +480 -75
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -15
package/README.md
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
# dnd-manager
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Lightweight, data-driven drag-and-drop for vanilla JS and any framework. Full control over UX
|
|
4
|
+
without opinionated component patterns.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
**Live demo:** [itsjavi.com/dnd-manager](https://itsjavi.com/dnd-manager) · **Source:**
|
|
7
|
+
[demo/](demo/) in this repo · **LLM-optimized docs:** [llms.md](llms.md)
|
|
8
|
+
|
|
9
|
+
---
|
|
8
10
|
|
|
9
11
|
## Table of Contents
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Quick start](#quick-start)
|
|
15
|
+
- [Clone preview helper](#clone-preview-helper)
|
|
16
|
+
- [Vanilla JS](#vanilla-js)
|
|
17
|
+
- [React](#react)
|
|
18
|
+
- [Multiple managers](#multiple-managers)
|
|
19
|
+
- [Dynamic drag ability](#dynamic-drag-ability)
|
|
20
|
+
- [Keyboard](#keyboard)
|
|
21
|
+
- [API overview](#api-overview)
|
|
15
22
|
|
|
16
23
|
---
|
|
17
24
|
|
|
@@ -19,832 +26,350 @@ React applications.
|
|
|
19
26
|
|
|
20
27
|
```bash
|
|
21
28
|
pnpm add dnd-manager
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Or with npm/yarn:
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
npm install dnd-manager
|
|
28
|
-
yarn add dnd-manager
|
|
29
|
+
# or: npm install dnd-manager
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
---
|
|
32
33
|
|
|
33
|
-
##
|
|
34
|
+
## Quick start
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
1. Mark draggable/droppable elements with `data-kind` (e.g. `data-kind="cell"`).
|
|
37
|
+
2. Add position data (e.g. `data-row`, `data-col` for a grid).
|
|
38
|
+
3. Create a `DragDropManager` with callbacks that read position/data and handle drag/drop.
|
|
37
39
|
|
|
38
40
|
```typescript
|
|
39
|
-
import {
|
|
40
|
-
DragPreviewController,
|
|
41
|
-
DragDropManager,
|
|
42
|
-
type DragDropCallbacks,
|
|
43
|
-
type PointerPosition,
|
|
44
|
-
} from 'dnd-manager'
|
|
45
|
-
|
|
46
|
-
type Item = { index: number }
|
|
47
|
-
type Position = { index: number }
|
|
48
|
-
|
|
49
|
-
const preview = new DragPreviewController({
|
|
50
|
-
zIndex: 9999,
|
|
51
|
-
opacity: 0.95,
|
|
52
|
-
className: 'drop-shadow-xl',
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
const callbacks: DragDropCallbacks<Item, Position> = {
|
|
56
|
-
getItemPosition: (element) => {
|
|
57
|
-
const raw = element.dataset.index
|
|
58
|
-
if (!raw) return null
|
|
59
|
-
const index = Number.parseInt(raw, 10)
|
|
60
|
-
return Number.isFinite(index) ? { index } : null
|
|
61
|
-
},
|
|
41
|
+
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
|
|
62
42
|
|
|
63
|
-
|
|
43
|
+
type Item = { id: string; name: string }
|
|
44
|
+
type Position = { row: number; col: number }
|
|
64
45
|
|
|
65
|
-
|
|
66
|
-
|
|
46
|
+
const callbacks: DragDropCallbacks<Item, Position> = {
|
|
47
|
+
getItemPosition: (el) => {
|
|
48
|
+
const row = el.dataset.row,
|
|
49
|
+
col = el.dataset.col
|
|
50
|
+
return row != null && col != null ? { row: +row, col: +col } : null
|
|
67
51
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
52
|
+
getItemData: (el, pos) => ({ id: el.dataset.itemId ?? '', name: el.dataset.name ?? '' }),
|
|
53
|
+
onDrop: (from, to, item) => {
|
|
54
|
+
/* swap/update DOM or state */
|
|
71
55
|
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
preview.stop()
|
|
56
|
+
onDragEnd: (result) => {
|
|
57
|
+
/* result or null; cleanup preview etc. */
|
|
75
58
|
},
|
|
76
59
|
}
|
|
77
60
|
|
|
78
61
|
const manager = new DragDropManager<Item, Position>(
|
|
79
|
-
'#container',
|
|
80
|
-
{
|
|
81
|
-
draggableKind: 'ITEM',
|
|
82
|
-
droppableKind: 'ITEM',
|
|
83
|
-
},
|
|
62
|
+
document.querySelector('#container'),
|
|
63
|
+
{ draggableKind: 'cell', droppableKind: 'cell' },
|
|
84
64
|
callbacks,
|
|
85
65
|
)
|
|
86
66
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
preview.destroy()
|
|
90
|
-
manager.destroy()
|
|
91
|
-
}
|
|
67
|
+
// On teardown:
|
|
68
|
+
manager.destroy()
|
|
92
69
|
```
|
|
93
70
|
|
|
94
|
-
|
|
95
|
-
|
|
71
|
+
Elements with `data-empty` are not draggable. Use `onDragStart` / `onDragMove` to show a preview;
|
|
72
|
+
use `onDragEnd(result)` for cleanup and optional reordering (see below).
|
|
96
73
|
|
|
97
74
|
---
|
|
98
75
|
|
|
99
|
-
##
|
|
76
|
+
## Clone preview helper
|
|
100
77
|
|
|
101
|
-
|
|
102
|
-
|
|
78
|
+
`DragPreviewController` gives a clone of the dragged element that follows the cursor
|
|
79
|
+
(`position: fixed`, `pointer-events: none`). Reduces drift in scrolled containers.
|
|
103
80
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
112
|
-
<title>Vanilla JS DragDrop Grid</title>
|
|
113
|
-
<style>
|
|
114
|
-
body {
|
|
115
|
-
margin: 0;
|
|
116
|
-
padding: 20px;
|
|
117
|
-
font-family:
|
|
118
|
-
system-ui,
|
|
119
|
-
-apple-system,
|
|
120
|
-
sans-serif;
|
|
121
|
-
background: #f9fafb;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
.grid {
|
|
125
|
-
display: grid;
|
|
126
|
-
grid-template-columns: repeat(3, 120px);
|
|
127
|
-
gap: 8px;
|
|
128
|
-
padding: 20px;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
.cell {
|
|
132
|
-
display: flex;
|
|
133
|
-
align-items: center;
|
|
134
|
-
justify-content: center;
|
|
135
|
-
font-weight: 600;
|
|
136
|
-
border-radius: 8px;
|
|
137
|
-
padding: 20px;
|
|
138
|
-
min-height: 80px;
|
|
139
|
-
transition:
|
|
140
|
-
transform 0.2s,
|
|
141
|
-
opacity 0.2s,
|
|
142
|
-
box-shadow 0.2s;
|
|
143
|
-
user-select: none;
|
|
144
|
-
touch-action: none;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
.cell:not([data-empty]) {
|
|
148
|
-
color: white;
|
|
149
|
-
cursor: grab;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
.cell[data-empty] {
|
|
153
|
-
background: #f3f4f6;
|
|
154
|
-
border: 2px dashed #d1d5db;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/* Dragging states */
|
|
158
|
-
.cell[data-dragging='true'] {
|
|
159
|
-
opacity: 0.4;
|
|
160
|
-
cursor: grabbing !important;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
.cell[data-hovered='true'] {
|
|
164
|
-
transform: scale(1.05);
|
|
165
|
-
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/* Drag preview */
|
|
169
|
-
#drag-preview {
|
|
170
|
-
position: fixed;
|
|
171
|
-
pointer-events: none;
|
|
172
|
-
z-index: 9999;
|
|
173
|
-
opacity: 0;
|
|
174
|
-
transform: translate(-50%, -50%);
|
|
175
|
-
transition: opacity 0.2s;
|
|
176
|
-
padding: 20px;
|
|
177
|
-
border-radius: 8px;
|
|
178
|
-
color: white;
|
|
179
|
-
font-weight: 600;
|
|
180
|
-
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
|
181
|
-
}
|
|
182
|
-
</style>
|
|
183
|
-
</head>
|
|
184
|
-
<body>
|
|
185
|
-
<h1>Vanilla JS Drag & Drop Grid</h1>
|
|
186
|
-
<p>Drag cells to reorder, click to log to console</p>
|
|
187
|
-
|
|
188
|
-
<div id="grid-container" class="grid">
|
|
189
|
-
<!-- Row 0 -->
|
|
190
|
-
<div
|
|
191
|
-
class="cell"
|
|
192
|
-
data-kind="cell"
|
|
193
|
-
data-row="0"
|
|
194
|
-
data-col="0"
|
|
195
|
-
data-item-id="1"
|
|
196
|
-
data-name="Item 1"
|
|
197
|
-
style="background: #ef4444"
|
|
198
|
-
>
|
|
199
|
-
Item 1
|
|
200
|
-
</div>
|
|
201
|
-
<div
|
|
202
|
-
class="cell"
|
|
203
|
-
data-kind="cell"
|
|
204
|
-
data-row="0"
|
|
205
|
-
data-col="1"
|
|
206
|
-
data-item-id="2"
|
|
207
|
-
data-name="Item 2"
|
|
208
|
-
style="background: #3b82f6"
|
|
209
|
-
>
|
|
210
|
-
Item 2
|
|
211
|
-
</div>
|
|
212
|
-
<div
|
|
213
|
-
class="cell"
|
|
214
|
-
data-kind="cell"
|
|
215
|
-
data-row="0"
|
|
216
|
-
data-col="2"
|
|
217
|
-
data-item-id="3"
|
|
218
|
-
data-name="Item 3"
|
|
219
|
-
style="background: #10b981"
|
|
220
|
-
>
|
|
221
|
-
Item 3
|
|
222
|
-
</div>
|
|
223
|
-
|
|
224
|
-
<!-- Row 1 -->
|
|
225
|
-
<div
|
|
226
|
-
class="cell"
|
|
227
|
-
data-kind="cell"
|
|
228
|
-
data-row="1"
|
|
229
|
-
data-col="0"
|
|
230
|
-
data-item-id="4"
|
|
231
|
-
data-name="Item 4"
|
|
232
|
-
style="background: #f59e0b"
|
|
233
|
-
>
|
|
234
|
-
Item 4
|
|
235
|
-
</div>
|
|
236
|
-
<div class="cell" data-kind="cell" data-row="1" data-col="1" data-empty></div>
|
|
237
|
-
<div
|
|
238
|
-
class="cell"
|
|
239
|
-
data-kind="cell"
|
|
240
|
-
data-row="1"
|
|
241
|
-
data-col="2"
|
|
242
|
-
data-item-id="5"
|
|
243
|
-
data-name="Item 5"
|
|
244
|
-
style="background: #8b5cf6"
|
|
245
|
-
>
|
|
246
|
-
Item 5
|
|
247
|
-
</div>
|
|
248
|
-
|
|
249
|
-
<!-- Row 2 -->
|
|
250
|
-
<div
|
|
251
|
-
class="cell"
|
|
252
|
-
data-kind="cell"
|
|
253
|
-
data-row="2"
|
|
254
|
-
data-col="0"
|
|
255
|
-
data-item-id="6"
|
|
256
|
-
data-name="Item 6"
|
|
257
|
-
style="background: #ec4899"
|
|
258
|
-
>
|
|
259
|
-
Item 6
|
|
260
|
-
</div>
|
|
261
|
-
<div
|
|
262
|
-
class="cell"
|
|
263
|
-
data-kind="cell"
|
|
264
|
-
data-row="2"
|
|
265
|
-
data-col="1"
|
|
266
|
-
data-item-id="7"
|
|
267
|
-
data-name="Item 7"
|
|
268
|
-
style="background: #14b8a6"
|
|
269
|
-
>
|
|
270
|
-
Item 7
|
|
271
|
-
</div>
|
|
272
|
-
<div class="cell" data-kind="cell" data-row="2" data-col="2" data-empty></div>
|
|
273
|
-
</div>
|
|
274
|
-
|
|
275
|
-
<!-- Drag preview element -->
|
|
276
|
-
<div id="drag-preview"></div>
|
|
277
|
-
|
|
278
|
-
<script type="module" src="./app.ts"></script>
|
|
279
|
-
</body>
|
|
280
|
-
</html>
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
**TypeScript (app.ts):**
|
|
81
|
+
The clone is a picture of the source: it drops `id`, `name`, `data-kind` and the drag attributes
|
|
82
|
+
(`data-dragging`, `data-hovered`, `data-drop-invalid`), so placeholder styles such as
|
|
83
|
+
`[data-dragging] { opacity: .4 }` don't hit it, and it is `inert`. Style it with
|
|
84
|
+
`[data-drag-preview]`. Appended to `document.body`, it loses descendant selectors (`.box .slot`) and
|
|
85
|
+
custom properties inherited from the source's ancestors; copy what it needs with
|
|
86
|
+
`copyComputedStyles` (e.g. `['--slot-bg']`), or render your own preview from `onDragStart` /
|
|
87
|
+
`onDragMove` / `onDragEnd`.
|
|
284
88
|
|
|
285
89
|
```typescript
|
|
286
|
-
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
|
|
90
|
+
import { DragPreviewController, DragDropManager, type DragDropCallbacks } from 'dnd-manager'
|
|
287
91
|
|
|
288
|
-
|
|
289
|
-
type GridItem = {
|
|
290
|
-
id: string
|
|
291
|
-
name: string
|
|
292
|
-
color: string
|
|
293
|
-
}
|
|
92
|
+
const preview = new DragPreviewController({ zIndex: 9999, opacity: 0.95 })
|
|
294
93
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
94
|
+
const callbacks: DragDropCallbacks<Item, Position> = {
|
|
95
|
+
getItemPosition: (el) => ({ index: Number(el.dataset.index) }),
|
|
96
|
+
getItemData: (el, pos) => ({ index: pos.index }),
|
|
97
|
+
onDragStart: (element) => preview.startFromElement(element),
|
|
98
|
+
onDragMove: (pos) => preview.moveToPointer(pos),
|
|
99
|
+
onDragEnd: () => preview.stop(),
|
|
298
100
|
}
|
|
299
101
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (!container || !preview) return
|
|
306
|
-
|
|
307
|
-
// Setup DragDropManager callbacks
|
|
308
|
-
const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
|
|
309
|
-
getItemPosition: (element, kind) => {
|
|
310
|
-
const row = element.dataset.row
|
|
311
|
-
const col = element.dataset.col
|
|
312
|
-
if (row === undefined || col === undefined) return null
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
row: parseInt(row, 10),
|
|
316
|
-
col: parseInt(col, 10),
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
getItemData: (element, position) => {
|
|
321
|
-
const itemId = element.dataset.itemId
|
|
322
|
-
const name = element.dataset.name
|
|
323
|
-
const color = element.style.background
|
|
324
|
-
|
|
325
|
-
if (!itemId || !name || !color) return null
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
id: itemId,
|
|
329
|
-
name: name,
|
|
330
|
-
color: color,
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
|
|
334
|
-
onDragStart: (element, position, item) => {
|
|
335
|
-
console.log('🚀 Drag started:', item)
|
|
336
|
-
|
|
337
|
-
// Copy element dimensions and styles to preview
|
|
338
|
-
const rect = element.getBoundingClientRect()
|
|
339
|
-
preview.textContent = item.name
|
|
340
|
-
preview.style.background = item.color
|
|
341
|
-
preview.style.width = `${rect.width}px`
|
|
342
|
-
preview.style.height = `${rect.height}px`
|
|
343
|
-
preview.style.opacity = '0.9'
|
|
344
|
-
},
|
|
345
|
-
|
|
346
|
-
onDragMove: (pos, hoveredElement) => {
|
|
347
|
-
// Update preview position
|
|
348
|
-
preview.style.left = `${pos.x}px`
|
|
349
|
-
preview.style.top = `${pos.y}px`
|
|
350
|
-
},
|
|
351
|
-
|
|
352
|
-
onDrop: (sourcePos, targetPos, sourceItem) => {
|
|
353
|
-
console.log('📦 Dropped:', {
|
|
354
|
-
from: sourcePos,
|
|
355
|
-
to: targetPos,
|
|
356
|
-
item: sourceItem,
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
// Get source and target elements
|
|
360
|
-
const sourceElement = container.querySelector(
|
|
361
|
-
`[data-row="${sourcePos.row}"][data-col="${sourcePos.col}"]`,
|
|
362
|
-
) as HTMLElement
|
|
363
|
-
const targetElement = container.querySelector(
|
|
364
|
-
`[data-row="${targetPos.row}"][data-col="${targetPos.col}"]`,
|
|
365
|
-
) as HTMLElement
|
|
366
|
-
|
|
367
|
-
if (!sourceElement || !targetElement) return
|
|
368
|
-
|
|
369
|
-
// Swap element contents and data attributes
|
|
370
|
-
const tempContent = sourceElement.innerHTML
|
|
371
|
-
const tempId = sourceElement.dataset.itemId
|
|
372
|
-
const tempName = sourceElement.dataset.name
|
|
373
|
-
const tempColor = sourceElement.style.background
|
|
374
|
-
const tempEmpty = sourceElement.dataset.empty
|
|
375
|
-
|
|
376
|
-
// Update source with target data
|
|
377
|
-
sourceElement.innerHTML = targetElement.innerHTML
|
|
378
|
-
sourceElement.dataset.itemId = targetElement.dataset.itemId || ''
|
|
379
|
-
sourceElement.dataset.name = targetElement.dataset.name || ''
|
|
380
|
-
sourceElement.style.background = targetElement.style.background
|
|
381
|
-
if (targetElement.dataset.empty !== undefined) {
|
|
382
|
-
sourceElement.dataset.empty = ''
|
|
383
|
-
} else {
|
|
384
|
-
delete sourceElement.dataset.empty
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Update target with source data
|
|
388
|
-
targetElement.innerHTML = tempContent
|
|
389
|
-
targetElement.dataset.itemId = tempId || ''
|
|
390
|
-
targetElement.dataset.name = tempName || ''
|
|
391
|
-
targetElement.style.background = tempColor
|
|
392
|
-
if (tempEmpty !== undefined) {
|
|
393
|
-
targetElement.dataset.empty = ''
|
|
394
|
-
} else {
|
|
395
|
-
delete targetElement.dataset.empty
|
|
396
|
-
}
|
|
397
|
-
},
|
|
398
|
-
|
|
399
|
-
onDragEnd: () => {
|
|
400
|
-
// Hide preview
|
|
401
|
-
preview.style.opacity = '0'
|
|
402
|
-
},
|
|
403
|
-
|
|
404
|
-
onClick: (element, position) => {
|
|
405
|
-
const itemId = element.dataset.itemId
|
|
406
|
-
const name = element.dataset.name
|
|
407
|
-
const color = element.style.background
|
|
408
|
-
|
|
409
|
-
if (itemId && name) {
|
|
410
|
-
console.log('👆 Clicked:', { id: itemId, name, color })
|
|
411
|
-
}
|
|
412
|
-
},
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Create DragDropManager instance
|
|
416
|
-
const dragDropManager = new DragDropManager<GridItem, GridPosition>(
|
|
417
|
-
container,
|
|
418
|
-
{
|
|
419
|
-
draggableKind: 'cell',
|
|
420
|
-
droppableKind: 'cell',
|
|
421
|
-
dragThreshold: 10,
|
|
422
|
-
clickThreshold: 10,
|
|
423
|
-
scrollThreshold: 100,
|
|
424
|
-
scrollSpeed: 10,
|
|
425
|
-
cancelOnEscape: true,
|
|
426
|
-
cancelOnPointerLeave: true,
|
|
427
|
-
},
|
|
428
|
-
callbacks,
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
// Cleanup on page unload
|
|
432
|
-
window.addEventListener('beforeunload', () => {
|
|
433
|
-
dragDropManager.destroy()
|
|
434
|
-
})
|
|
435
|
-
}
|
|
102
|
+
const manager = new DragDropManager(
|
|
103
|
+
container,
|
|
104
|
+
{ draggableKind: 'ITEM', droppableKind: 'ITEM' },
|
|
105
|
+
callbacks,
|
|
106
|
+
)
|
|
436
107
|
|
|
437
|
-
//
|
|
438
|
-
|
|
108
|
+
// Cleanup:
|
|
109
|
+
preview.destroy()
|
|
110
|
+
manager.destroy()
|
|
439
111
|
```
|
|
440
112
|
|
|
441
113
|
---
|
|
442
114
|
|
|
443
|
-
##
|
|
115
|
+
## Vanilla JS
|
|
444
116
|
|
|
445
|
-
|
|
117
|
+
Use a container of elements with `data-kind`, `data-row`, `data-col`, and item fields
|
|
118
|
+
(`data-item-id`, `data-name`, etc.). In callbacks:
|
|
446
119
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
120
|
+
- **getItemPosition** — parse position from data attributes.
|
|
121
|
+
- **getItemData** — return item payload (e.g. id, name, color).
|
|
122
|
+
- **onDragStart** — show preview (e.g. copy size/style to a fixed preview element).
|
|
123
|
+
- **onDragMove** — update preview position (`left`/`top` or transform).
|
|
124
|
+
- **onDrop** — swap/update DOM (or state) using source/target positions and `sourceItem`.
|
|
125
|
+
- **onDragEnd(result)** — `result` is `{ sourcePosition, targetPosition, sourceItem }` on valid
|
|
126
|
+
drop, or `null` when cancelled. Use for cleanup (hide preview) and optionally do reordering here
|
|
127
|
+
instead of in `onDrop`.
|
|
128
|
+
- **onClick** — handle click (e.g. log or select).
|
|
450
129
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
id: string
|
|
454
|
-
name: string
|
|
455
|
-
color: string
|
|
456
|
-
}
|
|
130
|
+
Full grid markup and CSS are in the [demo app](demo/) (see `GridDemo` and styles). Minimal markup
|
|
131
|
+
pattern:
|
|
457
132
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
col
|
|
461
|
-
|
|
133
|
+
```html
|
|
134
|
+
<div id="grid-container">
|
|
135
|
+
<div class="cell" data-kind="cell" data-row="0" data-col="0" data-item-id="1" data-name="Item 1">
|
|
136
|
+
Item 1
|
|
137
|
+
</div>
|
|
138
|
+
<!-- ... more cells; use data-empty on empty slots -->
|
|
139
|
+
</div>
|
|
140
|
+
<div id="drag-preview"></div>
|
|
141
|
+
```
|
|
462
142
|
|
|
463
|
-
|
|
464
|
-
const INITIAL_DATA: (GridItem | null)[][] = [
|
|
465
|
-
[
|
|
466
|
-
{ id: '1', name: 'Item 1', color: '#ef4444' },
|
|
467
|
-
{ id: '2', name: 'Item 2', color: '#3b82f6' },
|
|
468
|
-
{ id: '3', name: 'Item 3', color: '#10b981' },
|
|
469
|
-
],
|
|
470
|
-
[
|
|
471
|
-
{ id: '4', name: 'Item 4', color: '#f59e0b' },
|
|
472
|
-
null,
|
|
473
|
-
{ id: '5', name: 'Item 5', color: '#8b5cf6' },
|
|
474
|
-
],
|
|
475
|
-
[
|
|
476
|
-
{ id: '6', name: 'Item 6', color: '#ec4899' },
|
|
477
|
-
{ id: '7', name: 'Item 7', color: '#14b8a6' },
|
|
478
|
-
null,
|
|
479
|
-
],
|
|
480
|
-
]
|
|
481
|
-
|
|
482
|
-
// Cell component
|
|
483
|
-
type CellProps = {
|
|
484
|
-
item: GridItem | null
|
|
485
|
-
row: number
|
|
486
|
-
col: number
|
|
487
|
-
} & React.ComponentProps<'div'>
|
|
488
|
-
|
|
489
|
-
function GridCell({ item, row, col, ...props }: CellProps) {
|
|
490
|
-
if (item) {
|
|
491
|
-
return (
|
|
492
|
-
<div
|
|
493
|
-
data-kind="cell"
|
|
494
|
-
data-row={row}
|
|
495
|
-
data-col={col}
|
|
496
|
-
data-item-id={item.id}
|
|
497
|
-
className="grid-cell grid-cell--filled"
|
|
498
|
-
style={{
|
|
499
|
-
background: item.color,
|
|
500
|
-
}}
|
|
501
|
-
{...props}
|
|
502
|
-
>
|
|
503
|
-
{item.name}
|
|
504
|
-
</div>
|
|
505
|
-
)
|
|
506
|
-
}
|
|
143
|
+
### Reordering in `onDragEnd`
|
|
507
144
|
|
|
508
|
-
|
|
509
|
-
<div
|
|
510
|
-
data-kind="cell"
|
|
511
|
-
data-row={row}
|
|
512
|
-
data-col={col}
|
|
513
|
-
data-empty="true"
|
|
514
|
-
className="grid-cell grid-cell--empty"
|
|
515
|
-
{...props}
|
|
516
|
-
/>
|
|
517
|
-
)
|
|
518
|
-
}
|
|
145
|
+
You can do all post-drag work in `onDragEnd` so cleanup and DOM/state updates stay in one place:
|
|
519
146
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
147
|
+
```typescript
|
|
148
|
+
onDragEnd: (result) => {
|
|
149
|
+
if (result === null) return
|
|
150
|
+
const { sourcePosition, targetPosition, sourceItem } = result
|
|
151
|
+
// Update DOM or state (e.g. swap elements), then cleanup
|
|
152
|
+
swapElements(container, sourcePosition, targetPosition, sourceItem)
|
|
153
|
+
preview.style.opacity = '0'
|
|
526
154
|
}
|
|
155
|
+
```
|
|
527
156
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
left: position.x,
|
|
536
|
-
top: position.y,
|
|
537
|
-
background: item.color,
|
|
538
|
-
width: width ? `${width}px` : undefined,
|
|
539
|
-
height: height ? `${height}px` : undefined,
|
|
540
|
-
}}
|
|
541
|
-
>
|
|
542
|
-
{item.name}
|
|
543
|
-
</div>
|
|
544
|
-
)
|
|
545
|
-
}
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## React
|
|
160
|
+
|
|
161
|
+
Create the manager inside `useEffect` and clean up on unmount. Use state for grid data and preview;
|
|
162
|
+
update state in `onDrop` (or `onDragEnd`). Keep `getItemData` reading from current state so the
|
|
163
|
+
manager always has up-to-date item data.
|
|
546
164
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
|
|
563
|
-
getItemPosition: (element, kind) => {
|
|
564
|
-
const row = element.dataset.row
|
|
565
|
-
const col = element.dataset.col
|
|
566
|
-
if (row === undefined || col === undefined) return null
|
|
567
|
-
|
|
568
|
-
return {
|
|
569
|
-
row: parseInt(row, 10),
|
|
570
|
-
col: parseInt(col, 10),
|
|
571
|
-
}
|
|
572
|
-
},
|
|
573
|
-
|
|
574
|
-
getItemData: (element, position) => {
|
|
575
|
-
return gridData[position.row][position.col]
|
|
576
|
-
},
|
|
577
|
-
|
|
578
|
-
onDragStart: (element, position, item) => {
|
|
579
|
-
console.log('🚀 Drag started:', item)
|
|
580
|
-
|
|
581
|
-
// Store element dimensions for preview
|
|
582
|
-
const rect = element.getBoundingClientRect()
|
|
583
|
-
setDragPreview({
|
|
584
|
-
item,
|
|
585
|
-
position: null,
|
|
586
|
-
width: rect.width,
|
|
587
|
-
height: rect.height,
|
|
588
|
-
})
|
|
589
|
-
},
|
|
590
|
-
|
|
591
|
-
onDragMove: (pos, hoveredElement) => {
|
|
592
|
-
setDragPreview((prev) => ({
|
|
593
|
-
...prev,
|
|
594
|
-
position: pos,
|
|
595
|
-
}))
|
|
596
|
-
},
|
|
597
|
-
|
|
598
|
-
onDrop: (sourcePos, targetPos, sourceItem) => {
|
|
599
|
-
console.log('📦 Dropped:', {
|
|
600
|
-
from: sourcePos,
|
|
601
|
-
to: targetPos,
|
|
602
|
-
item: sourceItem,
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
// Swap items
|
|
606
|
-
setGridData((prevData) => {
|
|
607
|
-
const newData = prevData.map((row) => [...row])
|
|
608
|
-
const targetItem = newData[targetPos.row][targetPos.col]
|
|
609
|
-
newData[targetPos.row][targetPos.col] = sourceItem
|
|
610
|
-
newData[sourcePos.row][sourcePos.col] = targetItem
|
|
611
|
-
return newData
|
|
612
|
-
})
|
|
613
|
-
},
|
|
614
|
-
|
|
615
|
-
onDragEnd: () => {
|
|
616
|
-
setDragPreview({ item: null, position: null, width: undefined, height: undefined })
|
|
617
|
-
},
|
|
618
|
-
|
|
619
|
-
onClick: (element, position) => {
|
|
620
|
-
const item = gridData[position.row][position.col]
|
|
621
|
-
if (item) {
|
|
622
|
-
console.log('👆 Clicked:', item)
|
|
623
|
-
}
|
|
624
|
-
},
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Create manager
|
|
628
|
-
const manager = new DragDropManager<GridItem, GridPosition>(
|
|
629
|
-
containerRef,
|
|
630
|
-
{
|
|
631
|
-
draggableKind: 'cell',
|
|
632
|
-
droppableKind: 'cell',
|
|
633
|
-
dragThreshold: 10,
|
|
634
|
-
clickThreshold: 10,
|
|
635
|
-
scrollThreshold: 100,
|
|
636
|
-
scrollSpeed: 10,
|
|
637
|
-
cancelOnEscape: true,
|
|
638
|
-
cancelOnPointerLeave: true,
|
|
639
|
-
},
|
|
640
|
-
callbacks,
|
|
641
|
-
)
|
|
642
|
-
|
|
643
|
-
// Cleanup
|
|
644
|
-
return () => {
|
|
645
|
-
manager.destroy()
|
|
646
|
-
}
|
|
647
|
-
}, [gridData])
|
|
648
|
-
|
|
649
|
-
return (
|
|
650
|
-
<div className="draggable-grid-container">
|
|
651
|
-
<h1>React Drag & Drop Grid</h1>
|
|
652
|
-
<p>Drag cells to reorder, click to log to console</p>
|
|
653
|
-
|
|
654
|
-
<div ref={containerRef} className="grid">
|
|
655
|
-
{gridData.map((row, rowIndex) =>
|
|
656
|
-
row.map((item, colIndex) => (
|
|
657
|
-
<GridCell key={`${rowIndex}-${colIndex}`} item={item} row={rowIndex} col={colIndex} />
|
|
658
|
-
)),
|
|
659
|
-
)}
|
|
660
|
-
</div>
|
|
661
|
-
|
|
662
|
-
<DragPreview
|
|
663
|
-
item={dragPreview.item}
|
|
664
|
-
position={dragPreview.position}
|
|
665
|
-
width={dragPreview.width}
|
|
666
|
-
height={dragPreview.height}
|
|
667
|
-
/>
|
|
668
|
-
</div>
|
|
165
|
+
```typescript
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!containerRef.current) return
|
|
168
|
+
const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
|
|
169
|
+
getItemPosition: (el) => ({ row: +el.dataset.row!, col: +el.dataset.col! }),
|
|
170
|
+
getItemData: (_, pos) => gridData[pos.row][pos.col],
|
|
171
|
+
onDragStart: (el, _, item) => setDragPreview({ item, rect: el.getBoundingClientRect() }),
|
|
172
|
+
onDragMove: (pos) => setDragPreview((p) => (p ? { ...p, position: pos } : p)),
|
|
173
|
+
onDrop: (from, to, item) => setGridData((prev) => swap(prev, from, to, item)),
|
|
174
|
+
onDragEnd: () => setDragPreview(null),
|
|
175
|
+
}
|
|
176
|
+
const manager = new DragDropManager(
|
|
177
|
+
containerRef,
|
|
178
|
+
{ draggableKind: 'cell', droppableKind: 'cell' },
|
|
179
|
+
callbacks,
|
|
669
180
|
)
|
|
670
|
-
|
|
181
|
+
return () => manager.destroy()
|
|
182
|
+
}, [gridData])
|
|
671
183
|
```
|
|
672
184
|
|
|
673
|
-
|
|
185
|
+
Render cells with `data-kind`, `data-row`, `data-col`, and item attributes; render a fixed-position
|
|
186
|
+
preview from `dragPreview`. Full component and CSS: [demo app](demo/).
|
|
674
187
|
|
|
675
|
-
|
|
676
|
-
/* Container */
|
|
677
|
-
.draggable-grid-container {
|
|
678
|
-
padding: 20px;
|
|
679
|
-
font-family:
|
|
680
|
-
system-ui,
|
|
681
|
-
-apple-system,
|
|
682
|
-
sans-serif;
|
|
683
|
-
}
|
|
188
|
+
---
|
|
684
189
|
|
|
685
|
-
|
|
686
|
-
.grid {
|
|
687
|
-
display: grid;
|
|
688
|
-
grid-template-columns: repeat(3, 120px);
|
|
689
|
-
gap: 8px;
|
|
690
|
-
padding: 20px;
|
|
691
|
-
}
|
|
190
|
+
## Multiple managers
|
|
692
191
|
|
|
693
|
-
|
|
694
|
-
.
|
|
695
|
-
|
|
696
|
-
align-items: center;
|
|
697
|
-
justify-content: center;
|
|
698
|
-
font-weight: 600;
|
|
699
|
-
border-radius: 8px;
|
|
700
|
-
transition:
|
|
701
|
-
transform 0.2s,
|
|
702
|
-
opacity 0.2s,
|
|
703
|
-
box-shadow 0.2s;
|
|
704
|
-
user-select: none;
|
|
705
|
-
touch-action: none;
|
|
706
|
-
}
|
|
192
|
+
Use two (or more) `DragDropManager` instances with **compatible `data-kind`** and a **shared
|
|
193
|
+
callbacks** object. Ensure positions identify items globally (e.g. `{ containerId, itemId }`) so
|
|
194
|
+
`onDrop` / `onDragEnd` can update both containers or shared state.
|
|
707
195
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
color: white;
|
|
711
|
-
cursor: grab;
|
|
712
|
-
min-height: 80px;
|
|
713
|
-
}
|
|
196
|
+
`onDrop` runs on the manager where the drag **started**. `onDragEnd(result)` runs for every drag;
|
|
197
|
+
`result` is the drop payload or `null`.
|
|
714
198
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
199
|
+
```typescript
|
|
200
|
+
const callbacks: DragDropCallbacks<Item, Position> = {
|
|
201
|
+
getItemPosition: (el) => ({
|
|
202
|
+
containerId: el.closest('[data-container]')?.dataset.container as 'left' | 'right',
|
|
203
|
+
itemId: el.dataset.id!,
|
|
204
|
+
}),
|
|
205
|
+
getItemData: (el) => ({ id: el.dataset.id!, label: el.dataset.label! }),
|
|
206
|
+
onDrop: (sourcePos, targetPos, sourceItem) => {
|
|
207
|
+
// Update DOM or state for both source and target containers
|
|
208
|
+
},
|
|
719
209
|
}
|
|
720
210
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
211
|
+
const leftManager = new DragDropManager(
|
|
212
|
+
leftEl,
|
|
213
|
+
{ draggableKind: 'cell', droppableKind: 'cell' },
|
|
214
|
+
callbacks,
|
|
215
|
+
)
|
|
216
|
+
const rightManager = new DragDropManager(
|
|
217
|
+
rightEl,
|
|
218
|
+
{ draggableKind: 'cell', droppableKind: 'cell' },
|
|
219
|
+
callbacks,
|
|
220
|
+
)
|
|
221
|
+
// Cleanup: destroy both
|
|
222
|
+
```
|
|
726
223
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
224
|
+
Drops land on any matching droppable in the page by default (`dropScope: 'document'`). For
|
|
225
|
+
independent widgets that share kinds but must not exchange items, set `dropScope: 'container'`.
|
|
226
|
+
Managers may also nest: a press inside an inner manager's container belongs to the inner manager
|
|
227
|
+
only.
|
|
732
228
|
|
|
733
|
-
|
|
734
|
-
.drag-preview {
|
|
735
|
-
position: fixed;
|
|
736
|
-
pointer-events: none;
|
|
737
|
-
z-index: 9999;
|
|
738
|
-
transform: translate(-50%, -50%);
|
|
739
|
-
padding: 20px;
|
|
740
|
-
border-radius: 8px;
|
|
741
|
-
color: white;
|
|
742
|
-
font-weight: 600;
|
|
743
|
-
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
|
744
|
-
opacity: 0.9;
|
|
745
|
-
}
|
|
746
|
-
```
|
|
229
|
+
---
|
|
747
230
|
|
|
748
|
-
|
|
231
|
+
## Dynamic drag ability
|
|
749
232
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
function GridCell({ item, row, col, ...props }: CellProps) {
|
|
753
|
-
if (item) {
|
|
754
|
-
return (
|
|
755
|
-
<div
|
|
756
|
-
data-kind="cell"
|
|
757
|
-
data-row={row}
|
|
758
|
-
data-col={col}
|
|
759
|
-
data-item-id={item.id}
|
|
760
|
-
className="flex items-center justify-center p-5 rounded-lg cursor-grab
|
|
761
|
-
font-semibold text-white transition-all duration-200
|
|
762
|
-
data-[dragging=true]:opacity-40 data-[dragging=true]:cursor-grabbing
|
|
763
|
-
data-[hovered=true]:scale-105 data-[hovered=true]:ring-4
|
|
764
|
-
data-[hovered=true]:ring-blue-500/50"
|
|
765
|
-
style={{ background: item.color }}
|
|
766
|
-
{...props}
|
|
767
|
-
>
|
|
768
|
-
{item.name}
|
|
769
|
-
</div>
|
|
770
|
-
)
|
|
771
|
-
}
|
|
233
|
+
Control whether an item can be dragged (e.g. by permissions or locked state) **without
|
|
234
|
+
reinitializing** the manager.
|
|
772
235
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
className="bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg min-h-[80px]"
|
|
780
|
-
{...props}
|
|
781
|
-
/>
|
|
782
|
-
)
|
|
783
|
-
}
|
|
236
|
+
- **`canDrag(element, position)`** — optional callback; called on every pointer down. Return `true`
|
|
237
|
+
to allow drag, `false` to block.
|
|
238
|
+
- Keep the **callbacks object stable** and either:
|
|
239
|
+
- **Mutate** `callbacks.canDrag` when permissions/state change (vanilla), or
|
|
240
|
+
- Have **`canDrag` read from a ref or store** you update (e.g. in React) so the same manager
|
|
241
|
+
instance always sees current state.
|
|
784
242
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
className="fixed pointer-events-none z-[9999] -translate-x-1/2 -translate-y-1/2
|
|
792
|
-
p-5 rounded-lg text-white font-semibold shadow-2xl opacity-90"
|
|
793
|
-
style={{
|
|
794
|
-
left: position.x,
|
|
795
|
-
top: position.y,
|
|
796
|
-
background: item.color,
|
|
797
|
-
width: width ? `${width}px` : undefined,
|
|
798
|
-
height: height ? `${height}px` : undefined,
|
|
799
|
-
}}
|
|
800
|
-
>
|
|
801
|
-
{item.name}
|
|
802
|
-
</div>
|
|
803
|
-
)
|
|
243
|
+
**Vanilla:** update `canDrag` when state changes:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
const callbacks = { getItemPosition, getItemData, canDrag: () => true }
|
|
247
|
+
function setCanDrag(fn: (el: HTMLElement, pos: Position) => boolean) {
|
|
248
|
+
callbacks.canDrag = fn
|
|
804
249
|
}
|
|
250
|
+
setCanDrag((el, pos) => !getItemData(el, pos)?.locked && userCanEdit)
|
|
251
|
+
const manager = new DragDropManager(container, config, callbacks)
|
|
805
252
|
```
|
|
806
253
|
|
|
254
|
+
**React:** use a ref that always holds latest state; `canDrag` reads from it so the manager doesn’t
|
|
255
|
+
need to be recreated when state changes.
|
|
256
|
+
|
|
807
257
|
---
|
|
808
258
|
|
|
809
|
-
##
|
|
259
|
+
## Keyboard
|
|
810
260
|
|
|
811
|
-
|
|
261
|
+
`keyboard: true` adds pick-and-place for keyboard users. Draggables must be focusable (a `<button>`,
|
|
262
|
+
or `tabindex="0"`).
|
|
812
263
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
7. **TypeScript Types** - Full type safety with generics
|
|
264
|
+
| Key | Effect |
|
|
265
|
+
| --------------- | -------------------------------------------------------------------------------------- |
|
|
266
|
+
| Space / Enter | On a focused draggable: pick it up. During the drag: drop on the target. |
|
|
267
|
+
| Arrow keys | Move the target to the nearest droppable in that direction, scrolled into view. |
|
|
268
|
+
| Escape | Cancel (also with `cancelOnEscape: false`; the key goes no further, e.g. to a dialog). |
|
|
269
|
+
| Tab, focus loss | Cancel. |
|
|
820
270
|
|
|
821
|
-
|
|
271
|
+
The same callbacks and attributes apply: `onDragStart`, `data-dragging` on the source,
|
|
272
|
+
`data-hovered` / `data-drop-invalid` on the target, `onDragMove(targetCenter, target)` per move,
|
|
273
|
+
`onDrop` and `onDragEnd`. A drop on a target `canDrop` rejects is ignored (the drag goes on). Focus
|
|
274
|
+
stays on the source throughout; after a drop that moves the item, focus its new element in
|
|
275
|
+
`onDragEnd`. `getState().input` tells a `'keyboard'` drag from a `'pointer'` one.
|
|
822
276
|
|
|
823
|
-
|
|
824
|
-
- `getItemPosition` - Extracts grid coordinates from data attributes
|
|
825
|
-
- `getItemData` - Retrieves item data from grid state
|
|
826
|
-
- `onDragStart` - Shows preview and updates source appearance
|
|
827
|
-
- Uses `getBoundingClientRect()` to copy element dimensions to preview
|
|
828
|
-
- `onDragMove` - Updates preview position in real-time
|
|
829
|
-
- `onDrop` - Swaps items and updates state
|
|
830
|
-
- `onDragEnd` - Hides preview
|
|
831
|
-
- `onClick` - Handles click/tap events
|
|
277
|
+
Screen readers need words: announce from the callbacks into a live region, and describe the keys.
|
|
832
278
|
|
|
833
|
-
|
|
279
|
+
```typescript
|
|
280
|
+
const live = document.querySelector('#dnd-status')! // <div id="dnd-status" aria-live="assertive">
|
|
834
281
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
282
|
+
new DragDropManager(
|
|
283
|
+
grid,
|
|
284
|
+
{ draggableKind: 'slot', droppableKind: 'slot', keyboard: true },
|
|
285
|
+
{
|
|
286
|
+
getItemPosition,
|
|
287
|
+
getItemData,
|
|
288
|
+
onDragStart: (_el, _pos, item) => (live.textContent = `Picked up ${item.name}.`),
|
|
289
|
+
onDragMove: (_pos, target) => (live.textContent = target?.getAttribute('aria-label') ?? ''),
|
|
290
|
+
onDragEnd: (result) => (live.textContent = result ? 'Dropped.' : 'Cancelled.'),
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
```
|
|
841
294
|
|
|
842
295
|
---
|
|
843
296
|
|
|
844
|
-
##
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
297
|
+
## API overview
|
|
298
|
+
|
|
299
|
+
### Callbacks
|
|
300
|
+
|
|
301
|
+
| Callback | Purpose |
|
|
302
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
303
|
+
| `getItemPosition(element, kind)` | Return position object from DOM (e.g. `{ row, col }`) or `null`. |
|
|
304
|
+
| `getItemData(element, position)` | Return item data for the element at `position`. |
|
|
305
|
+
| `canDrag(element, position)` | Optional. Return `false` to block drag on this pointer down. |
|
|
306
|
+
| `canDrop(sourcePosition, targetPosition, sourceItem)` | Optional. Return `false` to reject the droppable under the pointer (checked every frame and at the drop): it gets `data-drop-invalid`, and a drop there ends with `onDragEnd(null)`. |
|
|
307
|
+
| `onDragStart(element, position, item)` | Show preview, update source appearance. |
|
|
308
|
+
| `onDragMove(pointerPosition, hoveredElement)` | Update preview position. |
|
|
309
|
+
| `onDrop(sourcePosition, targetPosition, sourceItem)` | Called when dropped on valid target; update DOM/state. |
|
|
310
|
+
| `onDragEnd(result)` | Always called when drag ends. `result` = `{ sourcePosition, targetPosition, sourceItem }` or `null`. Use for cleanup and optional reordering. |
|
|
311
|
+
| `onClick(element, position)` | Click/tap on draggable element. |
|
|
312
|
+
|
|
313
|
+
### Manager config
|
|
314
|
+
|
|
315
|
+
| Option | Description |
|
|
316
|
+
| --------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
317
|
+
| `draggableKind` / `droppableKind` | String or string[] to match `data-kind`. |
|
|
318
|
+
| `dragThreshold` | Pixels before drag starts (default 10). |
|
|
319
|
+
| `dragDelay` | Long press: ms to hold before the drag starts (default 0). A number, or per type: `{ touch: 250 }`. |
|
|
320
|
+
| `dragDelayTolerance` | Pixels a press may move during `dragDelay` before it is released to the browser (default 5). |
|
|
321
|
+
| `clickThreshold` | Max movement to still count as click (default 10). |
|
|
322
|
+
| `scrollThreshold` | Distance from a scroll container's or the viewport's edge to start auto-scroll (default 100). |
|
|
323
|
+
| `scrollSpeed` | Auto-scroll speed in px per frame (default 10, `0` disables it). |
|
|
324
|
+
| `cancelOnEscape` | Cancel drag on Escape (default true). |
|
|
325
|
+
| `cancelOnPointerLeave` | Cancel when pointer leaves window (default true). |
|
|
326
|
+
| `dropScope` | `'document'` (default): drop on matching droppables anywhere; `'container'`: only inside this container. |
|
|
327
|
+
| `keyboard` | Keyboard pick-and-place (default false); see [Keyboard](#keyboard). |
|
|
328
|
+
| `focusOnPress` | A press focuses the draggable, as a click would (default false); see below. |
|
|
329
|
+
|
|
330
|
+
### Conventions
|
|
331
|
+
|
|
332
|
+
- **`data-kind`** — identifies draggable/droppable type; must match config.
|
|
333
|
+
- **`data-empty`** — elements with this attribute are not draggable.
|
|
334
|
+
- **Buttons** — only the main button of the primary pointer starts a drag; right-click (context
|
|
335
|
+
menus), middle-click and extra touches are left to the browser.
|
|
336
|
+
- **`data-dragging`** — set to `"true"` on the source element during drag; removed on end/cancel.
|
|
337
|
+
- **`data-hovered`** — set to `"true"` on the hovered droppable; removed when pointer leaves or drag
|
|
338
|
+
ends.
|
|
339
|
+
- **`data-drop-invalid`** — set to `"true"` instead of `data-hovered` on a hovered droppable that
|
|
340
|
+
`canDrop` rejects (e.g. a full party, a locked box); style it as "not allowed".
|
|
341
|
+
- **Preview** — implement in `onDragStart` / `onDragMove` / `onDragEnd`, or use
|
|
342
|
+
`DragPreviewController`.
|
|
343
|
+
|
|
344
|
+
### Notes
|
|
345
|
+
|
|
346
|
+
- Uses `requestAnimationFrame` for 60fps updates; pointer events work with touch, mouse, and pen.
|
|
347
|
+
- **Focus.** By default a press on a draggable is cancelled (`preventDefault` on `pointerdown`),
|
|
348
|
+
which keeps the browser from selecting text or dragging an `<img>` or link natively, but also
|
|
349
|
+
keeps focus where it was. `focusOnPress: true` lets the press through and cancels `selectstart`
|
|
350
|
+
and `dragstart` while it is held instead: the draggable gets focus like a clicked button (no focus
|
|
351
|
+
ring for a mouse, per `:focus-visible`), so focus styles, keyboard follow-ups and a virtual list
|
|
352
|
+
that keeps its focused row rendered work with mouse drags too.
|
|
353
|
+
- **Touch.** Once a touch press becomes a drag, the manager keeps the browser from panning and from
|
|
354
|
+
opening a long-press context menu, so the drag is not cancelled. It does so with a non-passive
|
|
355
|
+
`touchmove` listener on the container, in place before any touch starts (iOS Safari ignores one
|
|
356
|
+
added later), so touch scrolls that start inside the container wait for that small handler. A drag
|
|
357
|
+
also survives its source leaving the DOM mid-drag (a virtualized list unmounting the row), whose
|
|
358
|
+
touch and pointer events keep going to the detached element. Checked in Chromium and iOS Safari.
|
|
359
|
+
Without a delay, a swipe that starts on a draggable drags rather than scrolls. For lists that must
|
|
360
|
+
also scroll by touch, use a long press: `dragDelay: { touch: 250 }` — a quick swipe scrolls,
|
|
361
|
+
holding still for 250ms picks the item up. On iOS also add
|
|
362
|
+
`-webkit-touch-callout: none; user-select: none` to draggables.
|
|
363
|
+
- Auto-scroll scrolls the closest scroll container under the pointer (`overflow: auto | scroll`)
|
|
364
|
+
that can still scroll toward the edge the pointer is near, nested ones first, and otherwise the
|
|
365
|
+
window. It keeps scrolling while the pointer rests in the edge zone, and re-checks the hovered
|
|
366
|
+
droppable after each step. A container's zone is at most a quarter of its size.
|
|
367
|
+
- The native `click` the browser fires after the release that ends a drag (on the source element,
|
|
368
|
+
which holds pointer capture) is swallowed, so a draggable `<button>` or link is not activated by a
|
|
369
|
+
drop. A press that never became a drag clicks as usual.
|
|
370
|
+
- While a press is held the manager follows `pointermove` on the whole window, so dragging out of
|
|
371
|
+
the container works even if the source element leaves the DOM mid-drag (a virtualized list
|
|
372
|
+
unmounting its row), which ends pointer capture. Re-render the source's state from your own data
|
|
373
|
+
(`onDragStart` / `onDragEnd`) if the row can come back as a new element.
|
|
374
|
+
- React: create manager in `useEffect`, depend on grid state if `getItemData` uses it, and destroy
|
|
375
|
+
on cleanup.
|