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/dist/index.mjs CHANGED
@@ -1,21 +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
- draggableKinds;
6
- droppableKinds;
7
- callbacks;
8
- 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 {
9
80
  startPosition: null,
10
81
  sourceElement: null,
11
82
  sourcePosition: null,
12
83
  sourceItem: null,
13
84
  activePointerId: null,
85
+ input: null,
14
86
  isDragging: false,
15
- lastHoveredElement: null
87
+ lastHoveredElement: null,
88
+ lastInvalidElement: null,
89
+ lastDropResult: null
16
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();
17
122
  rafId = null;
18
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;
19
140
  boundHandlers;
20
141
  constructor(containerRef, config, callbacks) {
21
142
  if (typeof containerRef === "string") {
@@ -28,13 +149,18 @@ var DragDropManager = class {
28
149
  } else this.container = containerRef;
29
150
  this.config = {
30
151
  dragThreshold: config.dragThreshold ?? 10,
152
+ dragDelay: config.dragDelay ?? 0,
153
+ dragDelayTolerance: config.dragDelayTolerance ?? 5,
31
154
  clickThreshold: config.clickThreshold ?? 10,
32
155
  scrollThreshold: config.scrollThreshold ?? 100,
33
156
  scrollSpeed: config.scrollSpeed ?? 10,
34
157
  cancelOnEscape: config.cancelOnEscape ?? true,
35
158
  cancelOnPointerLeave: config.cancelOnPointerLeave ?? true,
36
159
  draggableKind: config.draggableKind,
37
- droppableKind: config.droppableKind
160
+ droppableKind: config.droppableKind,
161
+ dropScope: config.dropScope ?? "document",
162
+ keyboard: config.keyboard ?? false,
163
+ focusOnPress: config.focusOnPress ?? false
38
164
  };
39
165
  this.draggableKinds = this.normalizeKindList(this.config.draggableKind);
40
166
  this.droppableKinds = this.normalizeKindList(this.config.droppableKind);
@@ -45,44 +171,87 @@ var DragDropManager = class {
45
171
  pointerUp: this.handlePointerUp.bind(this),
46
172
  pointerCancel: this.handlePointerCancel.bind(this),
47
173
  keyDown: this.handleKeyDown.bind(this),
48
- 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)
49
183
  };
50
184
  this.attachListeners();
51
185
  }
52
186
  attachListeners() {
53
187
  this.container.addEventListener("pointerdown", this.boundHandlers.pointerDown);
54
- this.container.addEventListener("pointermove", this.boundHandlers.pointerMove);
55
188
  this.container.addEventListener("pointerup", this.boundHandlers.pointerUp);
56
189
  this.container.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
57
190
  window.addEventListener("pointerup", this.boundHandlers.pointerUp);
58
191
  window.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
59
192
  window.addEventListener("keydown", this.boundHandlers.keyDown);
60
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
+ };
61
217
  }
62
218
  handlePointerDown(e) {
63
219
  if (this.state.startPosition) return;
64
- const target = e.target;
65
- const draggableMatch = this.findElementByKinds(target, this.draggableKinds);
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);
66
223
  if (!draggableMatch) return;
67
- const { element: draggableElement, kind: sourceKind } = draggableMatch;
68
- const position = this.callbacks.getItemPosition(draggableElement, sourceKind);
69
- if (!position) return;
70
- if (draggableElement.dataset.empty !== void 0) return;
71
- if (this.callbacks.canDrag && !this.callbacks.canDrag(draggableElement, position)) return;
72
- const item = this.callbacks.getItemData?.(draggableElement, position) ?? null;
73
- if (!item) return;
74
- e.preventDefault();
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);
75
231
  this.state.startPosition = {
76
232
  x: e.clientX,
77
233
  y: e.clientY
78
234
  };
79
235
  this.state.sourceElement = draggableElement;
80
- this.state.sourcePosition = position;
81
- this.state.sourceItem = item;
236
+ this.state.sourcePosition = source.position;
237
+ this.state.sourceItem = source.item;
238
+ this.state.input = "pointer";
82
239
  this.state.activePointerId = this.getPointerId(e);
