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/dist/index.mjs CHANGED
@@ -1,19 +1,142 @@
1
+ //#region src/auto-scroll.ts
2
+ const SCROLLABLE_OVERFLOW = /^(auto|scroll|overlay)$/;
3
+ /** Whether `element` may scroll on an axis: its overflow allows it and its content overflows. */
4
+ function scrollsOn(element, style, axis) {
5
+ return axis === "y" ? SCROLLABLE_OVERFLOW.test(style.overflowY) && element.scrollHeight > element.clientHeight : SCROLLABLE_OVERFLOW.test(style.overflowX) && element.scrollWidth > element.clientWidth;
6
+ }
7
+ /** The step on one axis: toward the edge the pointer is near, if the scroller can still go there. */
8
+ function stepOn(pointer, start, end, zone, speed, canGoBack, canGoForward) {
9
+ if (pointer < start + zone && canGoBack) return -speed;
10
+ if (pointer > end - zone && canGoForward) return speed;
11
+ return 0;
12
+ }
13
+ /**
14
+ * Scrolls one step toward the edge the pointer is near: the closest scroll container around
15
+ * `hit` (the element under the pointer) that can still scroll that way, or else the window.
16
+ * Returns whether anything scrolled, so the caller keeps going while the pointer rests there.
17
+ */
18
+ function autoScrollStep(pos, hit, { threshold, speed }) {
19
+ if (speed <= 0 || threshold <= 0) return false;
20
+ for (let element = hit; element; element = element.parentElement) {
21
+ if (element === document.body || element === document.documentElement) break;
22
+ const style = getComputedStyle(element);
23
+ const scrollsX = scrollsOn(element, style, "x");
24
+ const scrollsY = scrollsOn(element, style, "y");
25
+ if (!scrollsX && !scrollsY) continue;
26
+ const rect = element.getBoundingClientRect();
27
+ const left = scrollsX ? stepOn(pos.x, rect.left, rect.right, Math.min(threshold, rect.width / 4), speed, element.scrollLeft > 0, element.scrollLeft + element.clientWidth < element.scrollWidth - 1) : 0;
28
+ const top = scrollsY ? stepOn(pos.y, rect.top, rect.bottom, Math.min(threshold, rect.height / 4), speed, element.scrollTop > 0, element.scrollTop + element.clientHeight < element.scrollHeight - 1) : 0;
29
+ if (left !== 0 || top !== 0) {
30
+ element.scrollBy({
31
+ left,
32
+ top,
33
+ behavior: "instant"
34
+ });
35
+ return true;
36
+ }
37
+ }
38
+ const left = stepOn(pos.x, 0, window.innerWidth, threshold, speed, true, true);
39
+ const top = stepOn(pos.y, 0, window.innerHeight, threshold, speed, true, true);
40
+ if (left === 0 && top === 0) return false;
41
+ const before = [window.scrollX, window.scrollY];
42
+ window.scrollBy({
43
+ left,
44
+ top,
45
+ behavior: "instant"
46
+ });
47
+ return window.scrollX !== before[0] || window.scrollY !== before[1];
48
+ }
49
+ //#endregion
1
50
  //#region src/drag-drop-manager.ts
