vlist 2.0.0 → 2.0.2

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.
Files changed (91) hide show
  1. package/README.github.md +2 -2
  2. package/README.md +2 -2
  3. package/dist/core/dom.d.ts +1 -1
  4. package/dist/core/index.d.ts +1 -1
  5. package/dist/core/pipeline.d.ts +2 -2
  6. package/dist/core/scroll.d.ts +1 -1
  7. package/dist/core/types.d.ts +7 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -28
  10. package/dist/internals.js +1 -60
  11. package/dist/plugins/scrollbar/controller.d.ts +3 -3
  12. package/dist/plugins/scrollbar/scrollbar.d.ts +2 -2
  13. package/dist/rendering/renderer.d.ts +2 -2
  14. package/dist/rendering/viewport.d.ts +1 -1
  15. package/dist/size.json +1 -1
  16. package/dist/types.d.ts +1 -1
  17. package/package.json +1 -1
  18. package/dist/constants.js +0 -83
  19. package/dist/core/create.js +0 -740
  20. package/dist/core/dom.js +0 -47
  21. package/dist/core/hooks.js +0 -67
  22. package/dist/core/index.js +0 -13
  23. package/dist/core/pipeline.js +0 -307
  24. package/dist/core/pool.js +0 -42
  25. package/dist/core/scroll.js +0 -137
  26. package/dist/core/sizes.js +0 -6
  27. package/dist/core/state.js +0 -56
  28. package/dist/core/types.js +0 -7
  29. package/dist/core/velocity.js +0 -33
  30. package/dist/events/emitter.js +0 -60
  31. package/dist/events/index.js +0 -6
  32. package/dist/plugins/a11y/index.js +0 -1
  33. package/dist/plugins/a11y/plugin.js +0 -259
  34. package/dist/plugins/async/index.js +0 -12
  35. package/dist/plugins/async/manager.js +0 -568
  36. package/dist/plugins/async/placeholder.js +0 -154
  37. package/dist/plugins/async/plugin.js +0 -311
  38. package/dist/plugins/async/sparse.js +0 -540
  39. package/dist/plugins/autosize/index.js +0 -4
  40. package/dist/plugins/autosize/plugin.js +0 -185
  41. package/dist/plugins/grid/index.js +0 -5
  42. package/dist/plugins/grid/layout.js +0 -275
  43. package/dist/plugins/grid/plugin.js +0 -347
  44. package/dist/plugins/grid/renderer.js +0 -525
  45. package/dist/plugins/grid/types.js +0 -11
  46. package/dist/plugins/groups/async-bridge.js +0 -246
  47. package/dist/plugins/groups/index.js +0 -13
  48. package/dist/plugins/groups/layout.js +0 -294
  49. package/dist/plugins/groups/plugin.js +0 -571
  50. package/dist/plugins/groups/sticky.js +0 -255
  51. package/dist/plugins/groups/types.js +0 -12
  52. package/dist/plugins/masonry/index.js +0 -6
  53. package/dist/plugins/masonry/layout.js +0 -261
  54. package/dist/plugins/masonry/plugin.js +0 -381
  55. package/dist/plugins/masonry/renderer.js +0 -354
  56. package/dist/plugins/masonry/types.js +0 -9
  57. package/dist/plugins/page/index.js +0 -5
  58. package/dist/plugins/page/plugin.js +0 -166
  59. package/dist/plugins/scale/index.js +0 -4
  60. package/dist/plugins/scale/plugin.js +0 -507
  61. package/dist/plugins/scrollbar/controller.js +0 -574
  62. package/dist/plugins/scrollbar/index.js +0 -6
  63. package/dist/plugins/scrollbar/plugin.js +0 -93
  64. package/dist/plugins/scrollbar/scrollbar.js +0 -556
  65. package/dist/plugins/selection/index.js +0 -7
  66. package/dist/plugins/selection/plugin.js +0 -601
  67. package/dist/plugins/selection/state.js +0 -332
  68. package/dist/plugins/snapshots/index.js +0 -5
  69. package/dist/plugins/snapshots/plugin.js +0 -301
  70. package/dist/plugins/sortable/index.js +0 -6
  71. package/dist/plugins/sortable/plugin.js +0 -753
  72. package/dist/plugins/table/header.js +0 -501
  73. package/dist/plugins/table/index.js +0 -12
  74. package/dist/plugins/table/layout.js +0 -211
  75. package/dist/plugins/table/plugin.js +0 -391
  76. package/dist/plugins/table/renderer.js +0 -625
  77. package/dist/plugins/table/types.js +0 -12
  78. package/dist/plugins/transition/index.js +0 -5
  79. package/dist/plugins/transition/plugin.js +0 -405
  80. package/dist/rendering/aria.js +0 -23
  81. package/dist/rendering/index.js +0 -18
  82. package/dist/rendering/measured.js +0 -98
  83. package/dist/rendering/renderer.js +0 -586
  84. package/dist/rendering/scale.js +0 -267
  85. package/dist/rendering/scroll.js +0 -71
  86. package/dist/rendering/sizes.js +0 -193
  87. package/dist/rendering/sort.js +0 -65
  88. package/dist/rendering/viewport.js +0 -268
  89. package/dist/types.js +0 -5
  90. package/dist/utils/padding.js +0 -49
  91. package/dist/utils/stats.js +0 -124
