@celestia-island/hikari 0.43.8 → 0.43.9

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.9",
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,56 @@ 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
+
221
271
  const scopes: EffectScope[] = [];
222
272
 
223
273
  /** Mount the composable inside a real effect scope (the component case). */
@@ -874,7 +924,7 @@ describe("usePointerReorder", () => {
874
924
 
875
925
  press(handle, items, 0, { x: 10, y: 20 });
876
926
  move(150, 20);
877
- items.reverse(); // the strip reorders under the drag: it must give way
927
+ items.shift(); // the strip drops the very chip the pointer is holding
878
928
  move(260, 20);
879
929
  up(260, 20);
880
930
  expect(drops).toEqual([]);
@@ -886,15 +936,15 @@ describe("usePointerReorder", () => {
886
936
  expect(clicks).toEqual(["row"]);
887
937
  });
888
938
 
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.
939
+ it("follows the element it grabbed when the strip reorders under it", () => {
940
+ // The caller hands its items over in DISPLAY order, and the index a drag
941
+ // landed at is only the way the resolution names the element. A strip
942
+ // that reorders mid-gesture — a host edit from outside, a row toggled by
943
+ // a second pointer, a chip's ×, an Enter, a query that filters rows out —
944
+ // moves the elements under those indices, and the drag has to follow the
945
+ // ELEMENT: resolving the press-time index would move whichever item slid
946
+ // into it, and giving way would throw away a gesture the user is still
947
+ // holding.
898
948
  const items = boxes([
899
949
  { left: 0, right: 100, top: 0, bottom: 40 },
900
950
  { left: 100, right: 200, top: 0, bottom: 40 },
@@ -907,16 +957,160 @@ describe("usePointerReorder", () => {
907
957
  expect(handle.dragging.value).toBe(true);
908
958
  expect(handle.dragOver.value, "150 is past the first chip's midpoint").toBe(1);
909
959
 
910
- // The strip reorders under the live drag: index 0 is another chip now.
911
- items.reverse();
960
+ // The host moves the LAST chip to the front: the strip is now
961
+ // [C, A, B], so the chip the pointer grabbed sits at index 1.
962
+ const [grabbed, middle, last] = items;
963
+ const moved = [last, grabbed, middle];
964
+ moved.forEach((el, i) => reshape(el, { left: i * 100, right: i * 100 + 100, top: 0, bottom: 40 }));
965
+ items.splice(0, items.length, ...moved);
912
966
  move(260, 20);
913
- expect(handle.dragging.value, "the drag ended with the strip it was reading").toBe(false);
967
+ expect(handle.dragging.value, "the drag is still the one the pointer started").toBe(true);
968
+ expect(handle.dragFrom.value, "the lift follows the chip it grabbed").toBe(1);
969
+ expect(handle.dragOver.value, "260 is past the second chip's midpoint of 250").toBe(2);
970
+ up(260, 20);
971
+ expect(drops, "the drop is reported in the indices the strip has now").toEqual([[1, 2]]);
972
+ });
973
+
974
+ it("ends the gesture the moment the strip empties", () => {
975
+ // Nothing left to resolve against is the same answer as the element being
976
+ // gone: the gesture is over then, not at the release — a drag left
977
+ // "running" against an empty strip would keep publishing a slot it can no
978
+ // longer read, and would not swallow the click it owes.
979
+ const items = boxes([
980
+ { left: 0, right: 100, top: 0, bottom: 40 },
981
+ { left: 100, right: 200, top: 0, bottom: 40 },
982
+ { left: 200, right: 300, top: 0, bottom: 40 },
983
+ ]);
984
+ const { handle, drops } = harness("x", items);
985
+ const inner = document.createElement("span");
986
+ items[0].appendChild(inner);
987
+ const clicks: string[] = [];
988
+ inner.addEventListener("click", () => clicks.push("row"));
989
+
990
+ press(handle, items, 0, { x: 10, y: 20 });
991
+ move(150, 20);
992
+ expect(handle.dragging.value).toBe(true);
993
+
994
+ items.splice(0, items.length); // every item goes at once
995
+ move(260, 20);
996
+ expect(handle.dragging.value, "the gesture ended with the strip").toBe(false);
997
+ expect(handle.dragFrom.value).toBe(-1);
998
+ expect(handle.dragOver.value).toBe(-1);
999
+ up(260, 20);
1000
+ expect(drops).toEqual([]);
1001
+ inner.dispatchEvent(new MouseEvent("click", { bubbles: true }));
1002
+ expect(clicks, "…and its trailing click was swallowed").toEqual([]);
1003
+ });
1004
+
1005
+ it("abandons a drag whose pressed item leaves the strip", () => {
1006
+ // The other side of the same rule: a strip that no longer carries the
1007
+ // element at all — it was removed, or a drag of it can no longer land
1008
+ // anywhere — has nothing for the gesture to move, so it ends without a
1009
+ // reorder.
1010
+ const items = boxes([
1011
+ { left: 0, right: 100, top: 0, bottom: 40 },
1012
+ { left: 100, right: 200, top: 0, bottom: 40 },
1013
+ { left: 200, right: 300, top: 0, bottom: 40 },
1014
+ ]);
1015
+ const { handle, drops } = harness("x", items);
1016
+
1017
+ press(handle, items, 0, { x: 10, y: 20 });
1018
+ move(150, 20);
1019
+ expect(handle.dragOver.value).toBe(1);
1020
+
1021
+ items.shift(); // the chip the pointer is holding is gone
1022
+ move(260, 20);
1023
+ expect(handle.dragging.value, "the drag ended with the strip").toBe(false);
914
1024
  expect(handle.dragFrom.value).toBe(-1);
915
1025
  expect(handle.dragOver.value).toBe(-1);
916
1026
  up(260, 20);
917
1027
  expect(drops, "nothing was dropped").toEqual([]);
918
1028
  });
919
1029
 
1030
+ it("carries the snapshot exactly through a frame that scrolls AND is scaled", () => {
1031
+ // The frame here is the scroller the strip SITS IN — the shape this
1032
+ // composable documents — and the host zooms it mid-drag while it is
1033
+ // scrolled. The frame's own scroll slides the content inside a box that
1034
+ // does not move, and it does so in the frame's LAYOUT units: taken off
1035
+ // the drawn box as a raw offset it is measured at the wrong magnitude
1036
+ // the moment the frame is scaled (by `(scale - 1) x scroll`, here 300px
1037
+ // on a 2x zoom), and the snapshot drifts off the item it belongs to. The
1038
+ // pointer here is past the third chip and before the fourth, so the
1039
+ // drifted snapshot answers "stay where you are" where the strip answers
1040
+ // "land after the fourth".
1041
+ const { items, scene } = scrollerStrip(5, 100, 40);
1042
+ const { handle, drops } = harness("x", items);
1043
+
1044
+ press(handle, items, 2, { x: 250, y: 20 });
1045
+ scene.scale = 2; // the host zooms the strip…
1046
+ scene.scroll = 300; // …while it is scrolled under the live drag
1047
+ move(100, 20);
1048
+ expect(handle.dragging.value).toBe(true);
1049
+ expect(handle.dragOver.value, "past the third chip, before the fourth").toBe(3);
1050
+ up(100, 20);
1051
+ expect(drops).toEqual([[2, 3]]);
1052
+ });
1053
+
1054
+ it("reads a LAYOUT change as a layout change, not as a scale", () => {
1055
+ // The frame's own box growing is not the frame being drawn bigger: the
1056
+ // chips keep their place and their size, and the frame merely got wider
1057
+ // around them. A carrier that reads the drawn box alone cannot tell the
1058
+ // two apart, and would stretch the snapshot by the ratio of the two
1059
+ // sizes — here it would answer "stay where you are" for a pointer the
1060
+ // strip answers "land after the third chip".
1061
+ const { items, scene } = scrollerStrip(5, 100, 40);
1062
+ const { handle, drops } = harness("x", items);
1063
+
1064
+ press(handle, items, 1, { x: 150, y: 20 });
1065
+ scene.laid = 1000; // the frame is re-laid out around the same chips
1066
+ move(260, 20);
1067
+ expect(handle.dragging.value).toBe(true);
1068
+ expect(handle.dragOver.value, "260 is past the third chip's midpoint of 250").toBe(2);
1069
+ up(260, 20);
1070
+ expect(drops).toEqual([[1, 2]]);
1071
+ });
1072
+
1073
+ it("carries the snapshot in the nearest frame that has a box", () => {
1074
+ // A strip wrapped in an element that generates no box of its own
1075
+ // (`display: contents`) is laid out by the next box up, and that is the
1076
+ // box its content moves with: reading the boxless wrapper leaves the
1077
+ // snapshot behind the moment the page scrolls the real frame.
1078
+ const frame = document.createElement("div");
1079
+ const wrapper = document.createElement("div");
1080
+ frame.appendChild(wrapper);
1081
+ document.body.appendChild(frame);
1082
+ const scene = { shift: 0 };
1083
+ reshape(frame, { left: 0, right: 300, top: 0, bottom: 40 });
1084
+ Object.defineProperty(frame, "getBoundingClientRect", {
1085
+ configurable: true,
1086
+ value: () => {
1087
+ const box = { left: 0, right: 300, top: scene.shift, bottom: scene.shift + 40 };
1088
+ return asRect(box);
1089
+ },
1090
+ });
1091
+ // The wrapper itself: no box at all, exactly as `display: contents` reports.
1092
+ reshape(wrapper, { left: 0, right: 0, top: 0, bottom: 0 });
1093
+ const items = [0, 100, 200].map((start) => {
1094
+ const el = document.createElement("div");
1095
+ Object.defineProperty(el, "getBoundingClientRect", {
1096
+ configurable: true,
1097
+ value: () =>
1098
+ asRect({ left: start, right: start + 100, top: scene.shift, bottom: scene.shift + 40 }),
1099
+ });
1100
+ wrapper.appendChild(el);
1101
+ return el;
1102
+ });
1103
+ const { handle, drops } = harness("x", items);
1104
+
1105
+ press(handle, items, 0, { x: 10, y: 20 });
1106
+ scene.shift = 40; // the page scrolls the strip a line up under the drag
1107
+ move(150, 20);
1108
+ expect(handle.dragging.value).toBe(true);
1109
+ expect(handle.dragOver.value, "the chips kept their places inside the frame").toBe(1);
1110
+ up(150, 20);
1111
+ expect(drops).toEqual([[0, 1]]);
1112
+ });
1113
+
920
1114
  it("keeps the pressed item's geometry on the content when the frame is SCALED under it", () => {
921
1115
  // A `scale()` or a zoom painted on the frame — or on an ancestor — during
922
1116
  // 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,145 @@ 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
 
316
-
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
363
+ /** The pressed item's layout geometry for `indexAt`, placed where the
364
+ * frame puts it NOW. The snapshot is in VIEWPORT coordinates, so anything
319
365
  * that moves the frame under it — the auto-scroll this composable drives,
320
366
  * 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.
367
+ * re-parent, a transform on the frame or on an ancestor — has to be
368
+ * carried with it, or the line the item was laid out on would be compared
369
+ * against siblings that have since moved away from it. `null` before any
370
+ * press, and for a press whose item was not in the strip.
371
+ *
372
+ * The carrier is the frame's own reading of a coordinate: the snapshot
373
+ * travels to wherever the frame puts that same place in its layout now,
374
+ * which is exact for the three things that can move it —
325
375
  *
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
376
+ * - a DISPLACEMENT of the frame (a page scroll, a re-parent, a
377
+ * translation) moves the snapshot by the same amount;
378
+ * - the frame's own SCROLL slides its children inside a box that does not
379
+ * move, so it is taken off inside the frame's own units rather than in
380
+ * drawn pixels (`sample`);
381
+ * - a `scale()` or a zoom — on the frame or on an ancestor — keeps the
382
+ * snapshot at the same place INSIDE the frame, which is what the frame
383
+ * does to everything it holds, so the placement scales with it.
384
+ *
385
+ * A frame with no box to read any of this from falls back to the plain
333
386
  * displacement. */
334
387
  function layoutOrigin(): PointerReorderOrigin | null {
335
388
  if (!pressedBand || !pressedFrameBox) return null;
336
389
  const at = pressedFrameBox;
337
390
  const now = pressedFrame && pressedFrame.isConnected ? frameBox(pressedFrame) : at;
391
+ /** Where the frame draws a coordinate it drew at `from` when the press
392
+ * was read: how far it sat from the frame's content origin, taken
393
+ * against the scroll the frame has moved since, drawn again at the
394
+ * scale the frame carries now. A frame whose LAID-OUT size cannot be
395
+ * read (no layout engine, `display: contents`) answers with the ratio
396
+ * of its drawn sizes, and takes its own scroll off raw — the reading
397
+ * every frame got before the scale was measurable at all. A frame with
398
+ * no box and no scroll of its own — one that cannot be read at all —
399
+ * carries nothing, so the snapshot stays where the press put it. */
338
400
  const carry = (value: number, along: "x" | "y"): number => {
339
401
  const from = along === "x" ? at.x : at.y;
340
- const fromSpan = along === "x" ? at.w : at.h;
341
402
  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);
403
+ const drawnFrom = along === "x" ? at.w : at.h;
404
+ const drawnTo = along === "x" ? now.w : now.h;
405
+ const laidFrom = along === "x" ? at.laidW : at.laidH;
406
+ const laidTo = along === "x" ? now.laidW : now.laidH;
407
+ const scrollFrom = along === "x" ? at.scrollX : at.scrollY;
408
+ const scrollTo = along === "x" ? now.scrollX : now.scrollY;
409
+ if (laidFrom > 0 && laidTo > 0) {
410
+ const scaleFrom = drawnFrom / laidFrom;
411
+ const scaleTo = drawnTo / laidTo;
412
+ return to + (scaleTo / scaleFrom) * (value - from) + scaleTo * (scrollFrom - scrollTo);
413
+ }
414
+ const ratio = drawnFrom > 0 && drawnTo > 0 ? drawnTo / drawnFrom : 1;
415
+ return to + ratio * (value - from) + (scrollFrom - scrollTo);
346
416
  };
347
417
  const down = axis === "x" ? "y" : "x";
348
418
  const lo = carry(pressedBand.lo, down);
@@ -403,7 +473,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
403
473
  } else {
404
474
  container.scrollTop = next;
405
475
  }
406
- const over = slotAt(lastMain, lastCross, pressedIndex);
476
+ const over = slotAt(lastMain, lastCross);
407
477
  if (over >= 0) dragOver.value = over;
408
478
  scrollFrameId = requestAnimationFrame(scrollTick);
409
479
  }
@@ -527,16 +597,18 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
527
597
  const at = pointerAt(event);
528
598
  lastMain = at.main;
529
599
  lastCross = at.cross;
530
- const over = slotAt(lastMain, lastCross, pressedIndex);
600
+ const over = slotAt(lastMain, lastCross);
531
601
  if (over >= 0) dragOver.value = over;
532
602
  syncAutoScroll();
533
603
  }
534
604
 
535
605
  function onPointerUp(event: PointerEvent): void {
536
606
  if (!pressed.value || event.pointerId !== pointerId) return;
537
- const from = pressedIndex;
538
607
  const at = pointerAt(event);
539
- const to = moved ? slotAt(at.main, at.cross, from) : from;
608
+ // The slot is resolved first: it re-reads the pressed ELEMENT's index
609
+ // from the live strip, and that is the index the drop is reported with.
610
+ const to = moved ? slotAt(at.main, at.cross) : pressedIndex;
611
+ const from = pressedIndex;
540
612
  const dropped = moved && to >= 0 && to !== from;
541
613
  // The drag is over either way — the trap only guards the click the
542
614
  // browser is about to deliver.
@@ -545,11 +617,12 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
545
617
  if (dropped) options.onDrop(from, to);
546
618
  }
547
619
 
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. */
620
+ /** End a drag that must not resolve — the strip no longer carries the
621
+ * element the press grabbed, so there is nothing for the gesture to move.
622
+ * The trailing click is swallowed exactly as a drop's is: this WAS a drag
623
+ * (the pointer travelled past the threshold), and the click the browser is
624
+ * about to deliver at wherever the finger came to rest is one the user
625
+ * never made. */
553
626
  function abandonDrag(): void {
554
627
  if (moved) trapClick();
555
628
  releaseDrag();
@@ -594,7 +667,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
594
667
  ? rect.left + rect.width / 2
595
668
  : rect.top + rect.height / 2
596
669
  : 0;
597
- pressedFrame = item?.parentElement ?? null;
670
+ pressedFrame = item ? layoutFrameOf(item) : null;
598
671
  pressedFrameBox = pressedFrame ? frameBox(pressedFrame) : null;
599
672
  pressedItem = item ?? null;
600
673
  originX = event.clientX;