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/README.md CHANGED
@@ -1,18 +1,24 @@
1
1
  # dnd-manager
2
2
 
3
- Simple, performant, and data-driven Drag-and-Drop library for vanilla JS, compatible with any
4
- framework, and designed for developers who want full control over drag-and-drop UX without being
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
- This document provides practical examples of using the library in both vanilla JavaScript and React
8
- 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
+ ---
9
10
 
10
11
  ## Table of Contents
11
12
 
12
- 1. [Installation](#installation)
13
- 2. [Clone Preview Helper](#clone-preview-helper)
14
- 3. [Vanilla JavaScript Example](#vanilla-javascript-example)
15
- 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)
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
- ## Clone Preview Helper
34
+ ## Quick start
35
35
 
36
- Use `new DragPreviewController()` to get a clone of the dragged element that follows the cursor with
37
- `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.
38
39
 
39
40
  ```typescript
40
- import {
41
- DragPreviewController,
42
- DragDropManager,
43
- type DragDropCallbacks,
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: (element) => {
58
- const raw = element.dataset.index
59
- if (!raw) return null
60
- const index = Number.parseInt(raw, 10)
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
- getItemData: (_element, pos) => ({ index: pos.index }),
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
- onDragMove: (pos: PointerPosition) => {
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
- // Important cleanup for unmount/page teardown.
89
- function cleanup() {
90
- preview.destroy()
91
- manager.destroy()
92
- }
67
+ // On teardown:
68
+ manager.destroy()
93
69
  ```
94
70
 
95
- This helper uses the clone-in-`document.body` pattern to avoid drift in scrolled containers and
96
- 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).
97
73
 
98
74
  ---
99
75
 
100
- ## Vanilla JavaScript Example
76
+ ## Clone preview helper
101
77
 
102
- This example creates a draggable grid with cells that can be reordered. Clicking a cell logs its
103
- 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.
104
80
 
105
- **HTML:**
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
- ```html
108
- <!DOCTYPE html>
109
- <html lang="en">
110
- <head>
111
- <meta charset="UTF-8" />
112
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
113
- <title>Vanilla JS DragDrop Grid</title>
114
- <style>
115
- body {
116
- margin: 0;
117
- padding: 20px;
118
- font-family:
119
- system-ui,
120
- -apple-system,
121
- sans-serif;
122
- background: #f9fafb;
123
- }
124
-
125
- .grid {
126
- display: grid;
127
- grid-template-columns: repeat(3, 120px);
128
- gap: 8px;
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
- **TypeScript (app.ts):**
113
+ ---
285
114
 
286
- ```typescript
287
- import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
115
+ ## Vanilla JS
288
116
 
289
- // Types
290
- type GridItem = {
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
- type GridPosition = {
297
- row: number
298
- col: number
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
- // Initialize
302
- function initDragDropGrid(): void {
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
- if (!container || !preview) return
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
- // Setup DragDropManager callbacks
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
- // Create DragDropManager instance
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
- // Cleanup on page unload
433
- window.addEventListener('beforeunload', () => {
434
- dragDropManager.destroy()
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 Example
159
+ ## React
445
160
 
446
- This example shows the same draggable grid implemented as a React component with TypeScript.
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
- import { useEffect, useRef, useState } from 'react'
450
- import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
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
- // Types
453
- type GridItem = {
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
- type GridPosition = {
460
- row: number
461
- col: number
462
- }
188
+ ---
463
189
 
464
- // Initial data
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
- return (
510
- <div
511
- data-kind="cell"
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
- // Drag preview component
522
- type DragPreviewProps = {
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
- function DragPreview({ item, position, width, height }: DragPreviewProps) {
530
- if (!item || !position) return null
531
-
532
- return (
533
- <div
534
- className="drag-preview"
535
- style={{
536
- left: position.x,
537
- top: position.y,
538
- background: item.color,
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
- // Main component
549
- export function DraggableGrid() {
550
- const containerRef = useRef<HTMLDivElement>(null)
551
- const [gridData, setGridData] = useState<(GridItem | null)[][]>(INITIAL_DATA)
552
- const [dragPreview, setDragPreview] = useState<{
553
- item: GridItem | null
554
- position: { x: number; y: number } | null
555
- width?: number
556
- height?: number
557
- }>({ item: null, position: null })
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
- **CSS (Tailwind or plain CSS):**
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
- ```css
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
- /* Grid */
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
- /* Grid cells */
695
- .grid-cell {
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
- .grid-cell--filled {
710
- padding: 20px;
711
- color: white;
712
- cursor: grab;
713
- min-height: 80px;
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
- .grid-cell--empty {
717
- background: #f3f4f6;
718
- border: 2px dashed #d1d5db;
719
- min-height: 80px;
720
- }
243
+ **Vanilla:** update `canDrag` when state changes:
721
244
 
722
- /* Dragging state */
723
- .grid-cell[data-dragging='true'] {
724
- opacity: 0.4;
725
- cursor: grabbing !important;
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
- /* Hover state */
729
- .grid-cell[data-hovered='true'] {
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
- /* Drag preview */
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
- **With Tailwind CSS:**
259
+ ## Keyboard
750
260
 
751
- ```typescript
752
- // Update cell component to use Tailwind classes
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
- return (
775
- <div
776
- data-kind="cell"
777
- data-row={row}
778
- data-col={col}
779
- data-empty="true"
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
- // Drag preview with Tailwind
787
- function DragPreview({ item, position, width, height }: DragPreviewProps) {
788
- if (!item || !position) return null
789
-
790
- return (
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
- ## Covered Features
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
- ## Notes
295
+ ---
848
296
 
849
- - **Performance**: The manager uses `requestAnimationFrame` for smooth updates at 60fps
850
- - **Mobile-Friendly**: Uses pointer events which work with touch, mouse, and pen
851
- - **Accessibility**: Uses data attributes that don't interfere with screen readers
852
- - **React Integration**: Manager is created in `useEffect` with proper cleanup
853
- - **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.