vlist 2.0.0 → 2.0.1
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.js +1 -28
- package/dist/internals.js +1 -60
- package/package.json +1 -1
- package/dist/constants.js +0 -83
- package/dist/core/create.js +0 -740
- package/dist/core/dom.js +0 -47
- package/dist/core/hooks.js +0 -67
- package/dist/core/index.js +0 -13
- package/dist/core/pipeline.js +0 -307
- package/dist/core/pool.js +0 -42
- package/dist/core/scroll.js +0 -137
- package/dist/core/sizes.js +0 -6
- package/dist/core/state.js +0 -56
- package/dist/core/types.js +0 -7
- package/dist/core/velocity.js +0 -33
- package/dist/events/emitter.js +0 -60
- package/dist/events/index.js +0 -6
- package/dist/plugins/a11y/index.js +0 -1
- package/dist/plugins/a11y/plugin.js +0 -259
- package/dist/plugins/async/index.js +0 -12
- package/dist/plugins/async/manager.js +0 -568
- package/dist/plugins/async/placeholder.js +0 -154
- package/dist/plugins/async/plugin.js +0 -311
- package/dist/plugins/async/sparse.js +0 -540
- package/dist/plugins/autosize/index.js +0 -4
- package/dist/plugins/autosize/plugin.js +0 -185
- package/dist/plugins/grid/index.js +0 -5
- package/dist/plugins/grid/layout.js +0 -275
- package/dist/plugins/grid/plugin.js +0 -347
- package/dist/plugins/grid/renderer.js +0 -525
- package/dist/plugins/grid/types.js +0 -11
- package/dist/plugins/groups/async-bridge.js +0 -246
- package/dist/plugins/groups/index.js +0 -13
- package/dist/plugins/groups/layout.js +0 -294
- package/dist/plugins/groups/plugin.js +0 -571
- package/dist/plugins/groups/sticky.js +0 -255
- package/dist/plugins/groups/types.js +0 -12
- package/dist/plugins/masonry/index.js +0 -6
- package/dist/plugins/masonry/layout.js +0 -261
- package/dist/plugins/masonry/plugin.js +0 -381
- package/dist/plugins/masonry/renderer.js +0 -354
- package/dist/plugins/masonry/types.js +0 -9
- package/dist/plugins/page/index.js +0 -5
- package/dist/plugins/page/plugin.js +0 -166
- package/dist/plugins/scale/index.js +0 -4
- package/dist/plugins/scale/plugin.js +0 -507
- package/dist/plugins/scrollbar/controller.js +0 -574
- package/dist/plugins/scrollbar/index.js +0 -6
- package/dist/plugins/scrollbar/plugin.js +0 -93
- package/dist/plugins/scrollbar/scrollbar.js +0 -556
- package/dist/plugins/selection/index.js +0 -7
- package/dist/plugins/selection/plugin.js +0 -601
- package/dist/plugins/selection/state.js +0 -332
- package/dist/plugins/snapshots/index.js +0 -5
- package/dist/plugins/snapshots/plugin.js +0 -301
- package/dist/plugins/sortable/index.js +0 -6
- package/dist/plugins/sortable/plugin.js +0 -753
- package/dist/plugins/table/header.js +0 -501
- package/dist/plugins/table/index.js +0 -12
- package/dist/plugins/table/layout.js +0 -211
- package/dist/plugins/table/plugin.js +0 -391
- package/dist/plugins/table/renderer.js +0 -625
- package/dist/plugins/table/types.js +0 -12
- package/dist/plugins/transition/index.js +0 -5
- package/dist/plugins/transition/plugin.js +0 -405
- package/dist/rendering/aria.js +0 -23
- package/dist/rendering/index.js +0 -18
- package/dist/rendering/measured.js +0 -98
- package/dist/rendering/renderer.js +0 -586
- package/dist/rendering/scale.js +0 -267
- package/dist/rendering/scroll.js +0 -71
- package/dist/rendering/sizes.js +0 -193
- package/dist/rendering/sort.js +0 -65
- package/dist/rendering/viewport.js +0 -268
- package/dist/types.js +0 -5
- package/dist/utils/padding.js +0 -49
- 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
|
-
}
|