240
+ this.activePointerType = e.pointerType ?? "";
241
+ this.lastPointerPos = this.state.startPosition;
83
242
  if (this.state.activePointerId !== null && typeof draggableElement.setPointerCapture === "function") try {
84
243
  draggableElement.setPointerCapture(this.state.activePointerId);
85
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);
86
255
  }
87
256
  handlePointerMove(e) {
88
257
  if (!this.isActivePointer(e)) return;
@@ -91,36 +260,82 @@ var DragDropManager = class {
91
260
  y: e.clientY
92
261
  };
93
262
  if (this.state.startPosition && !this.state.isDragging) {
94
- if (this.getDistance(this.state.startPosition, currentPos) >= this.config.dragThreshold) {
95
- this.state.isDragging = true;
96
- if (this.state.sourceElement && this.state.sourcePosition && this.state.sourceItem) {
97
- this.state.sourceElement.setAttribute("data-dragging", "true");
98
- this.callbacks.onDragStart?.(this.state.sourceElement, this.state.sourcePosition, this.state.sourceItem);
99
- }
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;
100
268
  }
269
+ if (distance >= this.config.dragThreshold) this.startDrag();
101
270
  return;
102
271
  }
103
272
  if (!this.state.isDragging) return;
104
273
  this.lastPointerPos = currentPos;
105
- 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(() => {
106
315
  this.rafId = null;
107
- if (!this.lastPointerPos) return;
108
- const pos = this.lastPointerPos;
109
- const elementUnderCursor = document.elementFromPoint(pos.x, pos.y);
110
- const droppableElement = this.findElementByKinds(elementUnderCursor, this.droppableKinds)?.element ?? null;
111
- if (droppableElement !== this.state.lastHoveredElement) {
112
- if (this.state.lastHoveredElement) this.state.lastHoveredElement.removeAttribute("data-hovered");
113
- if (droppableElement) {
114
- droppableElement.setAttribute("data-hovered", "true");
115
- this.state.lastHoveredElement = droppableElement;
116
- } else this.state.lastHoveredElement = null;
117
- }
118
- this.handleAutoScroll(pos);
119
- 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
120
328
  });
329
+ this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement);
330
+ if (scrolled) this.scheduleFrame();
121
331
  }
122
332
  handlePointerUp(e) {
333
+ if (this.state.input === "keyboard") return;
123
334
  if (!this.isActivePointer(e)) return;
335
+ if (this.suppressClickOnRelease) {
336
+ this.suppressClickOnRelease = false;
337
+ this.blockNextClick();
338
+ }
124
339
  if (this.state.startPosition && !this.state.isDragging) {
125
340
  const currentPos = {
126
341
  x: e.clientX,
@@ -135,15 +350,24 @@ var DragDropManager = class {
135
350
  return;
136
351
  }
137
352
  const target = document.elementFromPoint(e.clientX, e.clientY);
138
- const droppableMatch = this.findElementByKinds(target, this.droppableKinds);
139
- const droppableElement = droppableMatch?.element ?? null;
140
- if (droppableElement && droppableMatch) {
141
- const targetPosition = this.callbacks.getItemPosition(droppableElement, droppableMatch.kind);
142
- 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
+ }
143
365
  }
144
366
  this.cleanup();
145
367
  }
146
368
  handlePointerCancel() {
369
+ if (this.state.input === "keyboard") return;
370
+ this.suppressClickOnRelease = false;
147
371
  this.cleanup();
148
372
  }
149
373
  handleKeyDown(e) {
@@ -153,44 +377,162 @@ var DragDropManager = class {
153
377
  this.cleanup();
154
378
  }
155
379
  handleMouseOut(e) {
156
- if (!this.config.cancelOnPointerLeave) return;
157
- if (!this.state.startPosition) return;
158
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;
432
+ this.cleanup();
433
+ }
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);
159
489
  this.cleanup();
160
490
  }
