@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.
@@ -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
+ }