dnd-manager 1.2.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 +290 -768
- package/dist/index.d.mts +146 -12
- package/dist/index.mjs +468 -77
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -13
package/README.md
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
# dnd-manager
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
locked into specific component patterns or opinionated libraries.
|
|
3
|
+
Lightweight, data-driven drag-and-drop for vanilla JS and any framework. Full control over UX
|
|
4
|
+
without opinionated component patterns.
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
---
|
|
9
10
|
|
|
10
11
|
## Table of Contents
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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)
|
|
16
22
|
|
|
17
23
|
---
|
|
18
24
|
|
|
@@ -20,834 +26,350 @@ applications.
|
|
|
20
26
|
|
|
21
27
|
```bash
|
|
22
28
|
pnpm add dnd-manager
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Or with npm/yarn:
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npm install dnd-manager
|
|
29
|
-
yarn add dnd-manager
|
|
29
|
+
# or: npm install dnd-manager
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
-
##
|
|
34
|
+
## Quick start
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
38
39
|
|
|
39
40
|
```typescript
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
type PointerPosition,
|
|
45
|
-
} from 'dnd-manager'
|
|
46
|
-
|
|
47
|
-
type Item = { index: number }
|
|
48
|
-
type Position = { index: number }
|
|
49
|
-
|
|
50
|
-
const preview = new DragPreviewController({
|
|
51
|
-
zIndex: 9999,
|
|
52
|
-
opacity: 0.95,
|
|
53
|
-
className: 'drop-shadow-xl',
|
|
54
|
-
})
|
|
41
|
+
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
|
|
42
|
+
|
|
43
|
+
type Item = { id: string; name: string }
|
|
44
|
+
type Position = { row: number; col: number }
|
|
55
45
|
|
|
56
46
|
const callbacks: DragDropCallbacks<Item, Position> = {
|
|
57
|
-
getItemPosition: (
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return Number.isFinite(index) ? { index } : null
|
|
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
|
|
62
51
|
},
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
onDragStart: (element) => {
|
|
67
|
-
preview.startFromElement(element)
|
|
52
|
+
getItemData: (el, pos) => ({ id: el.dataset.itemId ?? '', name: el.dataset.name ?? '' }),
|
|
53
|
+
onDrop: (from, to, item) => {
|
|
54
|
+
/* swap/update DOM or state */
|
|
68
55
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
preview.moveToPointer(pos)
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
onDragEnd: () => {
|
|
75
|
-
preview.stop()
|
|
56
|
+
onDragEnd: (result) => {
|
|
57
|
+
/* result or null; cleanup preview etc. */
|
|
76
58
|
},
|
|
77
59
|
}
|
|
78
60
|
|
|
79
61
|
const manager = new DragDropManager<Item, Position>(
|
|
80
|
-
'#container',
|
|
81
|
-
{
|
|
82
|
-
draggableKind: 'ITEM',
|
|
83
|
-
droppableKind: 'ITEM',
|
|
84
|
-
},
|
|
62
|
+
document.querySelector('#container'),
|
|
63
|
+
{ draggableKind: 'cell', droppableKind: 'cell' },
|
|
85
64
|
callbacks,
|
|
86
65
|
)
|
|
87
66
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
preview.destroy()
|
|
91
|
-
manager.destroy()
|
|
92
|
-
}
|
|
67
|
+
// On teardown:
|
|
68
|
+
manager.destroy()
|
|
93
69
|
```
|
|
94
70
|
|
|
95
|
-
|
|
96
|
-
|
|
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).
|
|
97
73
|
|
|
98
74
|
---
|
|
99
75
|
|
|
100
|
-
##
|
|
76
|
+
## Clone preview helper
|
|
101
77
|
|
|
102
|
-
|
|
103
|
-
|
|
78
|
+
`DragPreviewController` gives a clone of the dragged element that follows the cursor
|
|
79
|
+
(`position: fixed`, `pointer-events: none`). Reduces drift in scrolled containers.
|
|
104
80
|
|
|
105
|
-
|
|
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`.
|
|
106
88
|
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
padding: 20px;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.cell {
|
|
133
|
-
display: flex;
|
|
134
|
-
align-items: center;
|
|
135
|
-
justify-content: center;
|
|
136
|
-
font-weight: 600;
|
|
137
|
-
border-radius: 8px;
|
|
138
|
-
padding: 20px;
|
|
139
|
-
min-height: 80px;
|
|
140
|
-
transition:
|
|
141
|
-
transform 0.2s,
|
|
142
|
-
opacity 0.2s,
|
|
143
|
-
box-shadow 0.2s;
|
|
144
|
-
user-select: none;
|
|
145
|
-
touch-action: none;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.cell:not([data-empty]) {
|
|
149
|
-
color: white;
|
|
150
|
-
cursor: grab;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
.cell[data-empty] {
|
|
154
|
-
background: #f3f4f6;
|
|
155
|
-
border: 2px dashed #d1d5db;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/* Dragging states */
|
|
159
|
-
.cell[data-dragging='true'] {
|
|
160
|
-
opacity: 0.4;
|
|
161
|
-
cursor: grabbing !important;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.cell[data-hovered='true'] {
|
|
165
|
-
transform: scale(1.05);
|
|
166
|
-
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/* Drag preview */
|
|
170
|
-
#drag-preview {
|
|
171
|
-
position: fixed;
|
|
172
|
-
pointer-events: none;
|
|
173
|
-
z-index: 9999;
|
|
174
|
-
opacity: 0;
|
|
175
|
-
transform: translate(-50%, -50%);
|
|
176
|
-
transition: opacity 0.2s;
|
|
177
|
-
padding: 20px;
|
|
178
|
-
border-radius: 8px;
|
|
179
|
-
color: white;
|
|
180
|
-
font-weight: 600;
|
|
181
|
-
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
|
182
|
-
}
|
|
183
|
-
</style>
|
|
184
|
-
</head>
|
|
185
|
-
<body>
|
|
186
|
-
<h1>Vanilla JS Drag & Drop Grid</h1>
|
|
187
|
-
<p>Drag cells to reorder, click to log to console</p>
|
|
188
|
-
|
|
189
|
-
<div id="grid-container" class="grid">
|
|
190
|
-
<!-- Row 0 -->
|
|
191
|
-
<div
|
|
192
|
-
class="cell"
|
|
193
|
-
data-kind="cell"
|
|
194
|
-
data-row="0"
|
|
195
|
-
data-col="0"
|
|
196
|
-
data-item-id="1"
|
|
197
|
-
data-name="Item 1"
|
|
198
|
-
style="background: #ef4444"
|
|
199
|
-
>
|
|
200
|
-
Item 1
|
|
201
|
-
</div>
|
|
202
|
-
<div
|
|
203
|
-
class="cell"
|
|
204
|
-
data-kind="cell"
|
|
205
|
-
data-row="0"
|
|
206
|
-
data-col="1"
|
|
207
|
-
data-item-id="2"
|
|
208
|
-
data-name="Item 2"
|
|
209
|
-
style="background: #3b82f6"
|
|
210
|
-
>
|
|
211
|
-
Item 2
|
|
212
|
-
</div>
|
|
213
|
-
<div
|
|
214
|
-
class="cell"
|
|
215
|
-
data-kind="cell"
|
|
216
|
-
data-row="0"
|
|
217
|
-
data-col="2"
|
|
218
|
-
data-item-id="3"
|
|
219
|
-
data-name="Item 3"
|
|
220
|
-
style="background: #10b981"
|
|
221
|
-
>
|
|
222
|
-
Item 3
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
<!-- Row 1 -->
|
|
226
|
-
<div
|
|
227
|
-
class="cell"
|
|
228
|
-
data-kind="cell"
|
|
229
|
-
data-row="1"
|
|
230
|
-
data-col="0"
|
|
231
|
-
data-item-id="4"
|
|
232
|
-
data-name="Item 4"
|
|
233
|
-
style="background: #f59e0b"
|
|
234
|
-
>
|
|
235
|
-
Item 4
|
|
236
|
-
</div>
|
|
237
|
-
<div class="cell" data-kind="cell" data-row="1" data-col="1" data-empty></div>
|
|
238
|
-
<div
|
|
239
|
-
class="cell"
|
|
240
|
-
data-kind="cell"
|
|
241
|
-
data-row="1"
|
|
242
|
-
data-col="2"
|
|
243
|
-
data-item-id="5"
|
|
244
|
-
data-name="Item 5"
|
|
245
|
-
style="background: #8b5cf6"
|
|
246
|
-
>
|
|
247
|
-
Item 5
|
|
248
|
-
</div>
|
|
249
|
-
|
|
250
|
-
<!-- Row 2 -->
|
|
251
|
-
<div
|
|
252
|
-
class="cell"
|
|
253
|
-
data-kind="cell"
|
|
254
|
-
data-row="2"
|
|
255
|
-
data-col="0"
|
|
256
|
-
data-item-id="6"
|
|
257
|
-
data-name="Item 6"
|
|
258
|
-
style="background: #ec4899"
|
|
259
|
-
>
|
|
260
|
-
Item 6
|
|
261
|
-
</div>
|
|
262
|
-
<div
|
|
263
|
-
class="cell"
|
|
264
|
-
data-kind="cell"
|
|
265
|
-
data-row="2"
|
|
266
|
-
data-col="1"
|
|
267
|
-
data-item-id="7"
|
|
268
|
-
data-name="Item 7"
|
|
269
|
-
style="background: #14b8a6"
|
|
270
|
-
>
|
|
271
|
-
Item 7
|
|
272
|
-
</div>
|
|
273
|
-
<div class="cell" data-kind="cell" data-row="2" data-col="2" data-empty></div>
|
|
274
|
-
</div>
|
|
275
|
-
|
|
276
|
-
<!-- Drag preview element -->
|
|
277
|
-
<div id="drag-preview"></div>
|
|
278
|
-
|
|
279
|
-
<script type="module" src="./app.ts"></script>
|
|
280
|
-
</body>
|
|
281
|
-
</html>
|
|
89
|
+
```typescript
|
|
90
|
+
import { DragPreviewController, DragDropManager, type DragDropCallbacks } from 'dnd-manager'
|
|
91
|
+
|
|
92
|
+
const preview = new DragPreviewController({ zIndex: 9999, opacity: 0.95 })
|
|
93
|
+
|
|
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(),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const manager = new DragDropManager(
|
|
103
|
+
container,
|
|
104
|
+
{ draggableKind: 'ITEM', droppableKind: 'ITEM' },
|
|
105
|
+
callbacks,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// Cleanup:
|
|
109
|
+
preview.destroy()
|
|
110
|
+
manager.destroy()
|
|
282
111
|
```
|
|
283
112
|
|
|
284
|
-
|
|
113
|
+
---
|
|
285
114
|
|
|
286
|
-
|
|
287
|
-
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
|
|
115
|
+
## Vanilla JS
|
|
288
116
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
id: string
|
|
292
|
-
name: string
|
|
293
|
-
color: string
|
|
294
|
-
}
|
|
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:
|
|
295
119
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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).
|
|
300
129
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const container = document.getElementById('grid-container')
|
|
304
|
-
const preview = document.getElementById('drag-preview')
|
|
130
|
+
Full grid markup and CSS are in the [demo app](demo/) (see `GridDemo` and styles). Minimal markup
|
|
131
|
+
pattern:
|
|
305
132
|
|
|
306
|
-
|
|
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
|
+
```
|
|
307
142
|
|
|
308
|
-
|
|
309
|
-
const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
|
|
310
|
-
getItemPosition: (element, kind) => {
|
|
311
|
-
const row = element.dataset.row
|
|
312
|
-
const col = element.dataset.col
|
|
313
|
-
if (row === undefined || col === undefined) return null
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
row: parseInt(row, 10),
|
|
317
|
-
col: parseInt(col, 10),
|
|
318
|
-
}
|
|
319
|
-
},
|
|
320
|
-
|
|
321
|
-
getItemData: (element, position) => {
|
|
322
|
-
const itemId = element.dataset.itemId
|
|
323
|
-
const name = element.dataset.name
|
|
324
|
-
const color = element.style.background
|
|
325
|
-
|
|
326
|
-
if (!itemId || !name || !color) return null
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
id: itemId,
|
|
330
|
-
name: name,
|
|
331
|
-
color: color,
|
|
332
|
-
}
|
|
333
|
-
},
|
|
334
|
-
|
|
335
|
-
onDragStart: (element, position, item) => {
|
|
336
|
-
console.log('🚀 Drag started:', item)
|
|
337
|
-
|
|
338
|
-
// Copy element dimensions and styles to preview
|
|
339
|
-
const rect = element.getBoundingClientRect()
|
|
340
|
-
preview.textContent = item.name
|
|
341
|
-
preview.style.background = item.color
|
|
342
|
-
preview.style.width = `${rect.width}px`
|
|
343
|
-
preview.style.height = `${rect.height}px`
|
|
344
|
-
preview.style.opacity = '0.9'
|
|
345
|
-
},
|
|
346
|
-
|
|
347
|
-
onDragMove: (pos, hoveredElement) => {
|
|
348
|
-
// Update preview position
|
|
349
|
-
preview.style.left = `${pos.x}px`
|
|
350
|
-
preview.style.top = `${pos.y}px`
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
onDrop: (sourcePos, targetPos, sourceItem) => {
|
|
354
|
-
console.log('📦 Dropped:', {
|
|
355
|
-
from: sourcePos,
|
|
356
|
-
to: targetPos,
|
|
357
|
-
item: sourceItem,
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
// Get source and target elements
|
|
361
|
-
const sourceElement = container.querySelector(
|
|
362
|
-
`[data-row="${sourcePos.row}"][data-col="${sourcePos.col}"]`,
|
|
363
|
-
) as HTMLElement
|
|
364
|
-
const targetElement = container.querySelector(
|
|
365
|
-
`[data-row="${targetPos.row}"][data-col="${targetPos.col}"]`,
|
|
366
|
-
) as HTMLElement
|
|
367
|
-
|
|
368
|
-
if (!sourceElement || !targetElement) return
|
|
369
|
-
|
|
370
|
-
// Swap element contents and data attributes
|
|
371
|
-
const tempContent = sourceElement.innerHTML
|
|
372
|
-
const tempId = sourceElement.dataset.itemId
|
|
373
|
-
const tempName = sourceElement.dataset.name
|
|
374
|
-
const tempColor = sourceElement.style.background
|
|
375
|
-
const tempEmpty = sourceElement.dataset.empty
|
|
376
|
-
|
|
377
|
-
// Update source with target data
|
|
378
|
-
sourceElement.innerHTML = targetElement.innerHTML
|
|
379
|
-
sourceElement.dataset.itemId = targetElement.dataset.itemId || ''
|
|
380
|
-
sourceElement.dataset.name = targetElement.dataset.name || ''
|
|
381
|
-
sourceElement.style.background = targetElement.style.background
|
|
382
|
-
if (targetElement.dataset.empty !== undefined) {
|
|
383
|
-
sourceElement.dataset.empty = ''
|
|
384
|
-
} else {
|
|
385
|
-
delete sourceElement.dataset.empty
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Update target with source data
|
|
389
|
-
targetElement.innerHTML = tempContent
|
|
390
|
-
targetElement.dataset.itemId = tempId || ''
|
|
391
|
-
targetElement.dataset.name = tempName || ''
|
|
392
|
-
targetElement.style.background = tempColor
|
|
393
|
-
if (tempEmpty !== undefined) {
|
|
394
|
-
targetElement.dataset.empty = ''
|
|
395
|
-
} else {
|
|
396
|
-
delete targetElement.dataset.empty
|
|
397
|
-
}
|
|
398
|
-
},
|
|
399
|
-
|
|
400
|
-
onDragEnd: () => {
|
|
401
|
-
// Hide preview
|
|
402
|
-
preview.style.opacity = '0'
|
|
403
|
-
},
|
|
404
|
-
|
|
405
|
-
onClick: (element, position) => {
|
|
406
|
-
const itemId = element.dataset.itemId
|
|
407
|
-
const name = element.dataset.name
|
|
408
|
-
const color = element.style.background
|
|
409
|
-
|
|
410
|
-
if (itemId && name) {
|
|
411
|
-
console.log('👆 Clicked:', { id: itemId, name, color })
|
|
412
|
-
}
|
|
413
|
-
},
|
|
414
|
-
}
|
|
143
|
+
### Reordering in `onDragEnd`
|
|
415
144
|
|
|
416
|
-
|
|
417
|
-
const dragDropManager = new DragDropManager<GridItem, GridPosition>(
|
|
418
|
-
container,
|
|
419
|
-
{
|
|
420
|
-
draggableKind: 'cell',
|
|
421
|
-
droppableKind: 'cell',
|
|
422
|
-
dragThreshold: 10,
|
|
423
|
-
clickThreshold: 10,
|
|
424
|
-
scrollThreshold: 100,
|
|
425
|
-
scrollSpeed: 10,
|
|
426
|
-
cancelOnEscape: true,
|
|
427
|
-
cancelOnPointerLeave: true,
|
|
428
|
-
},
|
|
429
|
-
callbacks,
|
|
430
|
-
)
|
|
145
|
+
You can do all post-drag work in `onDragEnd` so cleanup and DOM/state updates stay in one place:
|
|
431
146
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
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'
|
|
436
154
|
}
|
|
437
|
-
|
|
438
|
-
// Start when DOM is ready
|
|
439
|
-
document.addEventListener('DOMContentLoaded', initDragDropGrid)
|
|
440
155
|
```
|
|
441
156
|
|
|
442
157
|
---
|
|
443
158
|
|
|
444
|
-
## React
|
|
159
|
+
## React
|
|
445
160
|
|
|
446
|
-
|
|
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.
|
|
447
164
|
|
|
448
165
|
```typescript
|
|
449
|
-
|
|
450
|
-
|
|
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,
|
|
180
|
+
)
|
|
181
|
+
return () => manager.destroy()
|
|
182
|
+
}, [gridData])
|
|
183
|
+
```
|
|
451
184
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
id: string
|
|
455
|
-
name: string
|
|
456
|
-
color: string
|
|
457
|
-
}
|
|
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/).
|
|
458
187
|
|
|
459
|
-
|
|
460
|
-
row: number
|
|
461
|
-
col: number
|
|
462
|
-
}
|
|
188
|
+
---
|
|
463
189
|
|
|
464
|
-
|
|
465
|
-
const INITIAL_DATA: (GridItem | null)[][] = [
|
|
466
|
-
[
|
|
467
|
-
{ id: '1', name: 'Item 1', color: '#ef4444' },
|
|
468
|
-
{ id: '2', name: 'Item 2', color: '#3b82f6' },
|
|
469
|
-
{ id: '3', name: 'Item 3', color: '#10b981' },
|
|
470
|
-
],
|
|
471
|
-
[
|
|
472
|
-
{ id: '4', name: 'Item 4', color: '#f59e0b' },
|
|
473
|
-
null,
|
|
474
|
-
{ id: '5', name: 'Item 5', color: '#8b5cf6' },
|
|
475
|
-
],
|
|
476
|
-
[
|
|
477
|
-
{ id: '6', name: 'Item 6', color: '#ec4899' },
|
|
478
|
-
{ id: '7', name: 'Item 7', color: '#14b8a6' },
|
|
479
|
-
null,
|
|
480
|
-
],
|
|
481
|
-
]
|
|
482
|
-
|
|
483
|
-
// Cell component
|
|
484
|
-
type CellProps = {
|
|
485
|
-
item: GridItem | null
|
|
486
|
-
row: number
|
|
487
|
-
col: number
|
|
488
|
-
} & React.ComponentProps<'div'>
|
|
489
|
-
|
|
490
|
-
function GridCell({ item, row, col, ...props }: CellProps) {
|
|
491
|
-
if (item) {
|
|
492
|
-
return (
|
|
493
|
-
<div
|
|
494
|
-
data-kind="cell"
|
|
495
|
-
data-row={row}
|
|
496
|
-
data-col={col}
|
|
497
|
-
data-item-id={item.id}
|
|
498
|
-
className="grid-cell grid-cell--filled"
|
|
499
|
-
style={{
|
|
500
|
-
background: item.color,
|
|
501
|
-
}}
|
|
502
|
-
{...props}
|
|
503
|
-
>
|
|
504
|
-
{item.name}
|
|
505
|
-
</div>
|
|
506
|
-
)
|
|
507
|
-
}
|
|
190
|
+
## Multiple managers
|
|
508
191
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
data-row={row}
|
|
513
|
-
data-col={col}
|
|
514
|
-
data-empty="true"
|
|
515
|
-
className="grid-cell grid-cell--empty"
|
|
516
|
-
{...props}
|
|
517
|
-
/>
|
|
518
|
-
)
|
|
519
|
-
}
|
|
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.
|
|
520
195
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
item: GridItem | null
|
|
524
|
-
position: { x: number; y: number } | null
|
|
525
|
-
width?: number
|
|
526
|
-
height?: number
|
|
527
|
-
}
|
|
196
|
+
`onDrop` runs on the manager where the drag **started**. `onDragEnd(result)` runs for every drag;
|
|
197
|
+
`result` is the drop payload or `null`.
|
|
528
198
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
width: width ? `${width}px` : undefined,
|
|
540
|
-
height: height ? `${height}px` : undefined,
|
|
541
|
-
}}
|
|
542
|
-
>
|
|
543
|
-
{item.name}
|
|
544
|
-
</div>
|
|
545
|
-
)
|
|
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
|
+
},
|
|
546
209
|
}
|
|
547
210
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
useEffect(() => {
|
|
560
|
-
if (!containerRef.current) return
|
|
561
|
-
|
|
562
|
-
// Setup callbacks
|
|
563
|
-
const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
|
|
564
|
-
getItemPosition: (element, kind) => {
|
|
565
|
-
const row = element.dataset.row
|
|
566
|
-
const col = element.dataset.col
|
|
567
|
-
if (row === undefined || col === undefined) return null
|
|
568
|
-
|
|
569
|
-
return {
|
|
570
|
-
row: parseInt(row, 10),
|
|
571
|
-
col: parseInt(col, 10),
|
|
572
|
-
}
|
|
573
|
-
},
|
|
574
|
-
|
|
575
|
-
getItemData: (element, position) => {
|
|
576
|
-
return gridData[position.row][position.col]
|
|
577
|
-
},
|
|
578
|
-
|
|
579
|
-
onDragStart: (element, position, item) => {
|
|
580
|
-
console.log('🚀 Drag started:', item)
|
|
581
|
-
|
|
582
|
-
// Store element dimensions for preview
|
|
583
|
-
const rect = element.getBoundingClientRect()
|
|
584
|
-
setDragPreview({
|
|
585
|
-
item,
|
|
586
|
-
position: null,
|
|
587
|
-
width: rect.width,
|
|
588
|
-
height: rect.height,
|
|
589
|
-
})
|
|
590
|
-
},
|
|
591
|
-
|
|
592
|
-
onDragMove: (pos, hoveredElement) => {
|
|
593
|
-
setDragPreview((prev) => ({
|
|
594
|
-
...prev,
|
|
595
|
-
position: pos,
|
|
596
|
-
}))
|
|
597
|
-
},
|
|
598
|
-
|
|
599
|
-
onDrop: (sourcePos, targetPos, sourceItem) => {
|
|
600
|
-
console.log('📦 Dropped:', {
|
|
601
|
-
from: sourcePos,
|
|
602
|
-
to: targetPos,
|
|
603
|
-
item: sourceItem,
|
|
604
|
-
})
|
|
605
|
-
|
|
606
|
-
// Swap items
|
|
607
|
-
setGridData((prevData) => {
|
|
608
|
-
const newData = prevData.map((row) => [...row])
|
|
609
|
-
const targetItem = newData[targetPos.row][targetPos.col]
|
|
610
|
-
newData[targetPos.row][targetPos.col] = sourceItem
|
|
611
|
-
newData[sourcePos.row][sourcePos.col] = targetItem
|
|
612
|
-
return newData
|
|
613
|
-
})
|
|
614
|
-
},
|
|
615
|
-
|
|
616
|
-
onDragEnd: () => {
|
|
617
|
-
setDragPreview({ item: null, position: null, width: undefined, height: undefined })
|
|
618
|
-
},
|
|
619
|
-
|
|
620
|
-
onClick: (element, position) => {
|
|
621
|
-
const item = gridData[position.row][position.col]
|
|
622
|
-
if (item) {
|
|
623
|
-
console.log('👆 Clicked:', item)
|
|
624
|
-
}
|
|
625
|
-
},
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Create manager
|
|
629
|
-
const manager = new DragDropManager<GridItem, GridPosition>(
|
|
630
|
-
containerRef,
|
|
631
|
-
{
|
|
632
|
-
draggableKind: 'cell',
|
|
633
|
-
droppableKind: 'cell',
|
|
634
|
-
dragThreshold: 10,
|
|
635
|
-
clickThreshold: 10,
|
|
636
|
-
scrollThreshold: 100,
|
|
637
|
-
scrollSpeed: 10,
|
|
638
|
-
cancelOnEscape: true,
|
|
639
|
-
cancelOnPointerLeave: true,
|
|
640
|
-
},
|
|
641
|
-
callbacks,
|
|
642
|
-
)
|
|
643
|
-
|
|
644
|
-
// Cleanup
|
|
645
|
-
return () => {
|
|
646
|
-
manager.destroy()
|
|
647
|
-
}
|
|
648
|
-
}, [gridData])
|
|
649
|
-
|
|
650
|
-
return (
|
|
651
|
-
<div className="draggable-grid-container">
|
|
652
|
-
<h1>React Drag & Drop Grid</h1>
|
|
653
|
-
<p>Drag cells to reorder, click to log to console</p>
|
|
654
|
-
|
|
655
|
-
<div ref={containerRef} className="grid">
|
|
656
|
-
{gridData.map((row, rowIndex) =>
|
|
657
|
-
row.map((item, colIndex) => (
|
|
658
|
-
<GridCell key={`${rowIndex}-${colIndex}`} item={item} row={rowIndex} col={colIndex} />
|
|
659
|
-
)),
|
|
660
|
-
)}
|
|
661
|
-
</div>
|
|
662
|
-
|
|
663
|
-
<DragPreview
|
|
664
|
-
item={dragPreview.item}
|
|
665
|
-
position={dragPreview.position}
|
|
666
|
-
width={dragPreview.width}
|
|
667
|
-
height={dragPreview.height}
|
|
668
|
-
/>
|
|
669
|
-
</div>
|
|
670
|
-
)
|
|
671
|
-
}
|
|
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
|
|
672
222
|
```
|
|
673
223
|
|
|
674
|
-
|
|
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.
|
|
675
228
|
|
|
676
|
-
|
|
677
|
-
/* Container */
|
|
678
|
-
.draggable-grid-container {
|
|
679
|
-
padding: 20px;
|
|
680
|
-
font-family:
|
|
681
|
-
system-ui,
|
|
682
|
-
-apple-system,
|
|
683
|
-
sans-serif;
|
|
684
|
-
}
|
|
229
|
+
---
|
|
685
230
|
|
|
686
|
-
|
|
687
|
-
.grid {
|
|
688
|
-
display: grid;
|
|
689
|
-
grid-template-columns: repeat(3, 120px);
|
|
690
|
-
gap: 8px;
|
|
691
|
-
padding: 20px;
|
|
692
|
-
}
|
|
231
|
+
## Dynamic drag ability
|
|
693
232
|
|
|
694
|
-
|
|
695
|
-
.
|
|
696
|
-
display: flex;
|
|
697
|
-
align-items: center;
|
|
698
|
-
justify-content: center;
|
|
699
|
-
font-weight: 600;
|
|
700
|
-
border-radius: 8px;
|
|
701
|
-
transition:
|
|
702
|
-
transform 0.2s,
|
|
703
|
-
opacity 0.2s,
|
|
704
|
-
box-shadow 0.2s;
|
|
705
|
-
user-select: none;
|
|
706
|
-
touch-action: none;
|
|
707
|
-
}
|
|
233
|
+
Control whether an item can be dragged (e.g. by permissions or locked state) **without
|
|
234
|
+
reinitializing** the manager.
|
|
708
235
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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.
|
|
715
242
|
|
|
716
|
-
|
|
717
|
-
background: #f3f4f6;
|
|
718
|
-
border: 2px dashed #d1d5db;
|
|
719
|
-
min-height: 80px;
|
|
720
|
-
}
|
|
243
|
+
**Vanilla:** update `canDrag` when state changes:
|
|
721
244
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
245
|
+
```typescript
|
|
246
|
+
const callbacks = { getItemPosition, getItemData, canDrag: () => true }
|
|
247
|
+
function setCanDrag(fn: (el: HTMLElement, pos: Position) => boolean) {
|
|
248
|
+
callbacks.canDrag = fn
|
|
726
249
|
}
|
|
250
|
+
setCanDrag((el, pos) => !getItemData(el, pos)?.locked && userCanEdit)
|
|
251
|
+
const manager = new DragDropManager(container, config, callbacks)
|
|
252
|
+
```
|
|
727
253
|
|
|
728
|
-
|
|
729
|
-
.
|
|
730
|
-
transform: scale(1.05);
|
|
731
|
-
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
|
|
732
|
-
}
|
|
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.
|
|
733
256
|
|
|
734
|
-
|
|
735
|
-
.drag-preview {
|
|
736
|
-
position: fixed;
|
|
737
|
-
pointer-events: none;
|
|
738
|
-
z-index: 9999;
|
|
739
|
-
transform: translate(-50%, -50%);
|
|
740
|
-
padding: 20px;
|
|
741
|
-
border-radius: 8px;
|
|
742
|
-
color: white;
|
|
743
|
-
font-weight: 600;
|
|
744
|
-
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
|
745
|
-
opacity: 0.9;
|
|
746
|
-
}
|
|
747
|
-
```
|
|
257
|
+
---
|
|
748
258
|
|
|
749
|
-
|
|
259
|
+
## Keyboard
|
|
750
260
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
function GridCell({ item, row, col, ...props }: CellProps) {
|
|
754
|
-
if (item) {
|
|
755
|
-
return (
|
|
756
|
-
<div
|
|
757
|
-
data-kind="cell"
|
|
758
|
-
data-row={row}
|
|
759
|
-
data-col={col}
|
|
760
|
-
data-item-id={item.id}
|
|
761
|
-
className="flex items-center justify-center p-5 rounded-lg cursor-grab
|
|
762
|
-
font-semibold text-white transition-all duration-200
|
|
763
|
-
data-[dragging=true]:opacity-40 data-[dragging=true]:cursor-grabbing
|
|
764
|
-
data-[hovered=true]:scale-105 data-[hovered=true]:ring-4
|
|
765
|
-
data-[hovered=true]:ring-blue-500/50"
|
|
766
|
-
style={{ background: item.color }}
|
|
767
|
-
{...props}
|
|
768
|
-
>
|
|
769
|
-
{item.name}
|
|
770
|
-
</div>
|
|
771
|
-
)
|
|
772
|
-
}
|
|
261
|
+
`keyboard: true` adds pick-and-place for keyboard users. Draggables must be focusable (a `<button>`,
|
|
262
|
+
or `tabindex="0"`).
|
|
773
263
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
className="bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg min-h-[80px]"
|
|
781
|
-
{...props}
|
|
782
|
-
/>
|
|
783
|
-
)
|
|
784
|
-
}
|
|
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. |
|
|
785
270
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
<div
|
|
792
|
-
className="fixed pointer-events-none z-[9999] -translate-x-1/2 -translate-y-1/2
|
|
793
|
-
p-5 rounded-lg text-white font-semibold shadow-2xl opacity-90"
|
|
794
|
-
style={{
|
|
795
|
-
left: position.x,
|
|
796
|
-
top: position.y,
|
|
797
|
-
background: item.color,
|
|
798
|
-
width: width ? `${width}px` : undefined,
|
|
799
|
-
height: height ? `${height}px` : undefined,
|
|
800
|
-
}}
|
|
801
|
-
>
|
|
802
|
-
{item.name}
|
|
803
|
-
</div>
|
|
804
|
-
)
|
|
805
|
-
}
|
|
806
|
-
```
|
|
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.
|
|
807
276
|
|
|
808
|
-
|
|
277
|
+
Screen readers need words: announce from the callbacks into a live region, and describe the keys.
|
|
809
278
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
### Both Examples Include:
|
|
813
|
-
|
|
814
|
-
1. **Grid Layout** - 3x3 grid with draggable cells
|
|
815
|
-
2. **Click Detection** - Logs item data to console on click
|
|
816
|
-
3. **Drag Preview** - Visual feedback following cursor during drag, matching original element size
|
|
817
|
-
4. **Swap Behavior** - Items swap positions on drop
|
|
818
|
-
5. **Empty Cells** - Non-draggable placeholder cells
|
|
819
|
-
6. **Visual Feedback** - Opacity changes, hover states, and scaling
|
|
820
|
-
7. **TypeScript Types** - Full type safety with generics
|
|
821
|
-
|
|
822
|
-
### DragDropManager Features Used:
|
|
823
|
-
|
|
824
|
-
- `data-empty` convention - Elements with `data-empty` are automatically treated as non-draggable
|
|
825
|
-
- `getItemPosition` - Extracts grid coordinates from data attributes
|
|
826
|
-
- `getItemData` - Retrieves item data from grid state
|
|
827
|
-
- `onDragStart` - Shows preview and updates source appearance
|
|
828
|
-
- Uses `getBoundingClientRect()` to copy element dimensions to preview
|
|
829
|
-
- `onDragMove` - Updates preview position in real-time
|
|
830
|
-
- `onDrop` - Swaps items and updates state
|
|
831
|
-
- `onDragEnd` - Hides preview
|
|
832
|
-
- `onClick` - Handles click/tap events
|
|
833
|
-
|
|
834
|
-
### Configuration:
|
|
835
|
-
|
|
836
|
-
- `draggableKind` / `droppableKind` - Accept `string` or `string[]` to match one or many kinds. Each
|
|
837
|
-
element should still expose a single `data-kind` value.
|
|
838
|
-
- `dragThreshold: 10` - Prevents accidental drags
|
|
839
|
-
- `clickThreshold: 10` - Distinguishes clicks from drags
|
|
840
|
-
- `scrollThreshold: 100` - Auto-scroll near viewport edges
|
|
841
|
-
- `scrollSpeed: 10` - Smooth scroll speed
|
|
842
|
-
- `cancelOnEscape: true` - Cancels active drag when Escape is pressed
|
|
843
|
-
- `cancelOnPointerLeave: true` - Cancels active drag if pointer leaves the window
|
|
279
|
+
```typescript
|
|
280
|
+
const live = document.querySelector('#dnd-status')! // <div id="dnd-status" aria-live="assertive">
|
|
844
281
|
|
|
845
|
-
|
|
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
|
+
```
|
|
846
294
|
|
|
847
|
-
|
|
295
|
+
---
|
|
848
296
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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.
|