161
- handleAutoScroll(pos) {
162
- const { scrollThreshold, scrollSpeed } = this.config;
163
- const viewportWidth = window.innerWidth;
164
- const viewportHeight = window.innerHeight;
165
- let scrollX = 0;
166
- let scrollY = 0;
167
- if (pos.x < scrollThreshold) scrollX = -scrollSpeed;
168
- else if (pos.x > viewportWidth - scrollThreshold) scrollX = scrollSpeed;
169
- if (pos.y < scrollThreshold) scrollY = -scrollSpeed;
170
- else if (pos.y > viewportHeight - scrollThreshold) scrollY = scrollSpeed;
171
- if (scrollX !== 0 || scrollY !== 0) window.scrollBy(scrollX, scrollY);
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;
172
510
  }
173
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 = "";
174
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 {
175
524
  this.state.sourceElement.releasePointerCapture(this.state.activePointerId);
176
525
  } catch {}
177
526
  if (this.state.sourceElement) this.state.sourceElement.removeAttribute("data-dragging");
178
- if (this.state.lastHoveredElement) this.state.lastHoveredElement.removeAttribute("data-hovered");
527
+ this.markTarget(null);
179
528
  if (this.rafId !== null) {
180
529
  cancelAnimationFrame(this.rafId);
181
530
  this.rafId = null;
182
531
  }
183
- if (this.state.isDragging) this.callbacks.onDragEnd?.();
184
- this.state = {
185
- startPosition: null,
186
- sourceElement: null,
187
- sourcePosition: null,
188
- sourceItem: null,
189
- activePointerId: null,
190
- isDragging: false,
191
- lastHoveredElement: null
192
- };
532
+ if (this.state.isDragging) this.callbacks.onDragEnd?.(this.state.lastDropResult);
533
+ this.state = idleState();
193
534
  this.lastPointerPos = null;
535
+ this.keyboardTarget = null;
194
536
  }
195
537
  getPointerId(e) {
196
538
  return typeof e.pointerId === "number" ? e.pointerId : null;
@@ -201,8 +543,31 @@ var DragDropManager = class {
201
543
  return pointerId === null || pointerId === this.state.activePointerId;
202
544
  }
203
545
  normalizeKindList(kinds) {
204
- const values = Array.isArray(kinds) ? kinds : [kinds];
205
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
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
+ }
556
+ /**
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.
560
+ */
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);
206
571
  }
207
572
  /**
208
573
  * Find closest ancestor element with a `data-kind` matching any expected kind.
@@ -242,20 +607,43 @@ var DragDropManager = class {
242
607
  */
243
608
  destroy() {
244
609
  this.cleanup();
610
+ this.suppressClickOnRelease = false;
611
+ this.releaseClickBlocker?.();
245
612
  this.container.removeEventListener("pointerdown", this.boundHandlers.pointerDown);
246
- this.container.removeEventListener("pointermove", this.boundHandlers.pointerMove);
247
613
  this.container.removeEventListener("pointerup", this.boundHandlers.pointerUp);
248
614
  this.container.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
615
+ this.container.removeEventListener("touchmove", this.boundHandlers.touchMove);
249
616
  window.removeEventListener("pointerup", this.boundHandlers.pointerUp);
250
617
  window.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
251
618
  window.removeEventListener("keydown", this.boundHandlers.keyDown);
252
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
+ }
253
626
  }
254
627
  };
255
-
256
628
  //#endregion
257
629
  //#region src/drag-preview-controller.ts
258
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
+ }
259
647
  function normalizeClassNames(className) {
260
648
  if (!className) return [];
261
649
  return Array.isArray(className) ? className : [className];
@@ -279,7 +667,10 @@ var DragPreviewController = class {
279
667
  const clone = element.cloneNode(true);
280
668
  const computed = window.getComputedStyle(element);
281
669
  const appendTo = this.options.appendTo ?? document.body;
670
+ stripSourceAttributes(clone);
671
+ clone.setAttribute("inert", "");
282
672
  clone.setAttribute("aria-hidden", "true");
673
+ clone.setAttribute("data-drag-preview", "");
283
674
  clone.style.position = "fixed";
284
675
  clone.style.left = "0";
285
676
  clone.style.top = "0";
@@ -315,7 +706,7 @@ var DragPreviewController = class {
315
706
  this.stop();
316
707
  }
317
708
  };
318
-
319
709
  //#endregion
320
710
  export { DragDropManager, DragPreviewController };
711
+
321
712
  //# sourceMappingURL=index.mjs.map