2
- var DragDropManager = class {
3
- container;
4
- config;
5
- callbacks;
6
- state = {
51
+ /**
52
+ * Framework-agnostic Drag and Drop Manager
53
+ *
54
+ * A data-driven drag-and-drop utility that works with React and vanilla JS.
55
+ * Uses data attributes and direct DOM manipulation for optimal performance.
56
+ *
57
+ * @template TItem - The type of data associated with draggable items
58
+ * @template TPosition - The type representing position/coordinates (e.g., {x, y}, {box, cell}, etc.)
59
+ */
60
+ /**
61
+ * Presses a manager took: with nested containers the inner manager hears the `pointerdown` first
62
+ * and takes it, and the outer ones leave it alone.
63
+ */
64
+ const claimedPresses = /* @__PURE__ */ new WeakSet();
65
+ /** The pointer events of a press that a detached source still receives (see handlePointerDown). */
66
+ const SOURCE_POINTER_EVENTS = [
67
+ "pointermove",
68
+ "pointerup",
69
+ "pointercancel"
70
+ ];
71
+ /** Moves the boolean attribute `name` from `from` to `to`, and returns `to`. */
72
+ function moveMark(name, from, to) {
73
+ if (from === to) return to;
74
+ from?.removeAttribute(name);
75
+ to?.setAttribute(name, "true");
76
+ return to;
77
+ }
78
+ function idleState() {
79
+ return {
7
80
  startPosition: null,
8
81
  sourceElement: null,
9
82
  sourcePosition: null,
10
83
  sourceItem: null,
11
84
  activePointerId: null,
85
+ input: null,
12
86
  isDragging: false,
13
- lastHoveredElement: null
87
+ lastHoveredElement: null,
88
+ lastInvalidElement: null,
89
+ lastDropResult: null
14
90
  };
91
+ }
92
+ const centerOf = (rect) => ({
93
+ x: rect.left + rect.width / 2,
94
+ y: rect.top + rect.height / 2
95
+ });
96
+ const ARROWS = {
97
+ ArrowLeft: {
98
+ axis: "x",
99
+ sign: -1
100
+ },
101
+ ArrowRight: {
102
+ axis: "x",
103
+ sign: 1
104
+ },
105
+ ArrowUp: {
106
+ axis: "y",
107
+ sign: -1
108
+ },
109
+ ArrowDown: {
110
+ axis: "y",
111
+ sign: 1
112
+ }
113
+ };
114
+ const isActionKey = (e) => (e.key === " " || e.key === "Enter") && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
115
+ var DragDropManager = class {
116
+ container;
117
+ config;
118
+ draggableKinds;
119
+ droppableKinds;
120
+ callbacks;
121
+ state = idleState();
15
122
  rafId = null;
16
123
  lastPointerPos = null;
124
+ /**
125
+ * Set while a drag holds the pointer down: the browser follows the release with a native
126
+ * `click` (on the source element under pointer capture), which must not activate it.
127
+ */
128
+ suppressClickOnRelease = false;
129
+ releaseClickBlocker = null;
130
+ /** The pending `dragDelay` of the current press. */
131
+ delayTimer = null;
132
+ activePointerType = "";
133
+ /** The droppable a keyboard drag points at. */
134
+ keyboardTarget = null;
135
+ /**
136
+ * The key that picked up or dropped: its `keyup` would activate a focused button (Space clicks
137
+ * on keyup), so it is swallowed.
138
+ */
139
+ swallowKeyUp = null;
17
140
  boundHandlers;
18
141
  constructor(containerRef, config, callbacks) {
19
142
  if (typeof containerRef === "string") {
@@ -26,14 +149,21 @@ var DragDropManager = class {
26
149
  } else this.container = containerRef;
27
150
  this.config = {
28
151
  dragThreshold: config.dragThreshold ?? 10,
152
+ dragDelay: config.dragDelay ?? 0,
153
+ dragDelayTolerance: config.dragDelayTolerance ?? 5,
29
154
  clickThreshold: config.clickThreshold ?? 10,
30
155
  scrollThreshold: config.scrollThreshold ?? 100,
31
156
  scrollSpeed: config.scrollSpeed ?? 10,
32
157
  cancelOnEscape: config.cancelOnEscape ?? true,
33
158
  cancelOnPointerLeave: config.cancelOnPointerLeave ?? true,
34
159
  draggableKind: config.draggableKind,
35
- droppableKind: config.droppableKind
160
+ droppableKind: config.droppableKind,
161
+ dropScope: config.dropScope ?? "document",
162
+ keyboard: config.keyboard ?? false,
163
+ focusOnPress: config.focusOnPress ?? false
36
164
  };
165
+ this.draggableKinds = this.normalizeKindList(this.config.draggableKind);
166
+ this.droppableKinds = this.normalizeKindList(this.config.droppableKind);
37
167
  this.callbacks = callbacks;
38
168
  this.boundHandlers = {
39
169
  pointerDown: this.handlePointerDown.bind(this),
@@ -41,43 +171,87 @@ var DragDropManager = class {
41
171
  pointerUp: this.handlePointerUp.bind(this),
42
172
  pointerCancel: this.handlePointerCancel.bind(this),
43
173
  keyDown: this.handleKeyDown.bind(this),
44
- mouseOut: this.handleMouseOut.bind(this)
174
+ mouseOut: this.handleMouseOut.bind(this),
175
+ touchMove: this.handleTouchMove.bind(this),
176
+ detachedSourcePointer: this.handleDetachedSourcePointer.bind(this),
177
+ contextMenu: this.handleContextMenu.bind(this),
178
+ preventDefault: (e) => e.preventDefault(),
179
+ pickUpKey: this.handlePickUpKey.bind(this),
180
+ keyboardDragKey: this.handleKeyboardDragKey.bind(this),
181
+ keyUp: this.handleKeyUp.bind(this),
182
+ focusOut: this.handleFocusOut.bind(this)
45
183
  };
46
184
  this.attachListeners();
47
185
  }
48
186
  attachListeners() {
49
187
  this.container.addEventListener("pointerdown", this.boundHandlers.pointerDown);
50
- this.container.addEventListener("pointermove", this.boundHandlers.pointerMove);
51
188
  this.container.addEventListener("pointerup", this.boundHandlers.pointerUp);
52
189
  this.container.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
53
190
  window.addEventListener("pointerup", this.boundHandlers.pointerUp);
54
191
  window.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
55
192
  window.addEventListener("keydown", this.boundHandlers.keyDown);
56
193
  window.addEventListener("mouseout", this.boundHandlers.mouseOut);
194
+ this.container.addEventListener("touchmove", this.boundHandlers.touchMove, { passive: false });
195
+ if (this.config.keyboard) {
196
+ this.container.addEventListener("keydown", this.boundHandlers.pickUpKey);
197
+ window.addEventListener("keydown", this.boundHandlers.keyboardDragKey, true);
198
+ window.addEventListener("keyup", this.boundHandlers.keyUp, true);
199
+ window.addEventListener("focusout", this.boundHandlers.focusOut, true);
200
+ }
201
+ }
202
+ /**
203
+ * The source position and item of the draggable `match`, if it may be dragged: it has a
204
+ * position, is not `data-empty`, `canDrag` allows it and it has item data.
205
+ */
206
+ resolveSource(match) {
207
+ const position = this.callbacks.getItemPosition(match.element, match.kind);
208
+ if (!position) return null;
209
+ if (match.element.dataset.empty !== void 0) return null;
210
+ if (this.callbacks.canDrag && !this.callbacks.canDrag(match.element, position)) return null;
211
+ const item = this.callbacks.getItemData?.(match.element, position) ?? null;
212
+ if (!item) return null;
213
+ return {
214
+ position,
215
+ item
216
+ };
57
217
  }
58
218
  handlePointerDown(e) {
59
219
  if (this.state.startPosition) return;
60
- const target = e.target;
61
- const draggableElement = this.findElementByKind(target, this.config.draggableKind);
62
- if (!draggableElement) return;
63
- const position = this.callbacks.getItemPosition(draggableElement, this.config.draggableKind);
64
- if (!position) return;
65
- if (draggableElement.dataset.empty !== void 0) return;
66
- if (this.callbacks.canDrag && !this.callbacks.canDrag(draggableElement, position)) return;
67
- const item = this.callbacks.getItemData?.(draggableElement, position) ?? null;
68
- if (!item) return;
69
- e.preventDefault();
220
+ if (e.button !== 0 || e.pointerType === "touch" && !e.isPrimary) return;
221
+ if (claimedPresses.has(e)) return;
222
+ const draggableMatch = this.findElementByKinds(e.target, this.draggableKinds);
223
+ if (!draggableMatch) return;
224
+ const source = this.resolveSource(draggableMatch);
225
+ if (!source) return;
226
+ const draggableElement = draggableMatch.element;
227
+ claimedPresses.add(e);
228
+ if (!this.config.focusOnPress) e.preventDefault();
229
+ window.addEventListener("selectstart", this.boundHandlers.preventDefault, true);
230
+ window.addEventListener("dragstart", this.boundHandlers.preventDefault, true);
70
231
  this.state.startPosition = {
71
232
  x: e.clientX,
72
233
  y: e.clientY
73
234
  };
74
235
  this.state.sourceElement = draggableElement;
75
- this.state.sourcePosition = position;
76
- this.state.sourceItem = item;
236
+ this.state.sourcePosition = source.position;
237
+ this.state.sourceItem = source.item;
238
+ this.state.input = "pointer";
77
239
  this.state.activePointerId = this.getPointerId(e);
240
+ this.activePointerType = e.pointerType ?? "";
241
+ this.lastPointerPos = this.state.startPosition;
78
242
  if (this.state.activePointerId !== null && typeof draggableElement.setPointerCapture === "function") try {
79
243
  draggableElement.setPointerCapture(this.state.activePointerId);
80
244
  } catch {}
245
+ window.addEventListener("pointermove", this.boundHandlers.pointerMove);
246
+ draggableElement.addEventListener("touchmove", this.boundHandlers.touchMove, { passive: false });
247
+ for (const type of SOURCE_POINTER_EVENTS) draggableElement.addEventListener(type, this.boundHandlers.detachedSourcePointer);
248
+ window.addEventListener("contextmenu", this.boundHandlers.contextMenu);
249
+ const delay = this.delayFor(this.activePointerType);
250
+ if (delay > 0) this.delayTimer = setTimeout(() => {
251
+ this.delayTimer = null;
252
+ this.startDrag();
253
+ this.scheduleFrame();
254
+ }, delay);
81
255
  }
82
256
  handlePointerMove(e) {
83
257
  if (!this.isActivePointer(e)) return;
@@ -86,36 +260,82 @@ var DragDropManager = class {
86
260
  y: e.clientY
87
261
  };
88
262
  if (this.state.startPosition && !this.state.isDragging) {
89
- if (this.getDistance(this.state.startPosition, currentPos) >= this.config.dragThreshold) {
90
- this.state.isDragging = true;
91
- if (this.state.sourceElement && this.state.sourcePosition && this.state.sourceItem) {
92
- this.state.sourceElement.setAttribute("data-dragging", "true");
93
- this.callbacks.onDragStart?.(this.state.sourceElement, this.state.sourcePosition, this.state.sourceItem);
94
- }
263
+ const distance = this.getDistance(this.state.startPosition, currentPos);
264
+ if (this.delayTimer !== null) {
265
+ if (distance > this.config.dragDelayTolerance) this.cleanup();
266
+ else this.lastPointerPos = currentPos;
267
+ return;
95
268
  }
269
+ if (distance >= this.config.dragThreshold) this.startDrag();
96
270
  return;
97
271
  }
98
272
  if (!this.state.isDragging) return;
99
273
  this.lastPointerPos = currentPos;
100
- if (this.rafId === null) this.rafId = requestAnimationFrame(() => {
274
+ this.scheduleFrame();
275
+ }
276
+ startDrag() {
277
+ this.state.isDragging = true;
278
+ this.suppressClickOnRelease = this.state.input === "pointer";
279
+ if (this.state.sourceElement && this.state.sourcePosition && this.state.sourceItem) {
280
+ this.state.sourceElement.setAttribute("data-dragging", "true");
281
+ this.callbacks.onDragStart?.(this.state.sourceElement, this.state.sourcePosition, this.state.sourceItem);
282
+ }
283
+ }
284
+ /** The `dragDelay` for a pointer type (`''` from a constructed event counts as a mouse). */
285
+ delayFor(pointerType) {
286
+ const { dragDelay } = this.config;
287
+ if (typeof dragDelay === "number") return dragDelay;
288
+ return dragDelay[pointerType === "touch" || pointerType === "pen" ? pointerType : "mouse"] ?? 0;
289
+ }
290
+ /**
291
+ * Once a touch press is a drag, the finger moves the item, not the page: a browser that starts
292
+ * panning would cancel the pointer. Before that, touches stay the browser's.
293
+ */
294
+ handleTouchMove(e) {
295
+ if (this.state.isDragging && e.cancelable) e.preventDefault();
296
+ }
297
+ /**
298
+ * The press's pointer events, heard on the source itself. While it is in the DOM they also reach
299
+ * the window's listeners, which handle them; once it is detached only these hear them.
300
+ */
301
+ handleDetachedSourcePointer(e) {
302
+ if (this.state.sourceElement?.isConnected !== false) return;
303
+ if (e.type === "pointermove") this.handlePointerMove(e);
304
+ else if (e.type === "pointerup") this.handlePointerUp(e);
305
+ else this.handlePointerCancel();
306
+ }
307
+ /** A long press opens no context menu (Android, iOS) while it is a drag or about to be one. */
308
+ handleContextMenu(e) {
309
+ const pendingTouch = this.delayTimer !== null && this.activePointerType === "touch";
310
+ if (this.state.isDragging || pendingTouch) e.preventDefault();
311
+ }
312
+ scheduleFrame() {
313
+ if (this.rafId !== null) return;
314
+ this.rafId = requestAnimationFrame(() => {
101
315
  this.rafId = null;
102
- if (!this.lastPointerPos) return;
103
- const pos = this.lastPointerPos;
104
- const elementUnderCursor = document.elementFromPoint(pos.x, pos.y);
105
- const droppableElement = this.findElementByKind(elementUnderCursor, this.config.droppableKind);
106
- if (droppableElement !== this.state.lastHoveredElement) {
107
- if (this.state.lastHoveredElement) this.state.lastHoveredElement.removeAttribute("data-hovered");
108
- if (droppableElement) {
109
- droppableElement.setAttribute("data-hovered", "true");
110
- this.state.lastHoveredElement = droppableElement;
111
- } else this.state.lastHoveredElement = null;
112
- }
113
- this.handleAutoScroll(pos);
114
- this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement);
316
+ this.runFrame();
317
+ });
318
+ }
319
+ /** One drag frame: hover feedback, auto-scroll and `onDragMove` at the latest pointer position. */
320
+ runFrame() {
321
+ const pos = this.lastPointerPos;
322
+ if (!pos || !this.state.isDragging) return;
323
+ const elementUnderCursor = document.elementFromPoint(pos.x, pos.y);
324
+ this.markTarget(this.findDroppable(elementUnderCursor));
325
+ const scrolled = autoScrollStep(pos, elementUnderCursor, {
326
+ threshold: this.config.scrollThreshold,
327
+ speed: this.config.scrollSpeed
115
328
  });
329
+ this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement);
330
+ if (scrolled) this.scheduleFrame();
116
331
  }
117
332
  handlePointerUp(e) {
333
+ if (this.state.input === "keyboard") return;
118
334
  if (!this.isActivePointer(e)) return;
335
+ if (this.suppressClickOnRelease) {
336
+ this.suppressClickOnRelease = false;
337
+ this.blockNextClick();
338
+ }
119
339
  if (this.state.startPosition && !this.state.isDragging) {
120
340
  const currentPos = {
121
341
  x: e.clientX,
@@ -130,14 +350,24 @@ var DragDropManager = class {
130
350
  return;
131
351
  }
132
352
  const target = document.elementFromPoint(e.clientX, e.clientY);
133
- const droppableElement = this.findElementByKind(target, this.config.droppableKind);
134
- if (droppableElement) {
135
- const targetPosition = this.callbacks.getItemPosition(droppableElement, this.config.droppableKind);
136
- if (targetPosition) this.callbacks.onDrop?.(this.state.sourcePosition, targetPosition, this.state.sourceItem);
353
+ const droppableMatch = this.findDroppable(target);
354
+ if ((droppableMatch?.element ?? null) && droppableMatch) {
355
+ const targetPosition = this.acceptedPosition(droppableMatch);
356
+ if (targetPosition) {
357
+ const result = {
358
+ sourcePosition: this.state.sourcePosition,
359
+ targetPosition,
360
+ sourceItem: this.state.sourceItem
361
+ };
362
+ this.state.lastDropResult = result;
363
+ this.callbacks.onDrop?.(result.sourcePosition, result.targetPosition, result.sourceItem);
364
+ }
137
365
  }
138
366
  this.cleanup();
139
367
  }
140
368
  handlePointerCancel() {
369
+ if (this.state.input === "keyboard") return;
370
+ this.suppressClickOnRelease = false;
141
371
  this.cleanup();
142
372
  }
143
373
  handleKeyDown(e) {
@@ -147,44 +377,162 @@ var DragDropManager = class {
147
377
  this.cleanup();
148
378
  }
149
379
  handleMouseOut(e) {
150
- if (!this.config.cancelOnPointerLeave) return;
151
- if (!this.state.startPosition) return;
152
380
  if (e.relatedTarget !== null) return;
381
+ this.suppressClickOnRelease = false;
382
+ if (!this.config.cancelOnPointerLeave) return;
383
+ if (this.state.input !== "pointer") return;
384
+ this.cleanup();
385
+ }
386
+ /** Space or Enter on a focused draggable picks it up (`keyboard: true`). */
387
+ handlePickUpKey(e) {
388
+ if (this.state.startPosition || e.repeat || !isActionKey(e) || claimedPresses.has(e)) return;
389
+ const match = this.findElementByKinds(e.target, this.draggableKinds);
390
+ if (!match) return;
391
+ const source = this.resolveSource(match);
392
+ if (!source) return;
393
+ claimedPresses.add(e);
394
+ e.preventDefault();
395
+ this.swallowKeyUp = e.key;
396
+ this.state.startPosition = centerOf(match.element.getBoundingClientRect());
397
+ this.state.sourceElement = match.element;
398
+ this.state.sourcePosition = source.position;
399
+ this.state.sourceItem = source.item;
400
+ this.state.input = "keyboard";
401
+ this.startDrag();
402
+ this.pointKeyboardAt(this.findDroppable(match.element));
403
+ }
404
+ /** The keys of a keyboard drag, ahead of anything else on the page. */
405
+ handleKeyboardDragKey(e) {
406
+ if (this.state.input !== "keyboard") {
407
+ if (!e.repeat) this.swallowKeyUp = null;
408
+ return;
409
+ }
410
+ const arrow = ARROWS[e.key];
411
+ if (arrow) this.pointKeyboardAt(this.nearestDroppable(arrow.axis, arrow.sign) ?? this.keyboardTarget);
412
+ else if (isActionKey(e)) {
413
+ this.swallowKeyUp = e.key;
414
+ if (!e.repeat) this.dropWithKeyboard();
415
+ } else if (e.key === "Escape") this.cleanup();
416
+ else return;
417
+ e.preventDefault();
418
+ e.stopPropagation();
419
+ }
420
+ handleKeyUp(e) {
421
+ if (this.swallowKeyUp === null || e.key !== this.swallowKeyUp) return;
422
+ this.swallowKeyUp = null;
423
+ e.preventDefault();
424
+ e.stopPropagation();
425
+ }
426
+ /** Focus leaving the source (Tab, a click elsewhere) cancels a keyboard drag. */
427
+ handleFocusOut(e) {
428
+ const source = this.state.sourceElement;
429
+ if (this.state.input !== "keyboard" || !source) return;
430
+ if (!source.contains(e.target)) return;
431
+ if (source.contains(e.relatedTarget)) return;
153
432
  this.cleanup();
154
433
  }
155
- handleAutoScroll(pos) {
156
- const { scrollThreshold, scrollSpeed } = this.config;
157
- const viewportWidth = window.innerWidth;
158
- const viewportHeight = window.innerHeight;
159
- let scrollX = 0;
160
- let scrollY = 0;
161
- if (pos.x < scrollThreshold) scrollX = -scrollSpeed;
162
- else if (pos.x > viewportWidth - scrollThreshold) scrollX = scrollSpeed;
163
- if (pos.y < scrollThreshold) scrollY = -scrollSpeed;
164
- else if (pos.y > viewportHeight - scrollThreshold) scrollY = scrollSpeed;
165
- if (scrollX !== 0 || scrollY !== 0) window.scrollBy(scrollX, scrollY);
434
+ /** Makes `match` the keyboard drag's target: marked, scrolled into view, sent to `onDragMove`. */
435
+ pointKeyboardAt(match) {
436
+ this.keyboardTarget = match;
437
+ this.markTarget(match);
438
+ if (!match) return;
439
+ match.element.scrollIntoView?.({
440
+ block: "nearest",
441
+ inline: "nearest"
442
+ });
443
+ this.callbacks.onDragMove?.(centerOf(match.element.getBoundingClientRect()), this.state.lastHoveredElement);
444
+ }
445
+ /**
446
+ * The droppable nearest the current target (or the source) in a direction: its center lies that
447
+ * way, and it is closest along the axis, with distance across the axis counting double, so the
448
+ * next cell in the same row or column wins over a diagonal one.
449
+ */
450
+ nearestDroppable(axis, sign) {
451
+ const from = this.keyboardTarget?.element ?? this.state.sourceElement;
452
+ if (!from) return null;
453
+ const origin = centerOf(from.getBoundingClientRect());
454
+ const root = this.config.dropScope === "container" ? this.container : document;
455
+ let best = null;
456
+ let bestScore = Infinity;
457
+ for (const element of root.querySelectorAll("[data-kind]")) {
458
+ const kind = element.dataset.kind;
459
+ if (!kind || element === from || !this.droppableKinds.includes(kind)) continue;
460
+ const rect = element.getBoundingClientRect();
461
+ if (rect.width === 0 && rect.height === 0) continue;
462
+ const center = centerOf(rect);
463
+ const along = (axis === "x" ? center.x - origin.x : center.y - origin.y) * sign;
464
+ const across = Math.abs(axis === "x" ? center.y - origin.y : center.x - origin.x);
465
+ if (along < 1) continue;
466
+ const score = along + across * 2;
467
+ if (score < bestScore) {
468
+ best = {
469
+ element,
470
+ kind
471
+ };
472
+ bestScore = score;
473
+ }
474
+ }
475
+ return best;
476
+ }
477
+ /** Drops a keyboard drag on its target, unless there is none or `canDrop` rejects it. */
478
+ dropWithKeyboard() {
479
+ const target = this.keyboardTarget;
480
+ const targetPosition = target && this.acceptedPosition(target);
481
+ const { sourcePosition, sourceItem } = this.state;
482
+ if (!targetPosition || sourcePosition === null || sourceItem === null) return;
483
+ this.state.lastDropResult = {
484
+ sourcePosition,
485
+ targetPosition,
486
+ sourceItem
487
+ };
488
+ this.callbacks.onDrop?.(sourcePosition, targetPosition, sourceItem);
489
+ this.cleanup();
490
+ }
491
+ /**
492
+ * Swallows the `click` the browser dispatches right after the release that ended a drag, in the
493
+ * same task; a timeout lets the blocker go when no click comes.
494
+ */
495
+ blockNextClick() {
496
+ this.releaseClickBlocker?.();
497
+ const block = (event) => {
498
+ event.preventDefault();
499
+ event.stopImmediatePropagation();
500
+ release();
501
+ };
502
+ const release = () => {
503
+ window.removeEventListener("click", block, true);
504
+ clearTimeout(timer);
505
+ if (this.releaseClickBlocker === release) this.releaseClickBlocker = null;
506
+ };
507
+ window.addEventListener("click", block, true);
508
+ const timer = setTimeout(release, 0);
509
+ this.releaseClickBlocker = release;
166
510
  }
167
511
  cleanup() {
512
+ window.removeEventListener("pointermove", this.boundHandlers.pointerMove);
513
+ this.state.sourceElement?.removeEventListener("touchmove", this.boundHandlers.touchMove);
514
+ for (const type of SOURCE_POINTER_EVENTS) this.state.sourceElement?.removeEventListener(type, this.boundHandlers.detachedSourcePointer);
515
+ window.removeEventListener("contextmenu", this.boundHandlers.contextMenu);
516
+ window.removeEventListener("selectstart", this.boundHandlers.preventDefault, true);
517
+ window.removeEventListener("dragstart", this.boundHandlers.preventDefault, true);
518
+ if (this.delayTimer !== null) {
519
+ clearTimeout(this.delayTimer);
520
+ this.delayTimer = null;
521
+ }
522
+ this.activePointerType = "";
168
523
  if (this.state.sourceElement && this.state.activePointerId !== null && typeof this.state.sourceElement.hasPointerCapture === "function" && typeof this.state.sourceElement.releasePointerCapture === "function" && this.state.sourceElement.hasPointerCapture(this.state.activePointerId)) try {
169
524
  this.state.sourceElement.releasePointerCapture(this.state.activePointerId);
170
525
  } catch {}
171
526
  if (this.state.sourceElement) this.state.sourceElement.removeAttribute("data-dragging");
172
- if (this.state.lastHoveredElement) this.state.lastHoveredElement.removeAttribute("data-hovered");
527
+ this.markTarget(null);
173
528
  if (this.rafId !== null) {
174
529
  cancelAnimationFrame(this.rafId);
175
530
  this.rafId = null;
176
531
  }
177
- if (this.state.isDragging) this.callbacks.onDragEnd?.();
178
- this.state = {
179
- startPosition: null,
180
- sourceElement: null,
181
- sourcePosition: null,
182
- sourceItem: null,
183
- activePointerId: null,
184
- isDragging: false,
185
- lastHoveredElement: null
186
- };
532
+ if (this.state.isDragging) this.callbacks.onDragEnd?.(this.state.lastDropResult);
533
+ this.state = idleState();
187
534
  this.lastPointerPos = null;
535
+ this.keyboardTarget = null;
188
536
  }
189
537
  getPointerId(e) {
190
538
  return typeof e.pointerId === "number" ? e.pointerId : null;
@@ -194,13 +542,44 @@ var DragDropManager = class {
194
542
  const pointerId = this.getPointerId(e);
195
543
  return pointerId === null || pointerId === this.state.activePointerId;
196
544
  }
545
+ normalizeKindList(kinds) {
546
+ return [...new Set((Array.isArray(kinds) ? kinds : [kinds]).map((value) => value.trim()).filter(Boolean))];
547
+ }
548
+ /** The target position of the droppable `match` if the current source may drop there. */
549
+ acceptedPosition(match) {
550
+ const { sourcePosition, sourceItem } = this.state;
551
+ const targetPosition = this.callbacks.getItemPosition(match.element, match.kind);
552
+ if (targetPosition === null || sourcePosition === null || sourceItem === null) return null;
553
+ if (this.callbacks.canDrop && !this.callbacks.canDrop(sourcePosition, targetPosition, sourceItem)) return null;
554
+ return targetPosition;
555
+ }
197
556
  /**
198
- * Find closest ancestor element with matching data-kind attribute
557
+ * Marks the droppable under the pointer: `data-hovered` when it would receive a drop (also the
558
+ * source itself, for "drag over itself" styling), `data-drop-invalid` when `canDrop` rejects it.
559
+ * Without `canDrop` every droppable is marked hovered.
199
560
  */
200
- findElementByKind(element, kind) {
561
+ markTarget(match) {
562
+ const accepted = match && (!this.callbacks.canDrop || this.acceptedPosition(match) !== null) ? match.element : null;
563
+ const rejected = match && !accepted ? match.element : null;
564
+ this.state.lastHoveredElement = moveMark("data-hovered", this.state.lastHoveredElement, accepted);
565
+ this.state.lastInvalidElement = moveMark("data-drop-invalid", this.state.lastInvalidElement, rejected);
566
+ }
567
+ /** The droppable around `element` (the one under the pointer), within `dropScope`. */
568
+ findDroppable(element) {
569
+ if (this.config.dropScope === "container" && !(element && this.container.contains(element))) return null;
570
+ return this.findElementByKinds(element, this.droppableKinds);
571
+ }
572
+ /**
573
+ * Find closest ancestor element with a `data-kind` matching any expected kind.
574
+ */
575
+ findElementByKinds(element, kinds) {
201
576
  let current = element;
202
577
  while (current && current !== this.container) {
203
- if (current.dataset.kind === kind) return current;
578
+ const elementKind = current.dataset.kind;
579
+ if (elementKind && kinds.includes(elementKind)) return {
580
+ element: current,
581
+ kind: elementKind
582
+ };
204
583
  current = current.parentElement;
205
584
  }
206
585
  return null;
@@ -228,20 +607,43 @@ var DragDropManager = class {
228
607
  */
229
608
  destroy() {
230
609
  this.cleanup();
610
+ this.suppressClickOnRelease = false;
611
+ this.releaseClickBlocker?.();
231
612
  this.container.removeEventListener("pointerdown", this.boundHandlers.pointerDown);
232
- this.container.removeEventListener("pointermove", this.boundHandlers.pointerMove);
233
613
  this.container.removeEventListener("pointerup", this.boundHandlers.pointerUp);
234
614
  this.container.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
615
+ this.container.removeEventListener("touchmove", this.boundHandlers.touchMove);
235
616
  window.removeEventListener("pointerup", this.boundHandlers.pointerUp);
236
617
  window.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
237
618
  window.removeEventListener("keydown", this.boundHandlers.keyDown);
238
619
  window.removeEventListener("mouseout", this.boundHandlers.mouseOut);
620
+ if (this.config.keyboard) {
621
+ this.container.removeEventListener("keydown", this.boundHandlers.pickUpKey);
622
+ window.removeEventListener("keydown", this.boundHandlers.keyboardDragKey, true);
623
+ window.removeEventListener("keyup", this.boundHandlers.keyUp, true);
624
+ window.removeEventListener("focusout", this.boundHandlers.focusOut, true);
625
+ }
239
626
  }
240
627
  };
241
-
242
628
  //#endregion
243
629
  //#region src/drag-preview-controller.ts
244
630
  const OFFSCREEN_TRANSFORM = "translate(-9999px, -9999px)";
631
+ /**
632
+ * Attributes of the source (and its descendants) that belong to its place in the page or to the
633
+ * drag, not to its look: a clone keeping them would duplicate ids, join radio groups, count as a
634
+ * draggable or droppable, and match `[data-dragging]` placeholder styles.
635
+ */
636
+ const SOURCE_ONLY_ATTRIBUTES = [
637
+ "id",
638
+ "name",
639
+ "data-kind",
640
+ "data-dragging",
641
+ "data-hovered",
642
+ "data-drop-invalid"
643
+ ];
644
+ function stripSourceAttributes(clone) {
645
+ for (const element of [clone, ...clone.querySelectorAll("*")]) for (const name of SOURCE_ONLY_ATTRIBUTES) element.removeAttribute(name);
646
+ }
245
647
  function normalizeClassNames(className) {
246
648
  if (!className) return [];
247
649
  return Array.isArray(className) ? className : [className];
@@ -265,7 +667,10 @@ var DragPreviewController = class {
265
667
  const clone = element.cloneNode(true);
266
668
  const computed = window.getComputedStyle(element);
267
669
  const appendTo = this.options.appendTo ?? document.body;
670
+ stripSourceAttributes(clone);
671
+ clone.setAttribute("inert", "");
268
672
  clone.setAttribute("aria-hidden", "true");
673
+ clone.setAttribute("data-drag-preview", "");
269
674
  clone.style.position = "fixed";
270
675
  clone.style.left = "0";
271
676
  clone.style.top = "0";
@@ -301,7 +706,7 @@ var DragPreviewController = class {
301
706
  this.stop();
302
707
  }
303
708
  };
304
-
305
709
  //#endregion
306
710
  export { DragDropManager, DragPreviewController };
711
+
307
712
  //# sourceMappingURL=index.mjs.map