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/README.md CHANGED
@@ -1,17 +1,24 @@
1
1
  # dnd-manager
2
2
 
3
- Simple, performant, and data-driven Drag-and-Drop library for vanilla JS, and compatible with any
4
- framework.
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
- This document provides practical examples of using the library in both vanilla JavaScript and
7
- React applications.
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
- 1. [Installation](#installation)
12
- 2. [Clone Preview Helper](#clone-preview-helper)
13
- 3. [Vanilla JavaScript Example](#vanilla-javascript-example)
14
- 4. [React Example](#react-example)
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
- ## Clone Preview Helper
34
+ ## Quick start
34
35
 
35
- Use `new DragPreviewController()` to get a clone of the dragged element that follows the cursor with
36
- `position: fixed`, `pointer-events: none`, and transform-based movement.
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
- getItemData: (_element, pos) => ({ index: pos.index }),
43
+ type Item = { id: string; name: string }
44
+ type Position = { row: number; col: number }
64
45
 
65
- onDragStart: (element) => {
66
- preview.startFromElement(element)
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
- onDragMove: (pos: PointerPosition) => {
70
- preview.moveToPointer(pos)
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
- onDragEnd: () => {
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
- // Important cleanup for unmount/page teardown.
88
- function cleanup() {
89
- preview.destroy()
90
- manager.destroy()
91
- }
67
+ // On teardown:
68
+ manager.destroy()
92
69
  ```
93
70
 
94
- This helper uses the clone-in-`document.body` pattern to avoid drift in scrolled containers and
95
- keeps cursor tracking smooth via transform updates.
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
- ## Vanilla JavaScript Example
76
+ ## Clone preview helper
100
77
 
101
- This example creates a draggable grid with cells that can be reordered. Clicking a cell logs its
102
- data to the console, and dragging shows a preview element following the cursor.
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
- **HTML:**
105
-
106
- ```html
107
- <!DOCTYPE html>
108
- <html lang="en">
109
- <head>
110
- <meta charset="UTF-8" />
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
- // Types
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
- type GridPosition = {
296
- row: number
297
- col: number
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
- // Initialize
301
- function initDragDropGrid(): void {
302
- const container = document.getElementById('grid-container')
303
- const preview = document.getElementById('drag-preview')
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
- // Start when DOM is ready
438
- document.addEventListener('DOMContentLoaded', initDragDropGrid)
108
+ // Cleanup:
109
+ preview.destroy()
110
+ manager.destroy()
439
111
  ```
440
112
 
441
113
  ---
442
114
 
443
- ## React Example
115
+ ## Vanilla JS
444
116
 
445
- This example shows the same draggable grid implemented as a React component with TypeScript.
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
- ```typescript
448
- import { useEffect, useRef, useState } from 'react'
449
- import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
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
- // Types
452
- type GridItem = {
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
- type GridPosition = {
459
- row: number
460
- col: number
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
- // Initial data
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
- return (
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
- // Drag preview component
521
- type DragPreviewProps = {
522
- item: GridItem | null
523
- position: { x: number; y: number } | null
524
- width?: number
525
- height?: number
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
- function DragPreview({ item, position, width, height }: DragPreviewProps) {
529
- if (!item || !position) return null
530
-
531
- return (
532
- <div
533
- className="drag-preview"
534
- style={{
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
- // Main component
548
- export function DraggableGrid() {
549
- const containerRef = useRef<HTMLDivElement>(null)
550
- const [gridData, setGridData] = useState<(GridItem | null)[][]>(INITIAL_DATA)
551
- const [dragPreview, setDragPreview] = useState<{
552
- item: GridItem | null
553
- position: { x: number; y: number } | null
554
- width?: number
555
- height?: number
556
- }>({ item: null, position: null })
557
-
558
- useEffect(() => {
559
- if (!containerRef.current) return
560
-
561
- // Setup callbacks
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
- **CSS (Tailwind or plain CSS):**
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
- ```css
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
- /* Grid */
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
- /* Grid cells */
694
- .grid-cell {
695
- display: flex;
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
- .grid-cell--filled {
709
- padding: 20px;
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
- .grid-cell--empty {
716
- background: #f3f4f6;
717
- border: 2px dashed #d1d5db;
718
- min-height: 80px;
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
- /* Dragging state */
722
- .grid-cell[data-dragging='true'] {
723
- opacity: 0.4;
724
- cursor: grabbing !important;
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
- /* Hover state */
728
- .grid-cell[data-hovered='true'] {
729
- transform: scale(1.05);
730
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
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
- /* Drag preview */
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
- **With Tailwind CSS:**
231
+ ## Dynamic drag ability
749
232
 
750
- ```typescript
751
- // Update cell component to use Tailwind classes
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
- return (
774
- <div
775
- data-kind="cell"
776
- data-row={row}
777
- data-col={col}
778
- data-empty="true"
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
- // Drag preview with Tailwind
786
- function DragPreview({ item, position, width, height }: DragPreviewProps) {
787
- if (!item || !position) return null
788
-
789
- return (
790
- <div
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
- ## Covered Features
259
+ ## Keyboard
810
260
 
811
- ### Both Examples Include:
261
+ `keyboard: true` adds pick-and-place for keyboard users. Draggables must be focusable (a `<button>`,
262
+ or `tabindex="0"`).
812
263
 
813
- 1. **Grid Layout** - 3x3 grid with draggable cells
814
- 2. **Click Detection** - Logs item data to console on click
815
- 3. **Drag Preview** - Visual feedback following cursor during drag, matching original element size
816
- 4. **Swap Behavior** - Items swap positions on drop
817
- 5. **Empty Cells** - Non-draggable placeholder cells
818
- 6. **Visual Feedback** - Opacity changes, hover states, and scaling
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
- ### DragDropManager Features Used:
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
- - `data-empty` convention - Elements with `data-empty` are automatically treated as non-draggable
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
- ### Configuration:
279
+ ```typescript
280
+ const live = document.querySelector('#dnd-status')! // <div id="dnd-status" aria-live="assertive">
834
281
 
835
- - `dragThreshold: 10` - Prevents accidental drags
836
- - `clickThreshold: 10` - Distinguishes clicks from drags
837
- - `scrollThreshold: 100` - Auto-scroll near viewport edges
838
- - `scrollSpeed: 10` - Smooth scroll speed
839
- - `cancelOnEscape: true` - Cancels active drag when Escape is pressed
840
- - `cancelOnPointerLeave: true` - Cancels active drag if pointer leaves the window
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
- ## Notes
845
-
846
- - **Performance**: The manager uses `requestAnimationFrame` for smooth updates at 60fps
847
- - **Mobile-Friendly**: Uses pointer events which work with touch, mouse, and pen
848
- - **Accessibility**: Uses data attributes that don't interfere with screen readers
849
- - **React Integration**: Manager is created in `useEffect` with proper cleanup
850
- - **State Management**: React example uses `useState` to trigger re-renders after drops
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.