@@ -1,753 +0,0 @@
1
- /**
2
- * vlist v2 — Sortable Plugin
3
- *
4
- * Drag-and-drop reordering for virtual lists.
5
- * Priority 30 — runs after layout plugins and scrollbar, before selection.
6
- *
7
- * Features:
8
- * - Pointer drag-and-drop with ghost element
9
- * - Keyboard reordering (Space to grab, arrows to move, Space to drop, Escape to cancel)
10
- * - Items shift via CSS transforms during drag
11
- * - Auto-scroll when dragging near viewport edges
12
- * - ARIA attributes and live region announcements
13
- * - Emits sort:start, sort:end, sort:cancel events
14
- *
15
- * The plugin is purely visual during drag — it does NOT reorder data.
16
- * On drop, it emits `sort:end` with `{ fromIndex, toIndex }`.
17
- * The consumer reorders their data and calls `setItems()`.
18
- *
19
- * Restrictions:
20
- * - Cannot be combined with grid, masonry, table, or scale plugins
21
- */
22
- // =============================================================================
23
- // Factory
24
- // =============================================================================
25
- export function sortable(config) {
26
- const handleSelector = config?.handle ?? null;
27
- const ghostClass = config?.ghostClass ?? "vlist-sort-ghost";
28
- const shiftDuration = config?.shiftDuration ?? 150;
29
- const edgeScrollZone = config?.edgeScrollZone ?? 40;
30
- const edgeScrollSpeed = config?.edgeScrollSpeed ?? 20;
31
- const dragThreshold = config?.dragThreshold ?? 5;
32
- const ghostContainer = config?.ghostContainer ?? null;
33
- let engineState;
34
- let sizeCache;
35
- let storedCtx = null;
36
- let contentEl;
37
- let viewportEl;
38
- let rootEl;
39
- let classPrefix;
40
- let horizontal;
41
- // Precomputed values
42
- let prop;
43
- let shiftTransition;
44
- let sortingClass;
45
- let settlingClass;
46
- let dragSourceClass;
47
- // ── Drag state ──
48
- let sorting = false;
49
- let dragIndex = -1;
50
- let dropIndex = -1;
51
- let pointerStartX = 0;
52
- let pointerStartY = 0;
53
- let pointerCurrentX = 0;
54
- let pointerCurrentY = 0;
55
- let dragInitiated = false;
56
- let ghost = null;
57
- let scrollRafId = 0;
58
- let draggedElement = null;
59
- let draggedItemSize = 0;
60
- let ghostOffsetX = 0;
61
- let ghostOffsetY = 0;
62
- let dragFocusedItemId = null;
63
- // ── Keyboard state ──
64
- let kbGrabbed = false;
65
- let kbGrabbedItemId = "";
66
- let kbFromIndex = -1;
67
- let kbCurrentIndex = -1;
68
- let kbOriginalItems = [];
69
- // ── ARIA ──
70
- let instructionsId = "";
71
- let instructionsEl = null;
72
- let liveRegion = null;
73
- let kbGrabbedClassName = "";
74
- // ── Edge scroll state ──
75
- let inEdgeZone = false;
76
- // =========================================================================
77
- // Helpers
78
- // =========================================================================
79
- const findItemElement = (target) => {
80
- return target.closest("[data-index]");
81
- };
82
- const getIndex = (el) => {
83
- const attr = el.dataset.index;
84
- return attr === undefined ? -1 : +attr;
85
- };
86
- const createGhost = (sourceEl) => {
87
- const rect = sourceEl.getBoundingClientRect();
88
- const clone = sourceEl.cloneNode(true);
89
- clone.className = `${classPrefix}-item ${ghostClass}`;
90
- clone.removeAttribute("data-index");
91
- clone.style.cssText =
92
- `position:fixed;pointer-events:none;z-index:10000;width:${rect.width}px;` +
93
- `height:${rect.height}px;left:${rect.left}px;top:${rect.top}px;` +
94
- "transition:none;will-change:transform";
95
- (ghostContainer || document.body).appendChild(clone);
96
- return clone;
97
- };
98
- const updateGhostPosition = () => {
99
- if (!ghost)
100
- return;
101
- ghost.style.left = `${pointerCurrentX - ghostOffsetX}px`;
102
- ghost.style.top = `${pointerCurrentY - ghostOffsetY}px`;
103
- };
104
- const computeDropIndex = () => {
105
- const totalItems = engineState.totalItems;
106
- if (totalItems === 0)
107
- return 0;
108
- const viewportRect = viewportEl.getBoundingClientRect();
109
- const scrollPos = engineState.scrollPosition;
110
- const ghostTop = horizontal
111
- ? pointerCurrentX - ghostOffsetX - viewportRect.left + viewportEl.scrollLeft + scrollPos
112
- : pointerCurrentY - ghostOffsetY - viewportRect.top + scrollPos;
113
- const ghostBottom = ghostTop + draggedItemSize;
114
- const dragEnd = sizeCache.getOffset(dragIndex) + sizeCache.getSize(dragIndex);
115
- if (ghostBottom > dragEnd) {
116
- const rawIndex = sizeCache.indexAtOffset(ghostBottom);
117
- const mid = sizeCache.getOffset(rawIndex) + sizeCache.getSize(rawIndex) / 2;
118
- const result = ghostBottom > mid ? rawIndex : rawIndex - 1;
119
- return Math.min(Math.max(result, dragIndex), totalItems - 1);
120
- }
121
- const dragStart = sizeCache.getOffset(dragIndex);
122
- if (ghostTop < dragStart) {
123
- const rawIndex = sizeCache.indexAtOffset(ghostTop);
124
- const mid = sizeCache.getOffset(rawIndex) + sizeCache.getSize(rawIndex) / 2;
125
- const result = ghostTop < mid ? rawIndex : rawIndex + 1;
126
- return Math.max(Math.min(result, dragIndex), 0);
127
- }
128
- return dragIndex;
129
- };
130
- const applyShifts = () => {
131
- const children = contentEl.children;
132
- const shiftPx = draggedItemSize;
133
- for (let i = 0; i < children.length; i++) {
134
- const itemEl = children[i];
135
- const idx = getIndex(itemEl);
136
- if (idx < 0 || idx === dragIndex)
137
- continue;
138
- let shift = 0;
139
- if (dropIndex > dragIndex) {
140
- if (idx > dragIndex && idx <= dropIndex)
141
- shift = -shiftPx;
142
- }
143
- else if (dropIndex < dragIndex) {
144
- if (idx >= dropIndex && idx < dragIndex)
145
- shift = shiftPx;
146
- }
147
- const baseOffset = sizeCache.getOffset(idx);
148
- const finalOffset = Math.round(baseOffset + shift);
149
- itemEl.style.transition = shiftTransition;
150
- itemEl.style.transform = `${prop}(${finalOffset}px)`;
151
- }
152
- };
153
- const clearShifts = () => {
154
- const children = contentEl.children;
155
- for (let i = 0; i < children.length; i++) {
156
- const itemEl = children[i];
157
- const idx = getIndex(itemEl);
158
- if (idx >= 0) {
159
- itemEl.style.transform = `${prop}(${Math.round(sizeCache.getOffset(idx))}px)`;
160
- }
161
- itemEl.style.transition = "";
162
- }
163
- };
164
- const updateDropPosition = () => {
165
- if (!storedCtx)
166
- return;
167
- const newDropIndex = computeDropIndex();
168
- if (newDropIndex === dropIndex)
169
- return;
170
- dropIndex = newDropIndex;
171
- applyShifts();
172
- storedCtx.emitter.emit("sort:move", { fromIndex: dragIndex, currentIndex: dropIndex });
173
- };
174
- const isPointerOutsideViewport = () => {
175
- const viewportRect = viewportEl.getBoundingClientRect();
176
- if (horizontal) {
177
- return pointerCurrentX < viewportRect.left || pointerCurrentX > viewportRect.right;
178
- }
179
- return pointerCurrentY < viewportRect.top || pointerCurrentY > viewportRect.bottom;
180
- };
181
- // ── Selection helpers ──
182
- const getFocusedIndex = () => {
183
- const fn = storedCtx?.getMethod("_getFocusedIndex");
184
- return fn ? fn() : -1;
185
- };
186
- const focusById = (id) => {
187
- const fn = storedCtx?.getMethod("_focusById");
188
- if (fn)
189
- fn(id);
190
- };
191
- const scrollIntoView = (index) => {
192
- if (!storedCtx)
193
- return;
194
- const containerSize = horizontal
195
- ? viewportEl.clientWidth
196
- : viewportEl.clientHeight;
197
- const scrollPos = engineState.scrollPosition;
198
- const itemTop = sizeCache.getOffset(index);
199
- const itemBottom = itemTop + sizeCache.getSize(index);
200
- if (itemTop < scrollPos) {
201
- storedCtx.scrollTo(Math.max(0, itemTop));
202
- }
203
- else if (itemBottom > scrollPos + containerSize) {
204
- storedCtx.scrollTo(itemBottom - containerSize);
205
- }
206
- };
207
- const announce = (message) => {
208
- if (!liveRegion)
209
- return;
210
- liveRegion.textContent = "";
211
- void liveRegion.offsetHeight;
212
- liveRegion.textContent = message;
213
- };
214
- const getItemLabel = (index) => {
215
- if (!storedCtx)
216
- return "";
217
- const items = storedCtx.getItems();
218
- const item = items[index];
219
- if (!item)
220
- return "";
221
- const el = contentEl.querySelector(`[data-index="${index}"]`);
222
- const text = el?.textContent?.trim();
223
- return text || String(item.id);
224
- };
225
- const totalLabel = () => String(engineState.totalItems);
226
- const setChildTransitions = (value) => {
227
- const children = contentEl.children;
228
- for (let i = 0; i < children.length; i++) {
229
- children[i].style.transition = value;
230
- }
231
- };
232
- // =========================================================================
233
- // Edge Auto-Scroll
234
- // =========================================================================
235
- const startEdgeScroll = () => {
236
- const tick = () => {
237
- if (!sorting || !storedCtx)
238
- return;
239
- const viewportRect = viewportEl.getBoundingClientRect();
240
- let delta = 0;
241
- const maxT = 3;
242
- if (horizontal) {
243
- const distFromStart = pointerCurrentX - viewportRect.left;
244
- const distFromEnd = viewportRect.right - pointerCurrentX;
245
- if (distFromStart < edgeScrollZone) {
246
- const t = Math.min(maxT, 1 - distFromStart / edgeScrollZone);
247
- delta = -edgeScrollSpeed * t * t;
248
- }
249
- else if (distFromEnd < edgeScrollZone) {
250
- const t = Math.min(maxT, 1 - distFromEnd / edgeScrollZone);
251
- delta = edgeScrollSpeed * t * t;
252
- }
253
- }
254
- else {
255
- const distFromTop = pointerCurrentY - viewportRect.top;
256
- const distFromBottom = viewportRect.bottom - pointerCurrentY;
257
- if (distFromTop < edgeScrollZone) {
258
- const t = Math.min(maxT, 1 - distFromTop / edgeScrollZone);
259
- delta = -edgeScrollSpeed * t * t;
260
- }
261
- else if (distFromBottom < edgeScrollZone) {
262
- const t = Math.min(maxT, 1 - distFromBottom / edgeScrollZone);
263
- delta = edgeScrollSpeed * t * t;
264
- }
265
- }
266
- const outsideViewport = isPointerOutsideViewport();
267
- if (delta !== 0) {
268
- const currentScroll = engineState.scrollPosition;
269
- const maxScroll = sizeCache.getTotalSize() - (horizontal
270
- ? viewportEl.clientWidth
271
- : viewportEl.clientHeight);
272
- const atLimit = (delta < 0 && currentScroll <= 0)
273
- || (delta > 0 && currentScroll >= maxScroll);
274
- if (atLimit) {
275
- inEdgeZone = outsideViewport;
276
- }
277
- else {
278
- inEdgeZone = true;
279
- storedCtx.scrollTo(currentScroll + delta);
280
- }
281
- }
282
- else {
283
- inEdgeZone = outsideViewport;
284
- }
285
- scrollRafId = requestAnimationFrame(tick);
286
- };
287
- scrollRafId = requestAnimationFrame(tick);
288
- };
289
- const stopEdgeScroll = () => {
290
- if (scrollRafId) {
291
- cancelAnimationFrame(scrollRafId);
292
- scrollRafId = 0;
293
- }
294
- };
295
- // =========================================================================
296
- // Cleanup
297
- // =========================================================================
298
- const cleanupDrag = (skipRender = false) => {
299
- sorting = false;
300
- dragInitiated = false;
301
- if (ghost && ghost.parentNode)
302
- ghost.remove();
303
- ghost = null;
304
- draggedElement = null;
305
- const children = contentEl.children;
306
- for (let i = 0; i < children.length; i++) {
307
- const el = children[i];
308
- el.classList.remove(dragSourceClass);
309
- const idx = getIndex(el);
310
- if (idx >= 0) {
311
- el.style.transform = `${prop}(${Math.round(sizeCache.getOffset(idx))}px)`;
312
- }
313
- el.style.transition = "";
314
- }
315
- stopEdgeScroll();
316
- rootEl.classList.remove(sortingClass);
317
- document.body.style.cursor = "";
318
- const sel = window.getSelection();
319
- if (sel)
320
- sel.removeAllRanges();
321
- document.removeEventListener("pointermove", onPointerMove);
322
- document.removeEventListener("pointerup", onPointerUp);
323
- document.removeEventListener("pointercancel", onPointerCancel);
324
- if (!skipRender && storedCtx) {
325
- storedCtx.forceRender();
326
- }
327
- };
328
- // =========================================================================
329
- // Animate Drop
330
- // =========================================================================
331
- const animateDrop = (fromIndex, toIndex) => {
332
- if (!storedCtx)
333
- return;
334
- const posChanged = fromIndex !== toIndex && fromIndex >= 0 && toIndex >= 0;
335
- const finalize = () => {
336
- if (!storedCtx)
337
- return;
338
- sorting = false;
339
- if (posChanged) {
340
- rootEl.classList.add(settlingClass);
341
- storedCtx.emitter.emit("sort:end", { fromIndex, toIndex });
342
- if (dragFocusedItemId !== null)
343
- focusById(dragFocusedItemId);
344
- cleanupDrag(true);
345
- requestAnimationFrame(() => rootEl.classList.remove(settlingClass));
346
- }
347
- else {
348
- rootEl.classList.add(settlingClass);
349
- storedCtx.emitter.emit("sort:cancel", { originalItems: [...storedCtx.getItems()] });
350
- cleanupDrag(false);
351
- requestAnimationFrame(() => rootEl.classList.remove(settlingClass));
352
- }
353
- };
354
- if (!ghost) {
355
- finalize();
356
- return;
357
- }
358
- const viewportRect = viewportEl.getBoundingClientRect();
359
- const scrollPos = engineState.scrollPosition;
360
- const targetOffset = sizeCache.getOffset(toIndex);
361
- const duration = shiftDuration > 0 ? shiftDuration : 150;
362
- ghost.style.transition = `left ${duration}ms ease, top ${duration}ms ease`;
363
- if (horizontal) {
364
- ghost.style.left = `${viewportRect.left - viewportEl.scrollLeft + targetOffset - scrollPos}px`;
365
- ghost.style.top = `${viewportRect.top}px`;
366
- }
367
- else {
368
- ghost.style.left = `${viewportRect.left}px`;
369
- ghost.style.top = `${viewportRect.top + targetOffset - scrollPos}px`;
370
- }
371
- let settled = false;
372
- const onEnd = () => {
373
- if (settled)
374
- return;
375
- settled = true;
376
- ghost?.removeEventListener("transitionend", onEnd);
377
- finalize();
378
- };
379
- ghost.addEventListener("transitionend", onEnd);
380
- setTimeout(onEnd, duration + 50);
381
- };
382
- // =========================================================================
383
- // Pointer Events
384
- // =========================================================================
385
- const onPointerDown = (event) => {
386
- if (engineState.destroyed || !storedCtx)
387
- return;
388
- if (sorting)
389
- return;
390
- if (kbGrabbed)
391
- kbCancel();
392
- if (event.button !== 0)
393
- return;
394
- const target = event.target;
395
- if (handleSelector) {
396
- const handle = target.closest(handleSelector);
397
- if (!handle)
398
- return;
399
- }
400
- const itemEl = findItemElement(target);
401
- if (!itemEl)
402
- return;
403
- const index = getIndex(itemEl);
404
- if (index < 0)
405
- return;
406
- pointerStartX = event.clientX;
407
- pointerStartY = event.clientY;
408
- pointerCurrentX = event.clientX;
409
- pointerCurrentY = event.clientY;
410
- dragIndex = index;
411
- dragInitiated = false;
412
- draggedElement = itemEl;
413
- const rect = itemEl.getBoundingClientRect();
414
- ghostOffsetX = event.clientX - rect.left;
415
- ghostOffsetY = event.clientY - rect.top;
416
- document.addEventListener("pointermove", onPointerMove);
417
- document.addEventListener("pointerup", onPointerUp);
418
- document.addEventListener("pointercancel", onPointerCancel);
419
- };
420
- function onPointerMove(event) {
421
- if (!storedCtx)
422
- return;
423
- pointerCurrentX = event.clientX;
424
- pointerCurrentY = event.clientY;
425
- if (!dragInitiated) {
426
- const dx = pointerCurrentX - pointerStartX;
427
- const dy = pointerCurrentY - pointerStartY;
428
- if (Math.sqrt(dx * dx + dy * dy) < dragThreshold)
429
- return;
430
- dragInitiated = true;
431
- sorting = true;
432
- dropIndex = dragIndex;
433
- rootEl.classList.add(sortingClass);
434
- document.body.style.cursor = "grabbing";
435
- draggedItemSize = sizeCache.getSize(dragIndex);
436
- if (draggedElement) {
437
- ghost = createGhost(draggedElement);
438
- draggedElement.classList.add(dragSourceClass);
439
- }
440
- const focusIdx = getFocusedIndex();
441
- if (focusIdx >= 0) {
442
- const items = storedCtx.getItems();
443
- const focusItem = items[focusIdx];
444
- dragFocusedItemId = focusItem ? focusItem.id : null;
445
- }
446
- else {
447
- dragFocusedItemId = null;
448
- }
449
- storedCtx.emitter.emit("sort:start", { index: dragIndex });
450
- startEdgeScroll();
451
- }
452
- if (sorting) {
453
- event.preventDefault();
454
- updateGhostPosition();
455
- if (!inEdgeZone) {
456
- updateDropPosition();
457
- }
458
- else if (isPointerOutsideViewport()) {
459
- if (dropIndex !== dragIndex) {
460
- dropIndex = dragIndex;
461
- clearShifts();
462
- }
463
- }
464
- }
465
- }
466
- function onPointerUp(event) {
467
- if (!dragInitiated) {
468
- document.removeEventListener("pointermove", onPointerMove);
469
- document.removeEventListener("pointerup", onPointerUp);
470
- document.removeEventListener("pointercancel", onPointerCancel);
471
- draggedElement = null;
472
- return;
473
- }
474
- event.preventDefault();
475
- document.removeEventListener("pointermove", onPointerMove);
476
- document.removeEventListener("pointerup", onPointerUp);
477
- document.removeEventListener("pointercancel", onPointerCancel);
478
- stopEdgeScroll();
479
- animateDrop(dragIndex, dropIndex);
480
- }
481
- const cancelPointerDrag = () => {
482
- if (!dragInitiated)
483
- return;
484
- document.removeEventListener("pointermove", onPointerMove);
485
- document.removeEventListener("pointerup", onPointerUp);
486
- document.removeEventListener("pointercancel", onPointerCancel);
487
- stopEdgeScroll();
488
- clearShifts();
489
- animateDrop(dragIndex, dragIndex);
490
- };
491
- function onPointerCancel() {
492
- cancelPointerDrag();
493
- }
494
- // =========================================================================
495
- // Keyboard Reordering
496
- // =========================================================================
497
- const clearKbGrabbedClass = () => {
498
- const els = contentEl.querySelectorAll(`.${kbGrabbedClassName}`);
499
- for (let i = 0; i < els.length; i++) {
500
- els[i].classList.remove(kbGrabbedClassName);
501
- }
502
- };
503
- const applyKbGrabbedClass = () => {
504
- clearKbGrabbedClass();
505
- const el = contentEl.querySelector(`[data-id="${kbGrabbedItemId}"]`);
506
- if (el)
507
- el.classList.add(kbGrabbedClassName);
508
- };
509
- const kbGrab = (index) => {
510
- if (!storedCtx)
511
- return;
512
- const items = storedCtx.getItems();
513
- const item = items[index];
514
- if (!item)
515
- return;
516
- kbGrabbed = true;
517
- kbGrabbedItemId = item.id;
518
- kbFromIndex = index;
519
- kbCurrentIndex = index;
520
- kbOriginalItems = [...items];
521
- rootEl.classList.add(sortingClass);
522
- storedCtx.emitter.emit("sort:start", { index });
523
- applyKbGrabbedClass();
524
- announce(`Grabbed ${getItemLabel(index)}. Current position ${index + 1} of ${totalLabel()}. ` +
525
- `Use Up and Down arrow keys to move, Space to drop, Escape to cancel.`);
526
- };
527
- const kbDrop = () => {
528
- if (!kbGrabbed || !storedCtx)
529
- return;
530
- kbGrabbed = false;
531
- const toIndex = kbCurrentIndex;
532
- const label = getItemLabel(toIndex);
533
- setChildTransitions("none");
534
- rootEl.classList.remove(sortingClass);
535
- clearKbGrabbedClass();
536
- focusById(kbGrabbedItemId);
537
- storedCtx.forceRender();
538
- announce(`${label} dropped. Final position ${toIndex + 1} of ${totalLabel()}.`);
539
- kbOriginalItems = [];
540
- requestAnimationFrame(() => setChildTransitions(""));
541
- };
542
- const kbCancel = () => {
543
- if (!kbGrabbed || !storedCtx)
544
- return;
545
- kbGrabbed = false;
546
- const originalIndex = kbFromIndex;
547
- setChildTransitions("none");
548
- rootEl.classList.remove(sortingClass);
549
- clearKbGrabbedClass();
550
- if (kbCurrentIndex !== originalIndex) {
551
- storedCtx.emitter.emit("sort:cancel", { originalItems: kbOriginalItems });
552
- }
553
- focusById(kbGrabbedItemId);
554
- storedCtx.forceRender();
555
- scrollIntoView(originalIndex);
556
- announce(`Reorder cancelled. Returned to position ${originalIndex + 1} of ${totalLabel()}.`);
557
- kbOriginalItems = [];
558
- requestAnimationFrame(() => setChildTransitions(""));
559
- };
560
- const kbMove = (direction) => {
561
- if (!kbGrabbed || !storedCtx)
562
- return;
563
- const total = engineState.totalItems;
564
- const newIndex = kbCurrentIndex + direction;
565
- if (newIndex < 0 || newIndex >= total)
566
- return;
567
- const fromIndex = kbCurrentIndex;
568
- const toIndex = newIndex;
569
- setChildTransitions("none");
570
- storedCtx.emitter.emit("sort:end", { fromIndex, toIndex });
571
- kbCurrentIndex = toIndex;
572
- focusById(kbGrabbedItemId);
573
- storedCtx.forceRender();
574
- scrollIntoView(toIndex);
575
- applyKbGrabbedClass();
576
- announce(`${getItemLabel(toIndex)} moved. New position ${toIndex + 1} of ${totalLabel()}.`);
577
- requestAnimationFrame(() => setChildTransitions(""));
578
- };
579
- // =========================================================================
580
- // Keyboard Handler
581
- // =========================================================================
582
- const onKeydown = (event) => {
583
- if (engineState.destroyed)
584
- return;
585
- if (sorting) {
586
- if (event.key === "Escape") {
587
- event.preventDefault();
588
- event.stopImmediatePropagation();
589
- cancelPointerDrag();
590
- }
591
- return;
592
- }
593
- if (kbGrabbed) {
594
- switch (event.key) {
595
- case " ":
596
- case "Enter":
597
- event.preventDefault();
598
- event.stopImmediatePropagation();
599
- kbDrop();
600
- return;
601
- case "Escape":
602
- event.preventDefault();
603
- event.stopImmediatePropagation();
604
- kbCancel();
605
- return;
606
- case "ArrowUp":
607
- case "ArrowLeft":
608
- event.preventDefault();
609
- event.stopImmediatePropagation();
610
- kbMove(-1);
611
- return;
612
- case "ArrowDown":
613
- case "ArrowRight":
614
- event.preventDefault();
615
- event.stopImmediatePropagation();
616
- kbMove(1);
617
- return;
618
- default:
619
- if (!event.key.startsWith("F") && event.key !== "Tab") {
620
- event.preventDefault();
621
- event.stopImmediatePropagation();
622
- }
623
- return;
624
- }
625
- }
626
- if (event.key === " " && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
627
- const focusedIndex = getFocusedIndex();
628
- if (focusedIndex >= 0) {
629
- event.preventDefault();
630
- event.stopImmediatePropagation();
631
- kbGrab(focusedIndex);
632
- }
633
- }
634
- };
635
- // =========================================================================
636
- // Plugin
637
- // =========================================================================
638
- return {
639
- name: "sortable",
640
- priority: 30,
641
- conflicts: ["grid", "masonry", "table", "scale"],
642
- setup(ctx) {
643
- storedCtx = ctx;
644
- engineState = ctx.getState();
645
- sizeCache = ctx.sizeCache;
646
- contentEl = ctx.dom.content;
647
- viewportEl = ctx.dom.viewport;
648
- rootEl = ctx.dom.root;
649
- classPrefix = ctx.config.classPrefix;
650
- horizontal = ctx.config.horizontal;
651
- prop = horizontal ? "translateX" : "translateY";
652
- shiftTransition = shiftDuration > 0
653
- ? `transform ${shiftDuration}ms ease`
654
- : "none";
655
- sortingClass = `${classPrefix}--sorting`;
656
- settlingClass = `${classPrefix}--settling`;
657
- dragSourceClass = `${classPrefix}-item--drag-source`;
658
- kbGrabbedClassName = `${classPrefix}-item--kb-sorting`;
659
- ctx.registerMethod("isSorting", () => sorting || kbGrabbed);
660
- // ── Pointer handler on items container ──
661
- contentEl.addEventListener("pointerdown", onPointerDown);
662
- // ── Keyboard handler directly on root ──
663
- // Registered directly (not via ctx.registerKeydownHandler) so
664
- // stopImmediatePropagation prevents selection from processing keys
665
- rootEl.addEventListener("keydown", onKeydown);
666
- // ── ARIA instructions ──
667
- instructionsId = `${classPrefix}-sort-instructions`;
668
- instructionsEl = document.createElement("div");
669
- instructionsEl.id = instructionsId;
670
- instructionsEl.style.cssText =
671
- "position:absolute;width:1px;height:1px;padding:0;margin:-1px;" +
672
- "overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
673
- instructionsEl.textContent =
674
- "Press Space to reorder. Use arrow keys to move, Space to drop, Escape to cancel.";
675
- rootEl.appendChild(instructionsEl);
676
- // ── Live region for announcements ──
677
- liveRegion = document.createElement("div");
678
- liveRegion.setAttribute("role", "status");
679
- liveRegion.setAttribute("aria-live", "assertive");
680
- liveRegion.setAttribute("aria-atomic", "true");
681
- liveRegion.style.cssText =
682
- "position:absolute;width:1px;height:1px;padding:0;margin:-1px;" +
683
- "overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
684
- rootEl.appendChild(liveRegion);
685
- // ── Cleanup ──
686
- ctx.registerDestroyHandler(() => {
687
- if (kbGrabbed)
688
- kbCancel();
689
- cleanupDrag();
690
- contentEl.removeEventListener("pointerdown", onPointerDown);
691
- rootEl.removeEventListener("keydown", onKeydown);
692
- instructionsEl?.remove();
693
- liveRegion?.remove();
694
- });
695
- },
696
- hooks: {
697
- onCommit() {
698
- if (!storedCtx)
699
- return;
700
- const children = contentEl.children;
701
- for (let i = 0; i < children.length; i++) {
702
- const el = children[i];
703
- const idx = getIndex(el);
704
- if (idx < 0)
705
- continue;
706
- // Apply ARIA attributes to all visible items
707
- el.setAttribute("aria-roledescription", "sortable item");
708
- el.setAttribute("aria-describedby", instructionsId);
709
- // During keyboard grab: maintain grabbed visual
710
- if (kbGrabbed) {
711
- const id = el.getAttribute("data-id");
712
- if (id === String(kbGrabbedItemId)) {
713
- el.classList.add(kbGrabbedClassName);
714
- }
715
- else {
716
- el.classList.remove(kbGrabbedClassName);
717
- }
718
- }
719
- // During pointer drag: maintain visual state on recycled elements
720
- if (sorting) {
721
- if (idx === dragIndex) {
722
- el.classList.add(dragSourceClass);
723
- draggedElement = el;
724
- }
725
- else {
726
- el.classList.remove(dragSourceClass);
727
- let shift = 0;
728
- if (dropIndex > dragIndex) {
729
- if (idx > dragIndex && idx <= dropIndex)
730
- shift = -draggedItemSize;
731
- }
732
- else if (dropIndex < dragIndex) {
733
- if (idx >= dropIndex && idx < dragIndex)
734
- shift = draggedItemSize;
735
- }
736
- const finalOffset = Math.round(sizeCache.getOffset(idx) + shift);
737
- el.style.transform = `${prop}(${finalOffset}px)`;
738
- }
739
- }
740
- }
741
- },
742
- },
743
- destroy() {
744
- if (ghost && ghost.parentNode)
745
- ghost.remove();
746
- ghost = null;
747
- stopEdgeScroll();
748
- instructionsEl?.remove();
749
- liveRegion?.remove();
750
- storedCtx = null;
751
- },
752
- };
753
- }