@celestia-island/hikari 0.43.9 → 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.9",
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",
@@ -268,6 +268,122 @@ function scrollerStrip(
268
268
  return { frame, items, scene };
269
269
  }
270
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
+
271
387
  const scopes: EffectScope[] = [];
272
388
 
273
389
  /** Mount the composable inside a real effect scope (the component case). */
@@ -971,6 +1087,319 @@ describe("usePointerReorder", () => {
971
1087
  expect(drops, "the drop is reported in the indices the strip has now").toEqual([[1, 2]]);
972
1088
  });
973
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
+
974
1403
  it("ends the gesture the moment the strip empties", () => {
975
1404
  // Nothing left to resolve against is the same answer as the element being
976
1405
  // gone: the gesture is over then, not at the release — a drag left
@@ -360,6 +360,78 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
360
360
  };
361
361
  }
362
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
+ }
434
+
363
435
  /** The pressed item's layout geometry for `indexAt`, placed where the
364
436
  * frame puts it NOW. The snapshot is in VIEWPORT coordinates, so anything
365
437
  * that moves the frame under it — the auto-scroll this composable drives,
@@ -385,6 +457,8 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
385
457
  * A frame with no box to read any of this from falls back to the plain
386
458
  * displacement. */
387
459
  function layoutOrigin(): PointerReorderOrigin | null {
460
+ const live = derivedOrigin();
461
+ if (live) return live;
388
462
  if (!pressedBand || !pressedFrameBox) return null;
389
463
  const at = pressedFrameBox;
390
464
  const now = pressedFrame && pressedFrame.isConnected ? frameBox(pressedFrame) : at;