@celestia-island/hikari 0.43.8 → 0.43.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.8",
3
+ "version": "0.43.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1547,7 +1547,7 @@ describe("HkTagInput panel geometry and drag reordering", () => {
1547
1547
  expect(tagTexts(container)).toEqual(["Technology", "Germany", "News"]);
1548
1548
  });
1549
1549
 
1550
- it("ends a row drag when the host reorders the list under it", async () => {
1550
+ it("follows the row it grabbed when the host reorders the list under it", async () => {
1551
1551
  const { events, container, model } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1552
1552
  await openPanel(container);
1553
1553
  const selected = reorderableRows();
@@ -1555,8 +1555,9 @@ describe("HkTagInput panel geometry and drag reordering", () => {
1555
1555
 
1556
1556
  holdPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
1557
1557
  await settle();
1558
- // The host rewrites its own order out of band while the drag is held:
1559
- // the index the drag resolves by no longer names the row it grabbed.
1558
+ // The host rewrites its own order out of band while the drag is held: the
1559
+ // index the drag landed at names a different row now, but the row the
1560
+ // pointer is holding is still the one being moved.
1560
1561
  model.value = ["de", "news", "tech"];
1561
1562
  await settle();
1562
1563
  expect(rowLabels().slice(0, 3)).toEqual(["Germany", "News", "Technology"]);
@@ -1564,10 +1565,35 @@ describe("HkTagInput panel geometry and drag reordering", () => {
1564
1565
  releasePointer({ x: 10, y: 130 });
1565
1566
  await settle();
1566
1567
 
1567
- // The gesture ends without a reorder — a drag may come to nothing, it
1568
- // must never move whichever row slid into the index it was holding.
1569
- expect(events.modelValue, "no reorder came out of the rewritten strip").toEqual([]);
1570
- expect(tagTexts(container)).toEqual(["Germany", "News", "Technology"]);
1568
+ // "News" — the row the pointer grabbed — moved to the slot it was dragged
1569
+ // to, in the strip's own new order: the released gesture is never
1570
+ // silently replaced by a move of whichever row slid into that index.
1571
+ expect(events.modelValue.at(-1)).toEqual(["de", "tech", "news"]);
1572
+ expect(tagTexts(container)).toEqual(["Germany", "Technology", "News"]);
1573
+ });
1574
+
1575
+ it("keeps a dragged chip on the chip it grabbed when a × removes another one mid-drag", async () => {
1576
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1577
+ const strip = tags(container);
1578
+ pinStrip(strip, 60, "x");
1579
+
1580
+ // A second pair of eyes on the same list: the × of the FIRST chip is
1581
+ // clicked while the SECOND is being dragged. The field re-lays out under
1582
+ // the held pointer and the dragged chip slides one index left — so the
1583
+ // index the press landed at names the wrong chip by the time the pointer
1584
+ // comes up, and the drop has to stay on the chip the pointer is holding.
1585
+ holdPointer({ x: 70, y: 10 }, { x: 200, y: 10 }, strip[1]);
1586
+ await settle();
1587
+ closeButtons(container)[0].click();
1588
+ await settle();
1589
+ expect(tagTexts(container), "the × landed").toEqual(["Technology", "Germany"]);
1590
+
1591
+ releasePointer({ x: 200, y: 10 });
1592
+ await settle();
1593
+
1594
+ // "Technology" — the grabbed chip — lands last, in the field's own order.
1595
+ expect(events.modelValue.at(-1)).toEqual(["de", "tech"]);
1596
+ expect(tagTexts(container)).toEqual(["Germany", "Technology"]);
1571
1597
  });
1572
1598
 
1573
1599
  it("clamps a row drag to the selected group", async () => {
@@ -460,14 +460,15 @@ export const HkTagInput = defineComponent({
460
460
  const stop = activeStop.value;
461
461
  if (props.disabled || !stop || stop.kind !== "option") return false;
462
462
  // A live press OWNS the order — either list's, and from the moment
463
- // the press lands rather than from the moment it turns into a drag:
464
- // the drag resolves its landing slot by INDEX, so a reorder under the
465
- // held pointer (or under a press that is about to become one) would
466
- // leave the release moving whatever now sits at that index — a
467
- // different row, silently. The chord is still consumed by the caller
468
- // either way (Alt+Arrow is not a plain arrow, and the browser must
469
- // not act on it); it just has nothing to move while a pointer owns
470
- // the list.
463
+ // the press lands rather than from the moment it turns into a drag.
464
+ // The drag would survive a reorder (it moves the ELEMENT the press
465
+ // grabbed, re-reading its index as it goes), but the item's geometry
466
+ // is the one the press read: reordering the strip under a held pointer
467
+ // leaves the drag resolving against a place its item no longer
468
+ // occupies, which is how a gesture ends up quietly doing nothing. So
469
+ // the chord is inert while a pointer owns the list. It is still
470
+ // consumed by the caller either way (Alt+Arrow is not a plain arrow,
471
+ // and the browser must not act on it).
471
472
  if (chipDrag.pressed.value || rowDrag.pressed.value) return false;
472
473
  const group = selectedRows.value;
473
474
  const from = group.findIndex((option) => option.key === stop.option.key);
@@ -218,6 +218,172 @@ function scrollStrip(
218
218
  return { container, items };
219
219
  }
220
220
 
221
+ /** A strip whose frame IS the scroller it sits in — the frame's box, its
222
+ * LAID-OUT size (`offsetWidth`/`offsetHeight`, which no transform touches)
223
+ * and its scroll offsets all answer as a browser would, and `scene` lets a
224
+ * test scale and scroll the whole thing mid-gesture. */
225
+ function scrollerStrip(
226
+ count: number,
227
+ size: number,
228
+ height: number,
229
+ ): {
230
+ frame: HTMLElement;
231
+ items: HTMLElement[];
232
+ scene: { scale: number; scroll: number; laid: number };
233
+ } {
234
+ const frame = document.createElement("div");
235
+ document.body.appendChild(frame);
236
+ const scene = { scale: 1, scroll: 0, laid: count * size };
237
+ Object.defineProperty(frame, "offsetWidth", { configurable: true, get: () => scene.laid });
238
+ Object.defineProperty(frame, "offsetHeight", { configurable: true, get: () => height });
239
+ Object.defineProperty(frame, "scrollLeft", { configurable: true, get: () => scene.scroll });
240
+ Object.defineProperty(frame, "scrollTop", { configurable: true, get: () => 0 });
241
+ Object.defineProperty(frame, "getBoundingClientRect", {
242
+ configurable: true,
243
+ value: () =>
244
+ asRect({
245
+ left: 0,
246
+ right: scene.laid * scene.scale,
247
+ top: 0,
248
+ bottom: height * scene.scale,
249
+ }),
250
+ });
251
+ const items = Array.from({ length: count }, (_, i) => {
252
+ const el = document.createElement("div");
253
+ Object.defineProperty(el, "getBoundingClientRect", {
254
+ configurable: true,
255
+ value: () => {
256
+ const start = (i * size - scene.scroll) * scene.scale;
257
+ return asRect({
258
+ left: start,
259
+ right: start + size * scene.scale,
260
+ top: 0,
261
+ bottom: height * scene.scale,
262
+ });
263
+ },
264
+ });
265
+ frame.appendChild(el);
266
+ return el;
267
+ });
268
+ return { frame, items, scene };
269
+ }
270
+
271
+ /** A strip a BROWSER could have laid out: every element reports both of the
272
+ * readings a real engine keeps apart — the box it is DRAWN in
273
+ * (`getBoundingClientRect`, which every transform moves) and the box it is
274
+ * LAID OUT in (`offsetLeft`/`offsetTop`/`offsetWidth`/`offsetHeight` along
275
+ * an `offsetParent` chain, which no transform touches). One model drives
276
+ * both, exactly as a browser would, so a test can re-lay the strip out (a
277
+ * reorder, an insertion, a font change) or paint a transform on it and see
278
+ * which reading the resolution followed. */
279
+ function laidStrip(
280
+ layout: Box[],
281
+ frameLayout: { left: number; top: number; w: number; h: number },
282
+ frameDrawn: Box,
283
+ scene: {
284
+ scale?: number;
285
+ scroll?: number;
286
+ border?: { left: number; top: number };
287
+ /** The frame is `position: static`, so it is nobody's `offsetParent`:
288
+ * the items' layout numbers are then measured from further up — the
289
+ * fact that decides whether the frame's border still has to be added. */
290
+ staticFrame?: boolean;
291
+ } = {},
292
+ ): {
293
+ frame: HTMLElement;
294
+ items: HTMLElement[];
295
+ laid: Box[];
296
+ relayout: (next: Box[]) => void;
297
+ setFrameDrawn: (next: Box) => void;
298
+ } {
299
+ const frame = document.createElement("div");
300
+ document.body.appendChild(frame);
301
+ const live = layout.map((box) => ({ ...box }));
302
+ const pins: Array<() => void> = [];
303
+ const border = scene.border ?? { left: 0, top: 0 };
304
+ const sceneOf = () => ({ scale: scene.scale ?? 1, scroll: scene.scroll ?? 0 });
305
+ frame.style.borderLeftWidth = `${border.left}px`;
306
+ frame.style.borderTopWidth = `${border.top}px`;
307
+
308
+ /** The box a browser draws for a laid-out box inside this frame. */
309
+ const drawn = (box: Box): Box => {
310
+ const { scale, scroll } = sceneOf();
311
+ const left = frameDrawn.left + (box.left - frameLayout.left - scroll) * scale;
312
+ const top = frameDrawn.top + (box.top - frameLayout.top) * scale;
313
+ return {
314
+ left,
315
+ right: left + (box.right - box.left) * scale,
316
+ top,
317
+ bottom: top + (box.bottom - box.top) * scale,
318
+ };
319
+ };
320
+
321
+ let frameDrawnBox = frameDrawn;
322
+ Object.defineProperty(frame, "getBoundingClientRect", {
323
+ configurable: true,
324
+ value: () => asRect(frameDrawnBox),
325
+ });
326
+ Object.defineProperty(frame, "offsetWidth", { configurable: true, get: () => frameLayout.w });
327
+ Object.defineProperty(frame, "offsetHeight", { configurable: true, get: () => frameLayout.h });
328
+ Object.defineProperty(frame, "offsetParent", { configurable: true, get: () => null });
329
+ Object.defineProperty(frame, "offsetLeft", { configurable: true, get: () => frameLayout.left });
330
+ Object.defineProperty(frame, "offsetTop", { configurable: true, get: () => frameLayout.top });
331
+ Object.defineProperty(frame, "scrollLeft", { configurable: true, get: () => sceneOf().scroll });
332
+ Object.defineProperty(frame, "scrollTop", { configurable: true, get: () => 0 });
333
+
334
+ const items = live.map((box, i) => {
335
+ const el = document.createElement("div");
336
+ frame.appendChild(el);
337
+ Object.defineProperty(el, "offsetParent", {
338
+ configurable: true,
339
+ get: () => (scene.staticFrame ? null : frame),
340
+ });
341
+ // A positioned frame is the items' offsetParent, so their offsets are
342
+ // measured from its PADDING edge; a static one is skipped and they are
343
+ // measured from the same place as the frame itself.
344
+ Object.defineProperty(el, "offsetLeft", {
345
+ configurable: true,
346
+ get: () =>
347
+ scene.staticFrame ? live[i].left : live[i].left - frameLayout.left - border.left,
348
+ });
349
+ Object.defineProperty(el, "offsetTop", {
350
+ configurable: true,
351
+ get: () => (scene.staticFrame ? live[i].top : live[i].top - frameLayout.top - border.top),
352
+ });
353
+ Object.defineProperty(el, "offsetWidth", {
354
+ configurable: true,
355
+ get: () => live[i].right - live[i].left,
356
+ });
357
+ Object.defineProperty(el, "offsetHeight", {
358
+ configurable: true,
359
+ get: () => live[i].bottom - live[i].top,
360
+ });
361
+ const pin = () =>
362
+ Object.defineProperty(el, "getBoundingClientRect", {
363
+ configurable: true,
364
+ value: () => asRect(drawn(live[i])),
365
+ });
366
+ pin();
367
+ pins.push(pin);
368
+ return el;
369
+ });
370
+
371
+ return {
372
+ frame,
373
+ items,
374
+ laid: live,
375
+ relayout: (next: Box[]) => {
376
+ next.forEach((box, i) => {
377
+ live[i] = { ...box };
378
+ });
379
+ pins.forEach((pin) => pin());
380
+ },
381
+ setFrameDrawn: (next: Box) => {
382
+ frameDrawnBox = next;
383
+ },
384
+ };
385
+ }
386
+
221
387
  const scopes: EffectScope[] = [];
222
388
 
223
389
  /** Mount the composable inside a real effect scope (the component case). */
@@ -874,7 +1040,7 @@ describe("usePointerReorder", () => {
874
1040
 
875
1041
  press(handle, items, 0, { x: 10, y: 20 });
876
1042
  move(150, 20);
877
- items.reverse(); // the strip reorders under the drag: it must give way
1043
+ items.shift(); // the strip drops the very chip the pointer is holding
878
1044
  move(260, 20);
879
1045
  up(260, 20);
880
1046
  expect(drops).toEqual([]);
@@ -886,15 +1052,15 @@ describe("usePointerReorder", () => {
886
1052
  expect(clicks).toEqual(["row"]);
887
1053
  });
888
1054
 
889
- it("abandons a drag whose strip reorders under it rather than dropping a foreign item", () => {
890
- // The caller hands its items over in DISPLAY order, so the index a drag
891
- // resolves is only meaningful while that index still names the element
892
- // the press grabbed. A strip that reorders mid-gesture — a host edit
893
- // from outside, a keyboard reorder, a row toggled by a second pointer, a
894
- // leaving item that finally leaves — puts a different element there, and
895
- // a drop resolved from that index would move whichever item slid into
896
- // the slot: never the one the pointer is holding. The gesture ends
897
- // instead, without a reorder.
1055
+ it("follows the element it grabbed when the strip reorders under it", () => {
1056
+ // The caller hands its items over in DISPLAY order, and the index a drag
1057
+ // landed at is only the way the resolution names the element. A strip
1058
+ // that reorders mid-gesture — a host edit from outside, a row toggled by
1059
+ // a second pointer, a chip's ×, an Enter, a query that filters rows out —
1060
+ // moves the elements under those indices, and the drag has to follow the
1061
+ // ELEMENT: resolving the press-time index would move whichever item slid
1062
+ // into it, and giving way would throw away a gesture the user is still
1063
+ // holding.
898
1064
  const items = boxes([
899
1065
  { left: 0, right: 100, top: 0, bottom: 40 },
900
1066
  { left: 100, right: 200, top: 0, bottom: 40 },
@@ -907,16 +1073,473 @@ describe("usePointerReorder", () => {
907
1073
  expect(handle.dragging.value).toBe(true);
908
1074
  expect(handle.dragOver.value, "150 is past the first chip's midpoint").toBe(1);
909
1075
 
910
- // The strip reorders under the live drag: index 0 is another chip now.
911
- items.reverse();
1076
+ // The host moves the LAST chip to the front: the strip is now
1077
+ // [C, A, B], so the chip the pointer grabbed sits at index 1.
1078
+ const [grabbed, middle, last] = items;
1079
+ const moved = [last, grabbed, middle];
1080
+ moved.forEach((el, i) => reshape(el, { left: i * 100, right: i * 100 + 100, top: 0, bottom: 40 }));
1081
+ items.splice(0, items.length, ...moved);
912
1082
  move(260, 20);
913
- expect(handle.dragging.value, "the drag ended with the strip it was reading").toBe(false);
1083
+ expect(handle.dragging.value, "the drag is still the one the pointer started").toBe(true);
1084
+ expect(handle.dragFrom.value, "the lift follows the chip it grabbed").toBe(1);
1085
+ expect(handle.dragOver.value, "260 is past the second chip's midpoint of 250").toBe(2);
1086
+ up(260, 20);
1087
+ expect(drops, "the drop is reported in the indices the strip has now").toEqual([[1, 2]]);
1088
+ });
1089
+
1090
+ it("resolves against where the strip has RE-LAID the held item out", () => {
1091
+ // A font, a zoom or an insertion around the strip re-lays it out under a
1092
+ // live drag, so the item the pointer is holding is no longer where the
1093
+ // press found it. The snapshot describes the old place; the layout
1094
+ // numbers describe the new one, and the resolution has to follow the
1095
+ // strip the user is looking at. The chips here shrink mid-drag, which
1096
+ // brings the held chip's midpoint back behind the pointer: the walk that
1097
+ // the press-time midpoint stopped short of now runs past the strip.
1098
+ const laid: Box[] = [
1099
+ { left: 0, right: 100, top: 0, bottom: 40 },
1100
+ { left: 100, right: 200, top: 0, bottom: 40 },
1101
+ { left: 200, right: 300, top: 0, bottom: 40 },
1102
+ ];
1103
+ const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, { left: 0, right: 300, top: 0, bottom: 40 });
1104
+ const { handle, drops } = harness("x", strip.items);
1105
+
1106
+ press(handle, strip.items, 1, { x: 150, y: 20 });
1107
+ move(140, 20);
1108
+ expect(handle.dragging.value).toBe(true);
1109
+ expect(handle.dragOver.value, "the press-time midpoint is still ahead of the pointer").toBe(1);
1110
+
1111
+ // The strip is re-laid out under the drag: every chip, the held one
1112
+ // included, is drawn at its new place.
1113
+ strip.relayout([
1114
+ { left: 0, right: 50, top: 0, bottom: 40 },
1115
+ { left: 50, right: 100, top: 0, bottom: 40 },
1116
+ { left: 100, right: 150, top: 0, bottom: 40 },
1117
+ ]);
1118
+ move(140, 20);
1119
+ expect(handle.dragOver.value, "the pointer is past every chip of the new strip").toBe(2);
1120
+ up(140, 20);
1121
+ expect(drops).toEqual([[1, 2]]);
1122
+ });
1123
+
1124
+ it("ignores a transform painted on the held item while it follows the layout", () => {
1125
+ // The whole reason the snapshot exists: a transform on the item — the
1126
+ // drag's own lift, a host animation, a zoomed wrapper — is painted, not
1127
+ // laid out, and must not move the line or the midpoint the resolution
1128
+ // reads. The layout numbers are blind to it, so following the layout
1129
+ // costs nothing here: the same gesture resolves the same way with the
1130
+ // held chip drawn twice as large and shifted.
1131
+ const laid: Box[] = [
1132
+ { left: 0, right: 100, top: 0, bottom: 40 },
1133
+ { left: 100, right: 200, top: 0, bottom: 40 },
1134
+ { left: 200, right: 300, top: 0, bottom: 40 },
1135
+ ];
1136
+ const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, { left: 0, right: 300, top: 0, bottom: 40 });
1137
+ const { handle, drops } = harness("x", strip.items);
1138
+
1139
+ press(handle, strip.items, 0, { x: 10, y: 20 });
1140
+ move(150, 20);
1141
+ expect(handle.dragOver.value).toBe(1);
1142
+
1143
+ // A host transform lands on the held chip: an edge-anchored cross-axis
1144
+ // scale, plus a hard translation that carries its drawn box onto the line
1145
+ // below and its drawn midpoint far past where it is laid out — the class
1146
+ // of transform an earlier wave recorded as unfixable while the drawn box
1147
+ // was what got measured. The pointer sits inside the moved band (where
1148
+ // measuring the drawn box makes the held chip the only candidate line, so
1149
+ // the walk stops on its own translated midpoint) and past the second
1150
+ // chip's midpoint, which is where the layout answers "land after the
1151
+ // second chip".
1152
+ reshape(strip.items[0], { left: 100, right: 300, top: 40, bottom: 96 });
1153
+ move(180, 45);
1154
+ expect(handle.dragOver.value, "180 is past the second chip's midpoint of 150").toBe(1);
1155
+ up(180, 45);
1156
+ expect(drops).toEqual([[0, 1]]);
1157
+ });
1158
+
1159
+ it("carries the derived geometry through a frame that scrolls", () => {
1160
+ // The derivation reads a LAYOUT position and maps it through the box the
1161
+ // frame is drawn in, so the frame's own scroll (which slides the content
1162
+ // inside that box) has to be taken off in the same units. The strip is
1163
+ // scrolled mid-drag; the pointer stays where it is, over a different
1164
+ // chip of the content.
1165
+ const laid: Box[] = [
1166
+ { left: 0, right: 100, top: 0, bottom: 40 },
1167
+ { left: 100, right: 200, top: 0, bottom: 40 },
1168
+ { left: 200, right: 300, top: 0, bottom: 40 },
1169
+ { left: 300, right: 400, top: 0, bottom: 40 },
1170
+ ];
1171
+ const scene = { scale: 1, scroll: 0 };
1172
+ const strip = laidStrip(
1173
+ laid,
1174
+ { left: 0, top: 0, w: 400, h: 40 },
1175
+ { left: 0, right: 400, top: 0, bottom: 40 },
1176
+ scene,
1177
+ );
1178
+ const { handle, drops } = harness("x", strip.items);
1179
+
1180
+ press(handle, strip.items, 0, { x: 10, y: 20 });
1181
+ move(40, 20);
1182
+ expect(handle.dragOver.value, "40 is still short of the first chip's midpoint of 50").toBe(0);
1183
+
1184
+ scene.scroll = 120; // the frame scrolls the strip under the drag
1185
+ strip.relayout(laid); // re-pin the drawn boxes at the new scroll
1186
+ move(40, 20);
1187
+ expect(handle.dragOver.value, "the chips moved back under the pointer with the content").toBe(1);
1188
+ up(40, 20);
1189
+ expect(drops).toEqual([[0, 1]]);
1190
+ });
1191
+
1192
+ it("reads the frame's border the way the frame hands its layout numbers over", () => {
1193
+ // The items' layout numbers are measured from their offsetParent's
1194
+ // PADDING edge — so when the frame is that offsetParent its border has to
1195
+ // be added back, and when the frame is `position: static` (nobody's
1196
+ // offsetParent) the same numbers are already measured from its BORDER
1197
+ // edge and adding it again would count it twice. Both shapes here are
1198
+ // laid out identically and drawn identically: the resolution has to
1199
+ // answer the same for both, and it has to keep the border's own scale.
1200
+ const layout: Box[] = [
1201
+ { left: 20, right: 60, top: 20, bottom: 60 },
1202
+ { left: 80, right: 120, top: 20, bottom: 60 },
1203
+ { left: 140, right: 180, top: 20, bottom: 60 },
1204
+ { left: 200, right: 240, top: 20, bottom: 60 },
1205
+ ];
1206
+ const frameLayout = { left: 0, top: 0, w: 260, h: 80 };
1207
+ const frameDrawn: Box = { left: 0, right: 260, top: 0, bottom: 80 };
1208
+ const border = { left: 20, top: 20 };
1209
+ for (const staticFrame of [false, true]) {
1210
+ const strip = laidStrip(layout, frameLayout, frameDrawn, { border, staticFrame });
1211
+ const { handle, drops } = harness("x", strip.items);
1212
+ press(handle, strip.items, 0, { x: 30, y: 35 });
1213
+ move(102, 62);
1214
+ expect(handle.dragging.value, `staticFrame=${staticFrame}`).toBe(true);
1215
+ expect(handle.dragOver.value, `staticFrame=${staticFrame} lands after the first chip`).toBe(1);
1216
+ up(102, 62);
1217
+ expect(drops, `staticFrame=${staticFrame}`).toEqual([[0, 1]]);
1218
+ }
1219
+ });
1220
+
1221
+ it("resolves exactly as the held item's own drawn box says, across frame shapes", () => {
1222
+ // The differential form of the test above: for every shape a frame can
1223
+ // have — positioned or static, with or without a border, drawn at its own
1224
+ // size or scaled, scrolled or not, one line or two — the resolution has
1225
+ // to answer what the held item's TRUE layout box answers. The sweep walks
1226
+ // the pointer over each strip and compares against `indexAt` handed that
1227
+ // true box, so any place the derived geometry drifts shows up wherever
1228
+ // the drift can be observed, without hand-picking pointers.
1229
+ const single: Box[] = [
1230
+ { left: 12, right: 52, top: 12, bottom: 52 },
1231
+ { left: 72, right: 112, top: 12, bottom: 52 },
1232
+ { left: 132, right: 172, top: 12, bottom: 52 },
1233
+ ];
1234
+ const wrapped: Box[] = [
1235
+ { left: 12, right: 52, top: 12, bottom: 52 },
1236
+ { left: 72, right: 112, top: 12, bottom: 52 },
1237
+ { left: 12, right: 52, top: 72, bottom: 112 },
1238
+ ];
1239
+ const scenes = [
1240
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: {} },
1241
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 5, right: 235, top: 7, bottom: 112 }, scene: { scale: 1.5 } },
1242
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 5, right: 235, top: 7, bottom: 112 }, scene: { scale: 1.5, border: { left: 12, top: 12 } } },
1243
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 5, right: 235, top: 7, bottom: 112 }, scene: { scale: 1.5, border: { left: 12, top: 12 }, staticFrame: true } },
1244
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: { border: { left: 12, top: 12 } } },
1245
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: { border: { left: 12, top: 12 }, staticFrame: true } },
1246
+ { layout: single, frame: { left: 0, top: 0, w: 200, h: 70 }, drawn: { left: 0, right: 200, top: 0, bottom: 70 }, scene: { scroll: 25 } },
1247
+ { layout: wrapped, frame: { left: 0, top: 0, w: 200, h: 130 }, drawn: { left: 0, right: 200, top: 0, bottom: 130 }, scene: {} },
1248
+ { layout: wrapped, frame: { left: 0, top: 0, w: 200, h: 130 }, drawn: { left: 0, right: 300, top: 0, bottom: 195 }, scene: { scale: 1.5, border: { left: 8, top: 8 } } },
1249
+ ];
1250
+ let compared = 0;
1251
+ for (const [index, entry] of scenes.entries()) {
1252
+ const strip = laidStrip(entry.layout, entry.frame, entry.drawn, entry.scene);
1253
+ const rects = strip.items.map((el) => el.getBoundingClientRect());
1254
+ // The line filter's tolerance is half a pixel wide, so the sweep has to
1255
+ // land ON the band edges, not only near them.
1256
+ const crosses = new Set<number>();
1257
+ for (let cross = -10; cross <= 140; cross += 5) crosses.add(cross);
1258
+ for (const rect of rects) {
1259
+ for (const edge of [rect.top, rect.bottom]) {
1260
+ for (const delta of [-2, -1, -0.5, 0, 0.5, 1, 2]) crosses.add(Number((edge + delta).toFixed(2)));
1261
+ }
1262
+ }
1263
+ for (let from = 0; from < strip.items.length; from += 1) {
1264
+ // The held item's own layout box, drawn — the geometry the resolution
1265
+ // is supposed to be reading.
1266
+ const truth = strip.items[from].getBoundingClientRect();
1267
+ const trueOrigin = {
1268
+ band: { lo: truth.top, hi: truth.bottom },
1269
+ mid: truth.left + truth.width / 2,
1270
+ };
1271
+ for (let main = -20; main <= 240; main += 20) {
1272
+ for (const cross of crosses) {
1273
+ const { handle } = harness("x", strip.items);
1274
+ press(handle, strip.items, from, { x: main - 40, y: cross });
1275
+ move(main, cross);
1276
+ const got = handle.dragOver.value;
1277
+ const want = indexAt(rects, "x", main, cross, from, trueOrigin);
1278
+ expect(got, `scene ${index} from ${from} at (${main}, ${cross})`).toBe(want);
1279
+ up(main, cross);
1280
+ compared += 1;
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ expect(compared, "the sweep really ran").toBeGreaterThan(1000);
1286
+ }, 20_000);
1287
+
1288
+ it("keeps the carried snapshot when only half of the box can be read", () => {
1289
+ // A box that reports one side and not the other cannot place anything: a
1290
+ // zero width would put the midpoint on the item's own edge. The gesture
1291
+ // here is one the derivation WOULD answer differently — the strip is
1292
+ // re-laid out under the drag — so the assertion is the carried
1293
+ // snapshot's answer, which is what a strip with no layout numbers at all
1294
+ // gets.
1295
+ const laid: Box[] = [
1296
+ { left: 0, right: 100, top: 0, bottom: 40 },
1297
+ { left: 100, right: 200, top: 0, bottom: 40 },
1298
+ { left: 200, right: 300, top: 0, bottom: 40 },
1299
+ ];
1300
+ const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, {
1301
+ left: 0,
1302
+ right: 300,
1303
+ top: 0,
1304
+ bottom: 40,
1305
+ });
1306
+ Object.defineProperty(strip.items[1], "offsetWidth", { configurable: true, get: () => 0 });
1307
+ const { handle, drops } = harness("x", strip.items);
1308
+
1309
+ press(handle, strip.items, 1, { x: 150, y: 20 });
1310
+ move(140, 20);
1311
+ expect(handle.dragging.value).toBe(true);
1312
+ expect(handle.dragOver.value, "the press-time midpoint still stops the walk").toBe(1);
1313
+
1314
+ strip.relayout([
1315
+ { left: 0, right: 50, top: 0, bottom: 40 },
1316
+ { left: 50, right: 100, top: 0, bottom: 40 },
1317
+ { left: 100, right: 150, top: 0, bottom: 40 },
1318
+ ]);
1319
+ move(140, 20);
1320
+ expect(handle.dragOver.value, "half a box is no box: the snapshot still resolves").toBe(1);
1321
+ up(140, 20);
1322
+ expect(drops).toEqual([]);
1323
+ });
1324
+
1325
+ it("keeps the carried snapshot when the layout chain cannot be added up", () => {
1326
+ // A chain that runs through something with no offsets of its own — an
1327
+ // element inside `<svg>` reports no `offsetLeft` at all — sums to NaN,
1328
+ // and a NaN origin answers every comparison the same way: it would place
1329
+ // the item at the end of the strip. The chain is checked, and the gesture
1330
+ // falls back to the snapshot.
1331
+ const laid: Box[] = [
1332
+ { left: 0, right: 100, top: 0, bottom: 40 },
1333
+ { left: 100, right: 200, top: 0, bottom: 40 },
1334
+ { left: 200, right: 300, top: 0, bottom: 40 },
1335
+ ];
1336
+ const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, {
1337
+ left: 0,
1338
+ right: 300,
1339
+ top: 0,
1340
+ bottom: 40,
1341
+ });
1342
+ const { handle, drops } = harness("x", strip.items);
1343
+
1344
+ press(handle, strip.items, 1, { x: 150, y: 20 });
1345
+ move(140, 20);
1346
+ expect(handle.dragOver.value).toBe(1);
1347
+
1348
+ // The held chip's chain now passes through an element with no offsets.
1349
+ const svgish = document.createElement("div");
1350
+ Object.defineProperty(svgish, "offsetLeft", { configurable: true, get: () => undefined });
1351
+ Object.defineProperty(svgish, "offsetTop", { configurable: true, get: () => undefined });
1352
+ Object.defineProperty(svgish, "offsetParent", {
1353
+ configurable: true,
1354
+ get: () => strip.frame,
1355
+ });
1356
+ Object.defineProperty(strip.items[1], "offsetParent", {
1357
+ configurable: true,
1358
+ get: () => svgish,
1359
+ });
1360
+ strip.relayout([
1361
+ { left: 0, right: 50, top: 0, bottom: 40 },
1362
+ { left: 50, right: 100, top: 0, bottom: 40 },
1363
+ { left: 100, right: 150, top: 0, bottom: 40 },
1364
+ ]);
1365
+ move(140, 20);
1366
+ expect(handle.dragOver.value, "an unaddable chain keeps the snapshot's answer").toBe(1);
1367
+ up(140, 20);
1368
+ expect(drops).toEqual([]);
1369
+ });
1370
+
1371
+ it("keeps the carried snapshot when the held item collapses", () => {
1372
+ // A held chip re-laid out to no height has no line to be on: its derived
1373
+ // band would be a point and the line filter would drop it, which is a
1374
+ // resolution the layout cannot justify. The fallback answers instead.
1375
+ const laid: Box[] = [
1376
+ { left: 0, right: 100, top: 0, bottom: 40 },
1377
+ { left: 100, right: 200, top: 0, bottom: 40 },
1378
+ { left: 200, right: 300, top: 0, bottom: 40 },
1379
+ ];
1380
+ const strip = laidStrip(laid, { left: 0, top: 0, w: 300, h: 40 }, {
1381
+ left: 0,
1382
+ right: 300,
1383
+ top: 0,
1384
+ bottom: 40,
1385
+ });
1386
+ const { handle, drops } = harness("x", strip.items);
1387
+
1388
+ press(handle, strip.items, 1, { x: 150, y: 20 });
1389
+ move(140, 20);
1390
+ expect(handle.dragOver.value).toBe(1);
1391
+
1392
+ strip.relayout([
1393
+ { left: 0, right: 50, top: 0, bottom: 40 },
1394
+ { left: 50, right: 100, top: 20, bottom: 20 },
1395
+ { left: 100, right: 150, top: 0, bottom: 40 },
1396
+ ]);
1397
+ move(140, 20);
1398
+ expect(handle.dragOver.value, "a chip with no height has no line to stand on").toBe(1);
1399
+ up(140, 20);
1400
+ expect(drops).toEqual([]);
1401
+ });
1402
+
1403
+ it("ends the gesture the moment the strip empties", () => {
1404
+ // Nothing left to resolve against is the same answer as the element being
1405
+ // gone: the gesture is over then, not at the release — a drag left
1406
+ // "running" against an empty strip would keep publishing a slot it can no
1407
+ // longer read, and would not swallow the click it owes.
1408
+ const items = boxes([
1409
+ { left: 0, right: 100, top: 0, bottom: 40 },
1410
+ { left: 100, right: 200, top: 0, bottom: 40 },
1411
+ { left: 200, right: 300, top: 0, bottom: 40 },
1412
+ ]);
1413
+ const { handle, drops } = harness("x", items);
1414
+ const inner = document.createElement("span");
1415
+ items[0].appendChild(inner);
1416
+ const clicks: string[] = [];
1417
+ inner.addEventListener("click", () => clicks.push("row"));
1418
+
1419
+ press(handle, items, 0, { x: 10, y: 20 });
1420
+ move(150, 20);
1421
+ expect(handle.dragging.value).toBe(true);
1422
+
1423
+ items.splice(0, items.length); // every item goes at once
1424
+ move(260, 20);
1425
+ expect(handle.dragging.value, "the gesture ended with the strip").toBe(false);
1426
+ expect(handle.dragFrom.value).toBe(-1);
1427
+ expect(handle.dragOver.value).toBe(-1);
1428
+ up(260, 20);
1429
+ expect(drops).toEqual([]);
1430
+ inner.dispatchEvent(new MouseEvent("click", { bubbles: true }));
1431
+ expect(clicks, "…and its trailing click was swallowed").toEqual([]);
1432
+ });
1433
+
1434
+ it("abandons a drag whose pressed item leaves the strip", () => {
1435
+ // The other side of the same rule: a strip that no longer carries the
1436
+ // element at all — it was removed, or a drag of it can no longer land
1437
+ // anywhere — has nothing for the gesture to move, so it ends without a
1438
+ // reorder.
1439
+ const items = boxes([
1440
+ { left: 0, right: 100, top: 0, bottom: 40 },
1441
+ { left: 100, right: 200, top: 0, bottom: 40 },
1442
+ { left: 200, right: 300, top: 0, bottom: 40 },
1443
+ ]);
1444
+ const { handle, drops } = harness("x", items);
1445
+
1446
+ press(handle, items, 0, { x: 10, y: 20 });
1447
+ move(150, 20);
1448
+ expect(handle.dragOver.value).toBe(1);
1449
+
1450
+ items.shift(); // the chip the pointer is holding is gone
1451
+ move(260, 20);
1452
+ expect(handle.dragging.value, "the drag ended with the strip").toBe(false);
914
1453
  expect(handle.dragFrom.value).toBe(-1);
915
1454
  expect(handle.dragOver.value).toBe(-1);
916
1455
  up(260, 20);
917
1456
  expect(drops, "nothing was dropped").toEqual([]);
918
1457
  });
919
1458
 
1459
+ it("carries the snapshot exactly through a frame that scrolls AND is scaled", () => {
1460
+ // The frame here is the scroller the strip SITS IN — the shape this
1461
+ // composable documents — and the host zooms it mid-drag while it is
1462
+ // scrolled. The frame's own scroll slides the content inside a box that
1463
+ // does not move, and it does so in the frame's LAYOUT units: taken off
1464
+ // the drawn box as a raw offset it is measured at the wrong magnitude
1465
+ // the moment the frame is scaled (by `(scale - 1) x scroll`, here 300px
1466
+ // on a 2x zoom), and the snapshot drifts off the item it belongs to. The
1467
+ // pointer here is past the third chip and before the fourth, so the
1468
+ // drifted snapshot answers "stay where you are" where the strip answers
1469
+ // "land after the fourth".
1470
+ const { items, scene } = scrollerStrip(5, 100, 40);
1471
+ const { handle, drops } = harness("x", items);
1472
+
1473
+ press(handle, items, 2, { x: 250, y: 20 });
1474
+ scene.scale = 2; // the host zooms the strip…
1475
+ scene.scroll = 300; // …while it is scrolled under the live drag
1476
+ move(100, 20);
1477
+ expect(handle.dragging.value).toBe(true);
1478
+ expect(handle.dragOver.value, "past the third chip, before the fourth").toBe(3);
1479
+ up(100, 20);
1480
+ expect(drops).toEqual([[2, 3]]);
1481
+ });
1482
+
1483
+ it("reads a LAYOUT change as a layout change, not as a scale", () => {
1484
+ // The frame's own box growing is not the frame being drawn bigger: the
1485
+ // chips keep their place and their size, and the frame merely got wider
1486
+ // around them. A carrier that reads the drawn box alone cannot tell the
1487
+ // two apart, and would stretch the snapshot by the ratio of the two
1488
+ // sizes — here it would answer "stay where you are" for a pointer the
1489
+ // strip answers "land after the third chip".
1490
+ const { items, scene } = scrollerStrip(5, 100, 40);
1491
+ const { handle, drops } = harness("x", items);
1492
+
1493
+ press(handle, items, 1, { x: 150, y: 20 });
1494
+ scene.laid = 1000; // the frame is re-laid out around the same chips
1495
+ move(260, 20);
1496
+ expect(handle.dragging.value).toBe(true);
1497
+ expect(handle.dragOver.value, "260 is past the third chip's midpoint of 250").toBe(2);
1498
+ up(260, 20);
1499
+ expect(drops).toEqual([[1, 2]]);
1500
+ });
1501
+
1502
+ it("carries the snapshot in the nearest frame that has a box", () => {
1503
+ // A strip wrapped in an element that generates no box of its own
1504
+ // (`display: contents`) is laid out by the next box up, and that is the
1505
+ // box its content moves with: reading the boxless wrapper leaves the
1506
+ // snapshot behind the moment the page scrolls the real frame.
1507
+ const frame = document.createElement("div");
1508
+ const wrapper = document.createElement("div");
1509
+ frame.appendChild(wrapper);
1510
+ document.body.appendChild(frame);
1511
+ const scene = { shift: 0 };
1512
+ reshape(frame, { left: 0, right: 300, top: 0, bottom: 40 });
1513
+ Object.defineProperty(frame, "getBoundingClientRect", {
1514
+ configurable: true,
1515
+ value: () => {
1516
+ const box = { left: 0, right: 300, top: scene.shift, bottom: scene.shift + 40 };
1517
+ return asRect(box);
1518
+ },
1519
+ });
1520
+ // The wrapper itself: no box at all, exactly as `display: contents` reports.
1521
+ reshape(wrapper, { left: 0, right: 0, top: 0, bottom: 0 });
1522
+ const items = [0, 100, 200].map((start) => {
1523
+ const el = document.createElement("div");
1524
+ Object.defineProperty(el, "getBoundingClientRect", {
1525
+ configurable: true,
1526
+ value: () =>
1527
+ asRect({ left: start, right: start + 100, top: scene.shift, bottom: scene.shift + 40 }),
1528
+ });
1529
+ wrapper.appendChild(el);
1530
+ return el;
1531
+ });
1532
+ const { handle, drops } = harness("x", items);
1533
+
1534
+ press(handle, items, 0, { x: 10, y: 20 });
1535
+ scene.shift = 40; // the page scrolls the strip a line up under the drag
1536
+ move(150, 20);
1537
+ expect(handle.dragging.value).toBe(true);
1538
+ expect(handle.dragOver.value, "the chips kept their places inside the frame").toBe(1);
1539
+ up(150, 20);
1540
+ expect(drops).toEqual([[0, 1]]);
1541
+ });
1542
+
920
1543
  it("keeps the pressed item's geometry on the content when the frame is SCALED under it", () => {
921
1544
  // A `scale()` or a zoom painted on the frame — or on an ancestor — during
922
1545
  // a live drag is not a movement the frame's DISPLACEMENT can describe:
@@ -75,8 +75,10 @@ export interface PointerReorder {
75
75
  * crossed the threshold into a drag. The strip is this gesture's until
76
76
  * it ends, so a caller that can change the order by other means (the
77
77
  * panel's own keyboard reorder, a host edit) can hold off while this is
78
- * `true`: the drag resolves by INDEX, and an order that moves under it
79
- * is an order the release would misread. */
78
+ * `true`: the drag moves the element the press grabbed and the snapshot
79
+ * it resolves by is the one the press read, so an order that reorders the
80
+ * strip under the gesture is an order resolved against geometry its item
81
+ * no longer occupies. */
80
82
  pressed: Ref<boolean>;
81
83
  /** Watch a press on item `index` (item 0 of `items()` is index 0).
82
84
  * Nothing is reported until the pointer travels past the threshold,
@@ -191,7 +193,13 @@ export function indexAt(
191
193
  * before the lift is painted on it (`start`) and carried along with the
192
194
  * frame it was read in, however that frame moves under the drag — so no
193
195
  * transform an item picks up mid-drag can move the line it is dragged along
194
- * or the midpoint it is compared against.
196
+ * or the midpoint it is compared against. The drag is moved BY ELEMENT: the
197
+ * press records the element it landed on and every resolution re-reads that
198
+ * element's index from the live strip, so a list that reorders under the
199
+ * gesture (a host edit, a second pointer, a removal elsewhere) keeps the
200
+ * drop on what the pointer is holding instead of on whatever slid into the
201
+ * index it started at — and a gesture whose element is gone from the strip
202
+ * ends without a reorder.
195
203
  *
196
204
  * The threshold is what keeps the gesture honest on a control that is also
197
205
  * clickable: a press under ~6px is not a drag at all — no state is
@@ -236,15 +244,19 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
236
244
  let lastMain = 0;
237
245
  let lastCross = 0;
238
246
  /** The pressed item's LAYOUT geometry (`null` until a press reads it), the
239
- * FRAME it was laid out in (its parent element) and where that frame sat
240
- * when the geometry was read — the whole drag resolves against this
241
- * instead of the item's live rect, which carries the lift. */
247
+ * FRAME it was laid out in (its parent element) and that frame's reading
248
+ * when the geometry was taken — the whole drag resolves against this
249
+ * instead of the item's live rect, which carries the lift. The geometry
250
+ * stays the press's even if the ELEMENT is re-anchored to another index
251
+ * mid-gesture (`slotAt`): the pointer grabbed it where it was. */
242
252
  let pressedBand: { lo: number; hi: number } | null = null;
243
253
  let pressedMid = 0;
244
254
  let pressedFrame: HTMLElement | null = null;
245
- let pressedFrameBox: { x: number; y: number; w: number; h: number } | null = null;
246
- /** The very ELEMENT the press landed on: the index the drag resolves by
247
- * is only meaningful while that index still names it (see `slotAt`). */
255
+ let pressedFrameBox: FrameBox | null = null;
256
+ /** The very ELEMENT the press landed on. The drag is moved BY element:
257
+ * the index is re-read from it on every resolution, so a strip that
258
+ * reorders under the gesture keeps the drop on what the pointer grabbed
259
+ * (`slotAt`). */
248
260
  let pressedItem: HTMLElement | null = null;
249
261
  /** Auto-scroll: px per frame (signed: negative scrolls back), 0 = off. */
250
262
  let scrollVelocity = 0;
@@ -262,87 +274,219 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
262
274
  return options.items().filter((el): el is HTMLElement => el != null);
263
275
  }
264
276
 
265
- /** The slot the live strip resolves for a pointer at (`main`, `cross`),
266
- * starting from index `from` — the elements measured, then handed to
267
- * `indexAt`, which carries the geometry's rules. One thing is checked
268
- * first: that the INDEX still names the element the press grabbed — a
269
- * keyboard reorder, a host edit, a row toggled by a second pointer or a
270
- * leaving item finally leaving all put a different element there. When it
271
- * does not, the gesture is abandoned rather than resolved: a drag may end
272
- * without a reorder, it never lands on an item the pointer did not
273
- * grab. */
274
- function slotAt(main: number, cross: number, from: number): number {
277
+ /** The slot the live strip resolves for a pointer at (`main`, `cross`).
278
+ * The press is on an ELEMENT, and the index it landed at is only the way
279
+ * the resolution names it — so the index is re-read from the element
280
+ * first: anything that reorders the strip under the gesture (a host edit,
281
+ * a row toggled by a second pointer, a chip's ×, an Enter, a query that
282
+ * filters rows out) moves the elements under those indices, while the
283
+ * element the pointer grabbed is still the one being dragged. The
284
+ * resolution then follows IT, and the caller's `onDrop` gets the indices
285
+ * the strip has now. Only a strip that no longer carries the element at
286
+ * all — it was removed, or a drag of it can no longer land anywhere —
287
+ * ends the gesture, and it ends without a reorder. */
288
+ function slotAt(main: number, cross: number): number {
275
289
  const items = liveItems();
276
- if (items.length === 0) return -1;
277
- if (pressedItem && items[pressedIndex] !== pressedItem) {
290
+ if (items.length === 0) {
278
291
  abandonDrag();
279
292
  return -1;
280
293
  }
294
+ if (pressedItem) {
295
+ const index = items.indexOf(pressedItem);
296
+ if (index < 0) {
297
+ abandonDrag();
298
+ return -1;
299
+ }
300
+ if (index !== pressedIndex) {
301
+ pressedIndex = index;
302
+ if (dragging.value) dragFrom.value = index;
303
+ }
304
+ }
281
305
  return indexAt(
282
306
  items.map((item) => item.getBoundingClientRect()),
283
307
  axis,
284
308
  main,
285
309
  cross,
286
- from,
310
+ pressedIndex,
287
311
  layoutOrigin(),
288
312
  );
289
313
  }
290
314
 
291
- /** Where a frame's CONTENT sits in viewport coordinates — the box the
292
- * item is laid out in, minus that box's own scroll, which is the point
293
- * its children move with — and how big that box is. A scroller the items
294
- * sit directly in scrolls its children without moving its own box, so
295
- * the scroll has to come off explicitly; for every other element the
296
- * offsets are 0 and this is its box origin.
297
- *
298
- * The scroll comes off at its own scale, which is right whenever the
299
- * frame is not ALSO transformed: this is a raw offset against a rect the
300
- * browser has already transformed, so a `scale()` on a frame that is
301
- * itself a scroller AND is scaled mid-drag would take it off at the
302
- * wrong magnitude — out of contract here, and the shapes this is written
303
- * for (a wrapping field, a panel list) are not scrollers at all: the
304
- * surface that scrolls them is their ancestor, and an ancestor's scroll
305
- * moves the frame's box like any other translation. */
306
- function frameBox(frame: HTMLElement): { x: number; y: number; w: number; h: number } {
315
+ /**
316
+ * Everything the frame says about where it puts a coordinate: the box the
317
+ * item is laid out in — where it is drawn (`x`/`y`), how big it is drawn
318
+ * (`w`/`h`) and how big it is LAID OUT (`laidW`/`laidH`, which no
319
+ * transform touches, so the two together are the scale it is drawn at) —
320
+ * and the scroll that slides its children inside that box.
321
+ */
322
+ interface FrameBox {
323
+ x: number;
324
+ y: number;
325
+ w: number;
326
+ h: number;
327
+ laidW: number;
328
+ laidH: number;
329
+ scrollX: number;
330
+ scrollY: number;
331
+ }
332
+
333
+ /** The nearest ancestor of `item` that is actually LAID OUT. A snapshot
334
+ * can only be carried in a frame that has a box to move it: an element
335
+ * with `display: contents` generates none, and its children are laid out
336
+ * by the next box up — which is the frame whose movement they follow. A
337
+ * tree with no boxes anywhere (no layout engine at all, a test
338
+ * environment) walks out of ancestors and keeps the parent it started
339
+ * with. */
340
+ function layoutFrameOf(item: HTMLElement): HTMLElement | null {
341
+ for (let el = item.parentElement; el; el = el.parentElement) {
342
+ const rect = el.getBoundingClientRect();
343
+ if (rect.width > 0 || rect.height > 0 || el.offsetWidth > 0 || el.offsetHeight > 0) return el;
344
+ }
345
+ return item.parentElement;
346
+ }
347
+
348
+ /** Read the frame as it is right now (see `FrameBox`). */
349
+ function frameBox(frame: HTMLElement): FrameBox {
307
350
  const rect = frame.getBoundingClientRect();
308
351
  return {
309
- x: rect.left - frame.scrollLeft,
310
- y: rect.top - frame.scrollTop,
352
+ x: rect.left,
353
+ y: rect.top,
311
354
  w: rect.width,
312
355
  h: rect.height,
356
+ laidW: frame.offsetWidth,
357
+ laidH: frame.offsetHeight,
358
+ scrollX: frame.scrollLeft,
359
+ scrollY: frame.scrollTop,
313
360
  };
314
361
  }
315
362
 
363
+ /** Where an element's border box sits in a space its ancestors share: the
364
+ * one number CSS transforms do not touch. `offsetLeft`/`offsetTop` are
365
+ * LAYOUT positions — the drag's own lift, a host `scale()`, a FLIP
366
+ * animation and a transition mid-flight all leave them exactly where they
367
+ * were — so walking the offsetParent chain of the item and of its frame
368
+ * and subtracting gives the item's current place inside the frame however
369
+ * it is drawn. Both sides are walked to the same root, so a frame that is
370
+ * not itself an offsetParent (a static frame) cancels exactly.
371
+ *
372
+ * `through` reports whether the walk passed THROUGH the frame, which is
373
+ * what decides how the two differ: an offset is measured from the
374
+ * offsetParent's PADDING edge, so a chain that runs through the frame
375
+ * leaves the difference measured from the frame's padding box (its border
376
+ * has to be added back), while a chain that skips it — a frame that is
377
+ * `position: static` is nobody's offsetParent — leaves the difference
378
+ * measured from the frame's BORDER box, where adding the border again
379
+ * would double-count it.
380
+ *
381
+ * The chain must be walked LIVE: a transform (or even
382
+ * `will-change: transform`) on an ancestor rebinds `offsetParent`. */
383
+ function layoutOffset(
384
+ el: HTMLElement,
385
+ frame: HTMLElement | null,
386
+ ): { x: number; y: number; through: boolean } {
387
+ let x = 0;
388
+ let y = 0;
389
+ let through = false;
390
+ for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) {
391
+ x += node.offsetLeft;
392
+ y += node.offsetTop;
393
+ if (node === frame) through = true;
394
+ }
395
+ return { x, y, through };
396
+ }
397
+
398
+ /** The pressed item's layout geometry read off the LIVE strip rather than
399
+ * carried from the press — the answer to "where is the item I am holding
400
+ * NOW?", which is what changes when the list reorders, an item is added or
401
+ * removed around it, a font or a zoom changes, or the strip re-wraps.
402
+ * `null` when there is no box to read (no layout engine at all, a detached
403
+ * element, a collapsed or partially-readable one), which keeps the caller
404
+ * on the carried snapshot. */
405
+ function derivedOrigin(): PointerReorderOrigin | null {
406
+ if (!pressedItem || !pressedFrame || !pressedFrame.isConnected) return null;
407
+ const box = frameBox(pressedFrame);
408
+ const itemW = pressedItem.offsetWidth;
409
+ const itemH = pressedItem.offsetHeight;
410
+ if (!(itemW > 0) || !(itemH > 0)) return null;
411
+ if (!(box.w > 0) || !(box.h > 0)) return null;
412
+ const at = layoutOffset(pressedItem, pressedFrame);
413
+ const of = layoutOffset(pressedFrame, null);
414
+ // A chain that runs through something without layout offsets of its own
415
+ // (an SVG ancestor, a foreignObject boundary) sums to NaN rather than
416
+ // failing: nothing can be placed from it, so the snapshot keeps the call.
417
+ if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return null;
418
+ if (!Number.isFinite(of.x) || !Number.isFinite(of.y)) return null;
419
+ const scaleX = box.laidW > 0 ? box.w / box.laidW : 1;
420
+ const scaleY = box.laidH > 0 ? box.h / box.laidH : 1;
421
+ /** The frame's own border, in the units it is DRAWN at — and only when
422
+ * the walk into the frame came through it (`through`). */
423
+ const style = getComputedStyle(pressedFrame);
424
+ const borderLeft = at.through ? (parseFloat(style.borderLeftWidth) || 0) * scaleX : 0;
425
+ const borderTop = at.through ? (parseFloat(style.borderTopWidth) || 0) * scaleY : 0;
426
+ const left = box.x + borderLeft + (at.x - of.x - box.scrollX) * scaleX;
427
+ const top = box.y + borderTop + (at.y - of.y - box.scrollY) * scaleY;
428
+ const width = itemW * scaleX;
429
+ const height = itemH * scaleY;
430
+ return axis === "x"
431
+ ? { band: { lo: top, hi: top + height }, mid: left + width / 2 }
432
+ : { band: { lo: left, hi: left + width }, mid: top + height / 2 };
433
+ }
316
434
 
317
- /** The pressed item's layout geometry for `indexAt`, placed in the frame's
318
- * box AS IT IS NOW. The snapshot is in VIEWPORT coordinates, so anything
435
+ /** The pressed item's layout geometry for `indexAt`, placed where the
436
+ * frame puts it NOW. The snapshot is in VIEWPORT coordinates, so anything
319
437
  * that moves the frame under it — the auto-scroll this composable drives,
320
438
  * a host scrolling the surface or any scroller above it, a page scroll, a
321
- * re-parent, a transform on an ancestor — has to be carried with it, or
322
- * the line the item was laid out on would be compared against siblings
323
- * that have since moved away from it. `null` before any press, and for a
324
- * press whose item was not in the strip.
439
+ * re-parent, a transform on the frame or on an ancestor — has to be
440
+ * carried with it, or the line the item was laid out on would be compared
441
+ * against siblings that have since moved away from it. `null` before any
442
+ * press, and for a press whose item was not in the strip.
325
443
  *
326
- * The carrier is the frame's BOX: a coordinate travels by the box's
327
- * displacement when the box keeps its size (a scroll of any kind, a
328
- * re-parent, a translation) and keeps its place INSIDE the box when the
329
- * box is resized — which is what a `scale()` or a zoom painted on the
330
- * frame or on an ancestor does to it, the box growing and shrinking with
331
- * the content it holds. A frame with no extent to scale against (no
332
- * layout at all, a `display: contents` frame) falls back to the
444
+ * The carrier is the frame's own reading of a coordinate: the snapshot
445
+ * travels to wherever the frame puts that same place in its layout now,
446
+ * which is exact for the three things that can move it —
447
+ *
448
+ * - a DISPLACEMENT of the frame (a page scroll, a re-parent, a
449
+ * translation) moves the snapshot by the same amount;
450
+ * - the frame's own SCROLL slides its children inside a box that does not
451
+ * move, so it is taken off inside the frame's own units rather than in
452
+ * drawn pixels (`sample`);
453
+ * - a `scale()` or a zoom — on the frame or on an ancestor — keeps the
454
+ * snapshot at the same place INSIDE the frame, which is what the frame
455
+ * does to everything it holds, so the placement scales with it.
456
+ *
457
+ * A frame with no box to read any of this from falls back to the plain
333
458
  * displacement. */
334
459
  function layoutOrigin(): PointerReorderOrigin | null {
460
+ const live = derivedOrigin();
461
+ if (live) return live;
335
462
  if (!pressedBand || !pressedFrameBox) return null;
336
463
  const at = pressedFrameBox;
337
464
  const now = pressedFrame && pressedFrame.isConnected ? frameBox(pressedFrame) : at;
465
+ /** Where the frame draws a coordinate it drew at `from` when the press
466
+ * was read: how far it sat from the frame's content origin, taken
467
+ * against the scroll the frame has moved since, drawn again at the
468
+ * scale the frame carries now. A frame whose LAID-OUT size cannot be
469
+ * read (no layout engine, `display: contents`) answers with the ratio
470
+ * of its drawn sizes, and takes its own scroll off raw — the reading
471
+ * every frame got before the scale was measurable at all. A frame with
472
+ * no box and no scroll of its own — one that cannot be read at all —
473
+ * carries nothing, so the snapshot stays where the press put it. */
338
474
  const carry = (value: number, along: "x" | "y"): number => {
339
475
  const from = along === "x" ? at.x : at.y;
340
- const fromSpan = along === "x" ? at.w : at.h;
341
476
  const to = along === "x" ? now.x : now.y;
342
- const toSpan = along === "x" ? now.w : now.h;
343
- return fromSpan > 0 && toSpan > 0
344
- ? to + ((value - from) * toSpan) / fromSpan
345
- : to + (value - from);
477
+ const drawnFrom = along === "x" ? at.w : at.h;
478
+ const drawnTo = along === "x" ? now.w : now.h;
479
+ const laidFrom = along === "x" ? at.laidW : at.laidH;
480
+ const laidTo = along === "x" ? now.laidW : now.laidH;
481
+ const scrollFrom = along === "x" ? at.scrollX : at.scrollY;
482
+ const scrollTo = along === "x" ? now.scrollX : now.scrollY;
483
+ if (laidFrom > 0 && laidTo > 0) {
484
+ const scaleFrom = drawnFrom / laidFrom;
485
+ const scaleTo = drawnTo / laidTo;
486
+ return to + (scaleTo / scaleFrom) * (value - from) + scaleTo * (scrollFrom - scrollTo);
487
+ }
488
+ const ratio = drawnFrom > 0 && drawnTo > 0 ? drawnTo / drawnFrom : 1;
489
+ return to + ratio * (value - from) + (scrollFrom - scrollTo);
346
490
  };
347
491
  const down = axis === "x" ? "y" : "x";
348
492
  const lo = carry(pressedBand.lo, down);
@@ -403,7 +547,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
403
547
  } else {
404
548
  container.scrollTop = next;
405
549
  }
406
- const over = slotAt(lastMain, lastCross, pressedIndex);
550
+ const over = slotAt(lastMain, lastCross);
407
551
  if (over >= 0) dragOver.value = over;
408
552
  scrollFrameId = requestAnimationFrame(scrollTick);
409
553
  }
@@ -527,16 +671,18 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
527
671
  const at = pointerAt(event);
528
672
  lastMain = at.main;
529
673
  lastCross = at.cross;
530
- const over = slotAt(lastMain, lastCross, pressedIndex);
674
+ const over = slotAt(lastMain, lastCross);
531
675
  if (over >= 0) dragOver.value = over;
532
676
  syncAutoScroll();
533
677
  }
534
678
 
535
679
  function onPointerUp(event: PointerEvent): void {
536
680
  if (!pressed.value || event.pointerId !== pointerId) return;
537
- const from = pressedIndex;
538
681
  const at = pointerAt(event);
539
- const to = moved ? slotAt(at.main, at.cross, from) : from;
682
+ // The slot is resolved first: it re-reads the pressed ELEMENT's index
683
+ // from the live strip, and that is the index the drop is reported with.
684
+ const to = moved ? slotAt(at.main, at.cross) : pressedIndex;
685
+ const from = pressedIndex;
540
686
  const dropped = moved && to >= 0 && to !== from;
541
687
  // The drag is over either way — the trap only guards the click the
542
688
  // browser is about to deliver.
@@ -545,11 +691,12 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
545
691
  if (dropped) options.onDrop(from, to);
546
692
  }
547
693
 
548
- /** End a drag that must not resolve — the strip it was reading is no
549
- * longer the strip the press grabbed. The trailing click is swallowed
550
- * exactly as a drop's is: this WAS a drag (the pointer travelled past the
551
- * threshold), and the click the browser is about to deliver at wherever
552
- * the finger came to rest is one the user never made. */
694
+ /** End a drag that must not resolve — the strip no longer carries the
695
+ * element the press grabbed, so there is nothing for the gesture to move.
696
+ * The trailing click is swallowed exactly as a drop's is: this WAS a drag
697
+ * (the pointer travelled past the threshold), and the click the browser is
698
+ * about to deliver at wherever the finger came to rest is one the user
699
+ * never made. */
553
700
  function abandonDrag(): void {
554
701
  if (moved) trapClick();
555
702
  releaseDrag();
@@ -594,7 +741,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
594
741
  ? rect.left + rect.width / 2
595
742
  : rect.top + rect.height / 2
596
743
  : 0;
597
- pressedFrame = item?.parentElement ?? null;
744
+ pressedFrame = item ? layoutFrameOf(item) : null;
598
745
  pressedFrameBox = pressedFrame ? frameBox(pressedFrame) : null;
599
746
  pressedItem = item ?? null;
600
747
  originX = event.clientX;