@rozie-ui/sortable-list-lit 0.1.5
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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/index.cjs +772 -0
- package/dist/index.d.cts +97 -0
- package/dist/index.d.mts +97 -0
- package/dist/index.mjs +744 -0
- package/package.json +72 -0
- package/src/SortableList.ts +419 -0
- package/src/index.ts +2 -0
- package/src/internal/useSortableJS.ts +475 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSortableJS — framework-agnostic SortableJS-vs-reconciler bridge.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Wrapping SortableJS for cross-framework consumption (Rozie's
|
|
7
|
+
* `examples/SortableList.rozie`) has four recurring fragilities every wrapper
|
|
8
|
+
* library independently re-discovers:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Three-handler ambiguity.** SortableJS fires distinct `onUpdate`,
|
|
11
|
+
* `onAdd`, and `onRemove` events for the same logical drag in cross-list
|
|
12
|
+
* moves. Inline handlers in user code can ONLY disambiguate by reading
|
|
13
|
+
* `e.from === listEl` / `e.to === listEl`, which is the same disambiguation
|
|
14
|
+
* `onEnd` can perform in a SINGLE handler. Collapsing to one onEnd path
|
|
15
|
+
* removes the per-handler boilerplate.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Fragile event shapes.** When SortableJS falls back to the mouse-event
|
|
18
|
+
* drag path (Playwright's mouse.move/down/up sequence; some touch devices;
|
|
19
|
+
* HTML5 DnD aborts), `e.item` may not be a valid Node by the time the
|
|
20
|
+
* handler runs (the source-side `onRemove` fires AFTER the destination
|
|
21
|
+
* has already detached the element). Inline
|
|
22
|
+
* `listEl.insertBefore(e.item, …)` throws inside SortableJS's
|
|
23
|
+
* `_dispatchEvent`. SortableJS catches and swallows the throw silently —
|
|
24
|
+
* the model writeback never runs, leaving the framework's view stale.
|
|
25
|
+
* THIS is the root cause of the duplicate-on-drop bug surfaced by the
|
|
26
|
+
* `sortable-nested-solid-deeper` VR spec.
|
|
27
|
+
*
|
|
28
|
+
* 3. **`e.oldIndex` is occasionally null.** Fallback-mode events lose the
|
|
29
|
+
* index. Identity-based item lookup (`items().indexOf(stashedItem)`) is
|
|
30
|
+
* strictly more reliable.
|
|
31
|
+
*
|
|
32
|
+
* 4. **Lit lit-html `repeat`-cache desync.** Every reconciler EXCEPT Lit
|
|
33
|
+
* handles the SortableJS DOM-restore + model writeback dance cleanly.
|
|
34
|
+
* Lit needs `$reconcileAfterDomMutation()` (a Rozie sigil that lowers to
|
|
35
|
+
* `__rozieReconcileAfterDomMutation(this)` on Lit + `void 0` elsewhere).
|
|
36
|
+
* The helper invokes the `afterCommit` callback after each `onCommit`;
|
|
37
|
+
* users wire `afterCommit: () => $reconcileAfterDomMutation()` to honour
|
|
38
|
+
* the Lit requirement without leaking lit-html internals into user code.
|
|
39
|
+
*
|
|
40
|
+
* Cross-target API contract
|
|
41
|
+
* -------------------------
|
|
42
|
+
* The helper is vanilla JS. It has NO framework imports. The same exported
|
|
43
|
+
* symbol resolves identically across React, Vue, Svelte, Angular, Solid and
|
|
44
|
+
* Lit consumer builds via the colocated relative `./internal/useSortableJS`
|
|
45
|
+
* copy vendored into each leaf package by scripts/codegen.mjs.
|
|
46
|
+
*
|
|
47
|
+
* Caller contract:
|
|
48
|
+
* - `items: () => T[]` is called fresh on every event. Wire it to the
|
|
49
|
+
* current snapshot (`$props.items` etc.).
|
|
50
|
+
* - `onCommit(next)` is called once per successful drag commit with the
|
|
51
|
+
* new items array. The caller writes it to whatever reactive surface
|
|
52
|
+
* drives re-renders.
|
|
53
|
+
* - `afterCommit?()` is called immediately after each `onCommit`. Used on
|
|
54
|
+
* Lit to invoke `$reconcileAfterDomMutation()`.
|
|
55
|
+
*
|
|
56
|
+
* Returns `{ destroy, instance }`. The caller wires
|
|
57
|
+
* - teardown via `return handle.destroy` from `$onMount`
|
|
58
|
+
* - runtime option updates via `instance.option('disabled', v)` from
|
|
59
|
+
* `$watch(() => $props.disabled, ...)`.
|
|
60
|
+
*
|
|
61
|
+
* @public
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import SortableJS, { type Options as SortableOptions, type SortableEvent } from 'sortablejs';
|
|
65
|
+
|
|
66
|
+
/** Symbol stashed on `e.item` between `onStart` and `onEnd` to ferry the
|
|
67
|
+
* dragged item DATA (not just the DOM node) across cross-list moves and
|
|
68
|
+
* survive `e.oldIndex` going null in fallback-mode events. The double
|
|
69
|
+
* underscore prefix signals "Rozie-internal, do not collide with user
|
|
70
|
+
* data". */
|
|
71
|
+
const ROZIE_ITEM_STASH_KEY = '__rozieItem';
|
|
72
|
+
|
|
73
|
+
/** Disambiguated event kind, exposed via `onChange`. */
|
|
74
|
+
export type SortableEventKind = 'reorder' | 'add' | 'remove';
|
|
75
|
+
|
|
76
|
+
/** Argument shape for the disambiguated onChange callback. */
|
|
77
|
+
export interface SortableChange<T> {
|
|
78
|
+
kind: SortableEventKind;
|
|
79
|
+
/** Source index. `-1` if it could not be determined (fragile-event path). */
|
|
80
|
+
oldIndex: number;
|
|
81
|
+
/** Destination index. `-1` if it could not be determined. */
|
|
82
|
+
newIndex: number;
|
|
83
|
+
/** The moved item. May be `undefined` only when both the stash AND the
|
|
84
|
+
* source list's snapshot lost track of it (extreme fallback path). */
|
|
85
|
+
item: T | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UseSortableJSOptions<T> {
|
|
89
|
+
/** Current items getter. Called fresh on every event for reactivity-safety. */
|
|
90
|
+
items: () => readonly T[];
|
|
91
|
+
/** Fires once per successful drag commit with the new items array. */
|
|
92
|
+
onCommit: (next: T[]) => void;
|
|
93
|
+
/** Optional: forwarded verbatim to SortableJS constructor.
|
|
94
|
+
* Any handler keys (`onStart`, `onEnd`, `onUpdate`, `onAdd`, `onRemove`)
|
|
95
|
+
* are silently ignored — the helper owns the event-handling path. Use
|
|
96
|
+
* `onStart` / `onEnd` / `onChange` on this options object instead. */
|
|
97
|
+
options?: SortableOptions;
|
|
98
|
+
/** Optional: fires on drag start. The dragged item DATA has already been
|
|
99
|
+
* stashed on `e.item.__rozieItem` by the time this fires. */
|
|
100
|
+
onStart?: (e: SortableEvent) => void;
|
|
101
|
+
/** Optional: fires on drag end after `onCommit` + `afterCommit`. */
|
|
102
|
+
onEnd?: (e: SortableEvent) => void;
|
|
103
|
+
/** Optional: fires once per commit with the disambiguated event kind. */
|
|
104
|
+
onChange?: (info: SortableChange<T>) => void;
|
|
105
|
+
/** Optional: called immediately after `onCommit`. Used on Lit to invoke
|
|
106
|
+
* `$reconcileAfterDomMutation()`. No-op on the other 5 targets. */
|
|
107
|
+
afterCommit?: () => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface UseSortableJSResult {
|
|
111
|
+
/** Teardown — call from `$onMount` return. */
|
|
112
|
+
destroy: () => void;
|
|
113
|
+
/** Live SortableJS instance — exposed for runtime
|
|
114
|
+
* `instance.option('disabled', v)` updates from `$watch`. */
|
|
115
|
+
instance: SortableJS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read the stashed item, falling back to a `null`/`undefined` when the
|
|
119
|
+
* element doesn't have the property. Centralised so the cast lives in
|
|
120
|
+
* exactly one place. */
|
|
121
|
+
function readStash<T>(item: unknown): T | undefined {
|
|
122
|
+
if (item === null || typeof item !== 'object') return undefined;
|
|
123
|
+
return (item as Record<string, unknown>)[ROZIE_ITEM_STASH_KEY] as T | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Try-catch wrapper for DOM operations that may throw on fragile event
|
|
127
|
+
* paths. Returning `false` lets the caller proceed to the model writeback
|
|
128
|
+
* — the framework's re-render off the new model brings the DOM back into
|
|
129
|
+
* sync (may flicker briefly, but vastly better than today's silent drop). */
|
|
130
|
+
function safeDom(op: () => void): boolean {
|
|
131
|
+
try {
|
|
132
|
+
op();
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
// The DOM-restore is best-effort. Proceed to model writeback regardless.
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wire a SortableJS instance to a reactive items array. See module doc
|
|
142
|
+
* for the full rationale + cross-target contract.
|
|
143
|
+
*/
|
|
144
|
+
export function useSortableJS<T>(
|
|
145
|
+
listEl: HTMLElement,
|
|
146
|
+
opts: UseSortableJSOptions<T>,
|
|
147
|
+
): UseSortableJSResult {
|
|
148
|
+
// Defensive merge: strip user-supplied handler keys from the verbatim
|
|
149
|
+
// `options` blob — we own those paths. We deliberately do NOT splat
|
|
150
|
+
// `opts.options` straight into the constructor argument unmodified, since
|
|
151
|
+
// a user-supplied `onUpdate` would silently bypass our reconciler dance.
|
|
152
|
+
const baseOptions: SortableOptions = { ...(opts.options ?? {}) };
|
|
153
|
+
|
|
154
|
+
/** Item-scoped child lookup. `listEl.children` now also includes the
|
|
155
|
+
* non-draggable `#header` / `#footer` slot holes (direct children of the
|
|
156
|
+
* list container), so the DOM-restore sites must index ONLY the item rows —
|
|
157
|
+
* `:scope > .rozie-sortable-item` — to keep the restore index item-relative
|
|
158
|
+
* and correct (no off-by-one from header/footer). */
|
|
159
|
+
const itemChildAt = (i: number): Element | null =>
|
|
160
|
+
listEl.querySelectorAll(':scope > .rozie-sortable-item')[i] ?? null;
|
|
161
|
+
// Build the explicit handler set — these always win over anything the
|
|
162
|
+
// caller passed in via `options`.
|
|
163
|
+
delete (baseOptions as Record<string, unknown>).onStart;
|
|
164
|
+
delete (baseOptions as Record<string, unknown>).onEnd;
|
|
165
|
+
delete (baseOptions as Record<string, unknown>).onUpdate;
|
|
166
|
+
delete (baseOptions as Record<string, unknown>).onAdd;
|
|
167
|
+
delete (baseOptions as Record<string, unknown>).onRemove;
|
|
168
|
+
// `onClone` is owned by the helper: we use it to bridge the
|
|
169
|
+
// `__rozieItem` stash from the original DOM node to the cloned node
|
|
170
|
+
// so the destination's `onAdd` can recover the dragged item DATA on
|
|
171
|
+
// a clone-mode drag. See `handleClone` below.
|
|
172
|
+
delete (baseOptions as Record<string, unknown>).onClone;
|
|
173
|
+
|
|
174
|
+
/** Stash dragged item DATA on `e.item` so cross-list moves can recover
|
|
175
|
+
* it on the destination side. */
|
|
176
|
+
const handleStart = (e: SortableEvent): void => {
|
|
177
|
+
const current = opts.items();
|
|
178
|
+
// The source index in this list (onStart fires from the source). Prefer the
|
|
179
|
+
// DRAGGABLE-relative index: with non-draggable element children present
|
|
180
|
+
// (Lit shadow-DOM `#header`/`#footer` `<slot>`s), plain `e.oldIndex` counts
|
|
181
|
+
// all children and is offset — stashing `current[oldIndex]` would capture
|
|
182
|
+
// the WRONG item (the row after the one actually grabbed), and the
|
|
183
|
+
// identity-based commit lookup would then faithfully move that wrong item.
|
|
184
|
+
// `oldDraggableIndex` counts only the `.rozie-sortable-item` rows.
|
|
185
|
+
const fromIdx =
|
|
186
|
+
typeof e.oldDraggableIndex === 'number'
|
|
187
|
+
? e.oldDraggableIndex
|
|
188
|
+
: typeof e.oldIndex === 'number'
|
|
189
|
+
? e.oldIndex
|
|
190
|
+
: -1;
|
|
191
|
+
if (fromIdx >= 0 && fromIdx < current.length) {
|
|
192
|
+
const item = e.item as unknown as Record<string, unknown> | null;
|
|
193
|
+
if (item !== null && typeof item === 'object') {
|
|
194
|
+
item[ROZIE_ITEM_STASH_KEY] = current[fromIdx];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
opts.onStart?.(e);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** The single point of truth for committed drags. Disambiguates via
|
|
201
|
+
* `e.from` and `e.to`.
|
|
202
|
+
*
|
|
203
|
+
* SortableJS's event lifecycle is asymmetric across lists: `onEnd` fires
|
|
204
|
+
* ONLY on the source list (the one that owned `e.item` at drag start).
|
|
205
|
+
* The destination list of a cross-list move receives an `onAdd` event,
|
|
206
|
+
* NOT an `onEnd`. We register this handler against BOTH:
|
|
207
|
+
* - source-list `onEnd` — handles same-list reorder + cross-list source
|
|
208
|
+
* - destination-list `onAdd` — handles cross-list destination
|
|
209
|
+
*
|
|
210
|
+
* The single function services both event names because the
|
|
211
|
+
* `from === listEl` / `to === listEl` disambiguation is sufficient to
|
|
212
|
+
* determine our role; SortableJS's event name doesn't carry additional
|
|
213
|
+
* information we need. Registering both is the load-bearing fix — the
|
|
214
|
+
* inline-handlers design DID register onAdd; collapsing to onEnd-only
|
|
215
|
+
* would lose the destination-side commit entirely. */
|
|
216
|
+
const handleCommit = (e: SortableEvent): void => {
|
|
217
|
+
try {
|
|
218
|
+
const fromUs = e.from === listEl;
|
|
219
|
+
const toUs = e.to === listEl;
|
|
220
|
+
const sameList = fromUs && toUs;
|
|
221
|
+
|
|
222
|
+
if (!fromUs && !toUs) {
|
|
223
|
+
// Not our event. Defensive — guards against the rare case where
|
|
224
|
+
// SortableJS would re-dispatch.
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const current = opts.items();
|
|
229
|
+
|
|
230
|
+
// Disambiguate kind for the onChange callback + computing the next array.
|
|
231
|
+
const kind: SortableEventKind = sameList ? 'reorder' : fromUs ? 'remove' : 'add';
|
|
232
|
+
|
|
233
|
+
// Identity-based item lookup beats fragile `e.oldIndex`. The stash was
|
|
234
|
+
// set on `onStart` and travels with `e.item` across lists (SortableJS
|
|
235
|
+
// physically moves the same node between lists).
|
|
236
|
+
const stashedItem = readStash<T>(e.item);
|
|
237
|
+
|
|
238
|
+
// Prefer the DRAGGABLE-relative indices. SortableJS computes plain
|
|
239
|
+
// `oldIndex`/`newIndex` via `index(dragEl)` — counting ALL element
|
|
240
|
+
// siblings — while `oldDraggableIndex`/`newDraggableIndex` use
|
|
241
|
+
// `index(dragEl, options.draggable)`, counting only the
|
|
242
|
+
// `.rozie-sortable-item` rows. When the list container also holds
|
|
243
|
+
// non-draggable element children, the plain indices are offset. This bites
|
|
244
|
+
// ONLY on Lit: the `#header` / `#footer` named slots render as real
|
|
245
|
+
// `<slot>` ELEMENTS inside the shadow-DOM list container (the 5 light-DOM
|
|
246
|
+
// targets render nothing for an empty named slot), so the leading
|
|
247
|
+
// `<slot name="header">` shifts every plain `newIndex` by +1 and the
|
|
248
|
+
// writeback dropped items one slot too far. The draggable indices stay
|
|
249
|
+
// item-relative everywhere; when no non-draggable children exist the two
|
|
250
|
+
// coincide, so the 5 light-DOM targets are unchanged.
|
|
251
|
+
const oldIndexHint =
|
|
252
|
+
typeof e.oldDraggableIndex === 'number'
|
|
253
|
+
? e.oldDraggableIndex
|
|
254
|
+
: typeof e.oldIndex === 'number'
|
|
255
|
+
? e.oldIndex
|
|
256
|
+
: -1;
|
|
257
|
+
const newIndexHint =
|
|
258
|
+
typeof e.newDraggableIndex === 'number'
|
|
259
|
+
? e.newDraggableIndex
|
|
260
|
+
: typeof e.newIndex === 'number'
|
|
261
|
+
? e.newIndex
|
|
262
|
+
: -1;
|
|
263
|
+
|
|
264
|
+
let next: T[];
|
|
265
|
+
let oldIndex: number;
|
|
266
|
+
let newIndex: number;
|
|
267
|
+
let moved: T | undefined;
|
|
268
|
+
|
|
269
|
+
if (sameList) {
|
|
270
|
+
// SAME-LIST REORDER
|
|
271
|
+
// Locate the moved item by identity first; fall back to the index hint.
|
|
272
|
+
const movedFromIdx =
|
|
273
|
+
stashedItem !== undefined && current.indexOf(stashedItem) !== -1
|
|
274
|
+
? current.indexOf(stashedItem)
|
|
275
|
+
: oldIndexHint;
|
|
276
|
+
if (movedFromIdx < 0 || movedFromIdx >= current.length) {
|
|
277
|
+
// Lost the source item entirely. Bail out cleanly.
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
moved = current[movedFromIdx] as T;
|
|
281
|
+
// Restore the moved DOM node back to its original position so the
|
|
282
|
+
// framework reconciler sees a clean model-vs-DOM shift. If the
|
|
283
|
+
// restore throws (fragile event), proceed to the model writeback —
|
|
284
|
+
// the re-render off the new model will reconcile.
|
|
285
|
+
safeDom(() => {
|
|
286
|
+
// Item-scoped: header/footer are non-item direct children of listEl,
|
|
287
|
+
// so index against the .rozie-sortable-item rows only.
|
|
288
|
+
const ref = itemChildAt(movedFromIdx);
|
|
289
|
+
listEl.insertBefore(e.item, ref);
|
|
290
|
+
});
|
|
291
|
+
next = [...current];
|
|
292
|
+
next.splice(movedFromIdx, 1);
|
|
293
|
+
const targetIdx =
|
|
294
|
+
newIndexHint >= 0 && newIndexHint <= next.length
|
|
295
|
+
? newIndexHint
|
|
296
|
+
: next.length;
|
|
297
|
+
next.splice(targetIdx, 0, moved);
|
|
298
|
+
oldIndex = movedFromIdx;
|
|
299
|
+
newIndex = targetIdx;
|
|
300
|
+
} else if (fromUs) {
|
|
301
|
+
// CLONE MODE — short-circuit. When SortableJS is in clone mode
|
|
302
|
+
// (`group.pull === 'clone'`), `e.pullMode === 'clone'` on cross-
|
|
303
|
+
// list events; the original DOM node STAYS in place and a fresh
|
|
304
|
+
// clone is what travels to the destination. The source list's
|
|
305
|
+
// items array is unchanged — no splice, no DOM-restore, and no
|
|
306
|
+
// `onChange({ kind: 'remove' })` (nothing was removed). The
|
|
307
|
+
// `__rozieItem` stash on the original is still cleaned up in
|
|
308
|
+
// the `finally` block below (`fromUs === true`), and the user's
|
|
309
|
+
// `onEnd` callback still fires from the finally — the drag did
|
|
310
|
+
// end on the source side.
|
|
311
|
+
if (e.pullMode === 'clone') {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// SOURCE SIDE of cross-list move — splice out of our items.
|
|
315
|
+
// Locate the moved item by identity (stash) first; fall back to oldIndex hint.
|
|
316
|
+
const movedFromIdx =
|
|
317
|
+
stashedItem !== undefined && current.indexOf(stashedItem) !== -1
|
|
318
|
+
? current.indexOf(stashedItem)
|
|
319
|
+
: oldIndexHint;
|
|
320
|
+
if (movedFromIdx < 0 || movedFromIdx >= current.length) {
|
|
321
|
+
// Lost the source item entirely. Bail.
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
moved = current[movedFromIdx] as T;
|
|
325
|
+
// Put the dragged DOM node back so our framework's re-render off
|
|
326
|
+
// the new (shorter) model removes it cleanly. If restore throws
|
|
327
|
+
// (fragile event — common path here when destination's onAdd
|
|
328
|
+
// already detached e.item), proceed to writeback anyway.
|
|
329
|
+
safeDom(() => {
|
|
330
|
+
// Item-scoped: header/footer are non-item direct children of listEl,
|
|
331
|
+
// so index against the .rozie-sortable-item rows only.
|
|
332
|
+
const ref = itemChildAt(movedFromIdx);
|
|
333
|
+
listEl.insertBefore(e.item, ref);
|
|
334
|
+
});
|
|
335
|
+
next = [...current];
|
|
336
|
+
next.splice(movedFromIdx, 1);
|
|
337
|
+
oldIndex = movedFromIdx;
|
|
338
|
+
newIndex = -1; // not applicable on the source side
|
|
339
|
+
} else {
|
|
340
|
+
// DESTINATION SIDE of cross-list move — splice INTO our items.
|
|
341
|
+
// Prefer the stash (the source list's item data); fall back to
|
|
342
|
+
// attempting to read it from the moved DOM if the stash was lost
|
|
343
|
+
// (extreme fallback — should not happen in practice).
|
|
344
|
+
moved = stashedItem;
|
|
345
|
+
if (moved === undefined) {
|
|
346
|
+
// Without a moved item we have nothing to insert. Remove the
|
|
347
|
+
// SortableJS-inserted node so the framework re-render is clean.
|
|
348
|
+
safeDom(() => {
|
|
349
|
+
const node = e.item as { remove?: () => void } | null;
|
|
350
|
+
node?.remove?.();
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Remove the SortableJS-inserted node — the framework re-render
|
|
355
|
+
// from the new (longer) model recreates the row properly.
|
|
356
|
+
safeDom(() => {
|
|
357
|
+
const node = e.item as { remove?: () => void } | null;
|
|
358
|
+
node?.remove?.();
|
|
359
|
+
});
|
|
360
|
+
next = [...current];
|
|
361
|
+
const targetIdx =
|
|
362
|
+
newIndexHint >= 0 && newIndexHint <= next.length
|
|
363
|
+
? newIndexHint
|
|
364
|
+
: next.length;
|
|
365
|
+
next.splice(targetIdx, 0, moved);
|
|
366
|
+
oldIndex = -1; // not applicable on the destination side
|
|
367
|
+
newIndex = targetIdx;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// SortableJS-internal-state hazard: calling `opts.onCommit(next)`
|
|
371
|
+
// SYNCHRONOUSLY here triggers framework re-renders (Solid/React/Vue/
|
|
372
|
+
// etc.) that may unmount sibling SortableList instances mid-drop.
|
|
373
|
+
// When the framework destroys a sibling SortableJS, that instance's
|
|
374
|
+
// `destroy()` calls `_onDrop()` which calls `_nulling()`, setting
|
|
375
|
+
// `Sortable.active = null` on the SortableJS module. The source
|
|
376
|
+
// list's pending `end` event is then guarded by `if (Sortable.active)`
|
|
377
|
+
// and skipped — losing the source-side commit on cross-list moves.
|
|
378
|
+
//
|
|
379
|
+
// Defer to a microtask so SortableJS's `_onDrop` can complete
|
|
380
|
+
// uninterrupted (both `add` and `end` events fire in their natural
|
|
381
|
+
// order) before any framework re-render runs. The DOM-restore step
|
|
382
|
+
// above has already happened synchronously, so the DOM and the
|
|
383
|
+
// model are kept in sync.
|
|
384
|
+
queueMicrotask(() => {
|
|
385
|
+
opts.onCommit(next);
|
|
386
|
+
opts.afterCommit?.();
|
|
387
|
+
opts.onChange?.({ kind, oldIndex, newIndex, item: moved });
|
|
388
|
+
});
|
|
389
|
+
} finally {
|
|
390
|
+
// The user's `onEnd` callback only fires from a true `onEnd` event
|
|
391
|
+
// (source-side). The destination's `onAdd` invocation routes
|
|
392
|
+
// through the same handler but should NOT fire `onEnd` again — the
|
|
393
|
+
// source list already will.
|
|
394
|
+
//
|
|
395
|
+
// Cleanup of the `__rozieItem` stash happens in the source's `onEnd`
|
|
396
|
+
// (`fromUs === true`) because that's the last guaranteed event in
|
|
397
|
+
// the SortableJS lifecycle for any drag. Cross-list `onAdd` runs
|
|
398
|
+
// BEFORE source `onEnd`, so the stash must remain readable on
|
|
399
|
+
// `e.item` until the source side completes.
|
|
400
|
+
const fromUs = e.from === listEl;
|
|
401
|
+
if (fromUs) {
|
|
402
|
+
const item = e.item as unknown as Record<string, unknown> | null;
|
|
403
|
+
if (item !== null && typeof item === 'object') {
|
|
404
|
+
delete item[ROZIE_ITEM_STASH_KEY];
|
|
405
|
+
}
|
|
406
|
+
opts.onEnd?.(e);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Bridge the `__rozieItem` stash from the original DOM node to its
|
|
412
|
+
* clone when SortableJS is in clone mode (`group.pull === 'clone'`).
|
|
413
|
+
*
|
|
414
|
+
* Clone-mode lifecycle:
|
|
415
|
+
* 1. `onStart` fires on the source — we stash the dragged item DATA
|
|
416
|
+
* on `e.item.__rozieItem`.
|
|
417
|
+
* 2. SortableJS clones the node — `clone` is the new DOM element
|
|
418
|
+
* that physically travels to the destination.
|
|
419
|
+
* 3. `onClone` fires with `{ item, clone }` — THIS HOOK — we copy
|
|
420
|
+
* the stash from `item` to `clone` so step 4 can find it.
|
|
421
|
+
* 4. `onAdd` fires on the destination with `e.item === clone`
|
|
422
|
+
* (NOT the original). Destination-side `handleCommit` reads
|
|
423
|
+
* `__rozieItem` off the clone via `readStash` → recovers the
|
|
424
|
+
* dragged item DATA, splices it into its array.
|
|
425
|
+
* 5. `onEnd` fires on the source with `e.item === original`.
|
|
426
|
+
* `handleCommit` (clone-mode short-circuit) returns early so
|
|
427
|
+
* no spurious `remove` change is fired; the `finally` cleans
|
|
428
|
+
* up the stash on the original.
|
|
429
|
+
*
|
|
430
|
+
* The `clone` DOM property is not in SortableJS's strict d.ts for
|
|
431
|
+
* `SortableEvent` (it's documented in their JS source but not the
|
|
432
|
+
* exported types), so we widen the parameter type locally. */
|
|
433
|
+
const handleClone = (e: SortableEvent & { clone: HTMLElement }): void => {
|
|
434
|
+
const original = e.item as unknown as Record<string, unknown> | null;
|
|
435
|
+
const clone = e.clone as unknown as Record<string, unknown> | null;
|
|
436
|
+
if (
|
|
437
|
+
original !== null &&
|
|
438
|
+
typeof original === 'object' &&
|
|
439
|
+
clone !== null &&
|
|
440
|
+
typeof clone === 'object' &&
|
|
441
|
+
ROZIE_ITEM_STASH_KEY in original
|
|
442
|
+
) {
|
|
443
|
+
clone[ROZIE_ITEM_STASH_KEY] = original[ROZIE_ITEM_STASH_KEY];
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const instance = new SortableJS(listEl, {
|
|
448
|
+
// Scope dragging to item rows only so the `#header` / `#footer` slot holes
|
|
449
|
+
// (non-draggable direct children of listEl) are skipped — keeps SortableJS's
|
|
450
|
+
// own e.oldIndex/e.newIndex hints item-relative. Placed BEFORE the spread so
|
|
451
|
+
// a consumer-supplied `options.draggable` still overrides it (pass-through).
|
|
452
|
+
draggable: '.rozie-sortable-item',
|
|
453
|
+
...baseOptions,
|
|
454
|
+
onStart: handleStart,
|
|
455
|
+
// SortableJS event lifecycle is asymmetric: `onEnd` fires only on the
|
|
456
|
+
// source list; cross-list destination receives `onAdd`. Wiring the
|
|
457
|
+
// same handler to both keeps the helper's "single handler with from/to
|
|
458
|
+
// disambiguation" contract consistent across same-list reorder
|
|
459
|
+
// (source onEnd), cross-list source (source onEnd), and cross-list
|
|
460
|
+
// destination (destination onAdd).
|
|
461
|
+
onEnd: handleCommit,
|
|
462
|
+
onAdd: handleCommit,
|
|
463
|
+
// Clone-mode stash bridge (see `handleClone` doc above). No-op on
|
|
464
|
+
// non-cloneable drags — SortableJS doesn't fire `onClone` outside
|
|
465
|
+
// clone mode.
|
|
466
|
+
onClone: handleClone,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
destroy: () => {
|
|
471
|
+
instance.destroy();
|
|
472
|
+
},
|
|
473
|
+
instance,
|
|
474
|
+
};
|
|
475
|
+
}
|