@celestia-island/hikari 0.43.0 → 0.43.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { effectScope, type EffectScope } from "vue";
3
+
4
+ import { usePointerReorder, type PointerReorder } from "./usePointerReorder";
5
+
6
+ /** Items with PINNED rects — happy-dom ships no layout engine, so every
7
+ * item is placed by hand along the axis under test (size 40, the same
8
+ * geometry a 40px chip row or panel row would report). */
9
+ function strip(axis: "x" | "y", count = 3, size = 40) {
10
+ const container = document.createElement("div");
11
+ document.body.appendChild(container);
12
+ const items: HTMLElement[] = [];
13
+ for (let i = 0; i < count; i += 1) {
14
+ const el = document.createElement("div");
15
+ el.textContent = `item-${i}`;
16
+ const start = i * size;
17
+ const box = {
18
+ left: axis === "x" ? start : 0,
19
+ right: axis === "x" ? start + size : size,
20
+ top: axis === "x" ? 0 : start,
21
+ bottom: axis === "x" ? size : start + size,
22
+ width: size,
23
+ height: size,
24
+ x: axis === "x" ? start : 0,
25
+ y: axis === "x" ? 0 : start,
26
+ };
27
+ Object.defineProperty(el, "getBoundingClientRect", {
28
+ configurable: true,
29
+ value: () => box as DOMRect,
30
+ });
31
+ container.appendChild(el);
32
+ items.push(el);
33
+ }
34
+ return { container, items };
35
+ }
36
+
37
+ /** Waits out `frames` animation frames — the auto-scroll loop steps once
38
+ * per frame (the repo's useApproachEnd pattern). */
39
+ async function frames(count: number): Promise<void> {
40
+ for (let i = 0; i < count; i += 1) {
41
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
42
+ }
43
+ }
44
+
45
+ /** A scrollable strip for the auto-scroll tests: the container reports a
46
+ * fixed box and holds real `scrollTop`/`scrollLeft` numbers, and every
47
+ * item is placed relative to the current scroll offset — which is exactly
48
+ * what a browser does to the content a scroll moves under the pointer. */
49
+ function scrollStrip(
50
+ axis: "x" | "y",
51
+ count: number,
52
+ size: number,
53
+ viewport: number,
54
+ ): { container: HTMLElement; items: HTMLElement[] } {
55
+ const container = document.createElement("div");
56
+ const box =
57
+ axis === "x"
58
+ ? { left: 0, right: viewport, top: 0, bottom: size }
59
+ : { left: 0, right: size, top: 0, bottom: viewport };
60
+ Object.defineProperty(container, "getBoundingClientRect", {
61
+ configurable: true,
62
+ value: () =>
63
+ ({ ...box, width: box.right - box.left, height: box.bottom - box.top, x: box.left, y: box.top }) as DOMRect,
64
+ });
65
+ document.body.appendChild(container);
66
+
67
+ const items: HTMLElement[] = [];
68
+ for (let i = 0; i < count; i += 1) {
69
+ const el = document.createElement("div");
70
+ Object.defineProperty(el, "getBoundingClientRect", {
71
+ configurable: true,
72
+ value: () => {
73
+ const offset = (axis === "x" ? container.scrollLeft : container.scrollTop);
74
+ const start = i * size - offset;
75
+ const item =
76
+ axis === "x"
77
+ ? { left: start, right: start + size, top: 0, bottom: size }
78
+ : { left: 0, right: size, top: start, bottom: start + size };
79
+ return { ...item, width: size, height: size, x: item.left, y: item.top } as DOMRect;
80
+ },
81
+ });
82
+ container.appendChild(el);
83
+ items.push(el);
84
+ }
85
+ return { container, items };
86
+ }
87
+
88
+ const scopes: EffectScope[] = [];
89
+
90
+ /** Mount the composable inside a real effect scope (the component case). */
91
+ function harness(
92
+ axis: "x" | "y",
93
+ items: HTMLElement[],
94
+ threshold?: number,
95
+ scrollContainer?: () => HTMLElement | null,
96
+ ): { handle: PointerReorder; drops: Array<[number, number]> } {
97
+ const drops: Array<[number, number]> = [];
98
+ const scope = effectScope();
99
+ scopes.push(scope);
100
+ let handle!: PointerReorder;
101
+ scope.run(() => {
102
+ handle = usePointerReorder({
103
+ items: () => items,
104
+ axis,
105
+ threshold,
106
+ scrollContainer,
107
+ onDrop: (from, to) => drops.push([from, to]),
108
+ });
109
+ });
110
+ return { handle, drops };
111
+ }
112
+
113
+ /** Press item `index` and hand the event to the composable — exactly what
114
+ * a component's `onPointerdown` handler does. `buttons` is 1 throughout a
115
+ * press, as a real pointer reports it: a move with `buttons: 0` means the
116
+ * button went up somewhere the page never saw, and the composable treats
117
+ * it as the end of the gesture. */
118
+ function press(
119
+ handle: PointerReorder,
120
+ items: HTMLElement[],
121
+ index: number,
122
+ coordinate: { x?: number; y?: number },
123
+ id = 1,
124
+ ): void {
125
+ const event = new PointerEvent("pointerdown", {
126
+ bubbles: true,
127
+ cancelable: true,
128
+ pointerId: id,
129
+ pointerType: "mouse",
130
+ button: 0,
131
+ buttons: 1,
132
+ clientX: coordinate.x ?? 0,
133
+ clientY: coordinate.y ?? 0,
134
+ });
135
+ items[index].dispatchEvent(event);
136
+ handle.start(event, index);
137
+ }
138
+
139
+ function move(x: number, y = 0, id = 1, buttons = 1): void {
140
+ window.dispatchEvent(
141
+ new PointerEvent("pointermove", {
142
+ bubbles: true,
143
+ pointerId: id,
144
+ pointerType: "mouse",
145
+ buttons,
146
+ clientX: x,
147
+ clientY: y,
148
+ }),
149
+ );
150
+ }
151
+
152
+ function up(x: number, y = 0, id = 1): void {
153
+ window.dispatchEvent(
154
+ new PointerEvent("pointerup", {
155
+ bubbles: true,
156
+ pointerId: id,
157
+ pointerType: "mouse",
158
+ buttons: 0,
159
+ clientX: x,
160
+ clientY: y,
161
+ }),
162
+ );
163
+ }
164
+
165
+ function cancel(id = 1): void {
166
+ window.dispatchEvent(
167
+ new PointerEvent("pointercancel", { bubbles: true, pointerId: id, pointerType: "touch" }),
168
+ );
169
+ }
170
+
171
+ afterEach(() => {
172
+ for (const scope of scopes.splice(0)) scope.stop();
173
+ document.body.innerHTML = "";
174
+ });
175
+
176
+ describe("usePointerReorder", () => {
177
+ it("keeps an idle press idle — a move under the threshold is not a drag", () => {
178
+ const { items } = strip("x");
179
+ const { handle, drops } = harness("x", items);
180
+
181
+ press(handle, items, 0, { x: 10 });
182
+ move(14);
183
+ expect(handle.dragging.value).toBe(false);
184
+ expect(handle.dragFrom.value).toBe(-1);
185
+ expect(handle.dragOver.value).toBe(-1);
186
+
187
+ up(14);
188
+ expect(drops).toEqual([]);
189
+ expect(handle.dragging.value).toBe(false);
190
+ });
191
+
192
+ it("honours a custom threshold", () => {
193
+ const { items } = strip("x");
194
+ const { handle, drops } = harness("x", items, 30);
195
+
196
+ press(handle, items, 0, { x: 10 });
197
+ move(30);
198
+ expect(handle.dragging.value, "20px is still under a 30px threshold").toBe(false);
199
+ move(75);
200
+ expect(handle.dragging.value).toBe(true);
201
+ up(75);
202
+ expect(drops).toEqual([[0, 1]]);
203
+ });
204
+
205
+ it("publishes the drag state and resolves the slot from the item midpoints", () => {
206
+ const { items } = strip("x");
207
+ const { handle, drops } = harness("x", items);
208
+
209
+ press(handle, items, 0, { x: 10 });
210
+ move(70);
211
+ expect(handle.dragging.value).toBe(true);
212
+ expect(handle.dragFrom.value).toBe(0);
213
+ expect(handle.dragOver.value, "70 is past item 1's midpoint of 60").toBe(1);
214
+
215
+ up(70);
216
+ expect(drops).toEqual([[0, 1]]);
217
+ // The gesture released every listener and reset the published state.
218
+ expect(handle.dragging.value).toBe(false);
219
+ expect(handle.dragFrom.value).toBe(-1);
220
+ expect(handle.dragOver.value).toBe(-1);
221
+ });
222
+
223
+ it("resolves the landing slot along the declared axis only", () => {
224
+ const { items } = strip("y");
225
+ const { handle, drops } = harness("y", items);
226
+
227
+ // Item 2 spans y 80-120 (midpoint 100) and is dragged from y 90.
228
+ press(handle, items, 2, { y: 90 });
229
+ move(500, 90);
230
+ expect(handle.dragging.value).toBe(true);
231
+ expect(handle.dragOver.value, "x travel says nothing on a y strip").toBe(2);
232
+
233
+ move(0, 10);
234
+ expect(handle.dragOver.value, "10 is before item 0's midpoint of 20").toBe(0);
235
+ up(0, 10);
236
+ expect(drops).toEqual([[2, 0]]);
237
+ });
238
+
239
+ it("clamps a release past the end onto the last item of the strip", () => {
240
+ const { items } = strip("y");
241
+ const { handle, drops } = harness("y", items);
242
+
243
+ press(handle, items, 0, { y: 10 });
244
+ move(0, 5000);
245
+ expect(handle.dragOver.value).toBe(2);
246
+ up(0, 5000);
247
+ expect(drops).toEqual([[0, 2]]);
248
+ });
249
+
250
+ it("emits nothing when the drag lands back on the item it started on", () => {
251
+ const { items } = strip("x");
252
+ const { handle, drops } = harness("x", items);
253
+
254
+ press(handle, items, 1, { x: 50 });
255
+ move(70);
256
+ expect(handle.dragging.value).toBe(true);
257
+ up(52);
258
+ expect(drops).toEqual([]);
259
+ });
260
+
261
+ it("abandons the drag on pointercancel without emitting", () => {
262
+ const { items } = strip("x");
263
+ const { handle, drops } = harness("x", items);
264
+
265
+ press(handle, items, 0, { x: 10 });
266
+ move(70);
267
+ cancel();
268
+ expect(handle.dragging.value).toBe(false);
269
+ expect(handle.dragFrom.value).toBe(-1);
270
+
271
+ // The gesture is forgotten: a stray move/up cannot resurrect it.
272
+ move(110);
273
+ up(110);
274
+ expect(drops).toEqual([]);
275
+ });
276
+
277
+ it("abandons the drag on Escape without emitting", () => {
278
+ const { items } = strip("x");
279
+ const { handle, drops } = harness("x", items);
280
+
281
+ press(handle, items, 0, { x: 10 });
282
+ move(70);
283
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
284
+ expect(handle.dragging.value).toBe(false);
285
+ up(110);
286
+ expect(drops).toEqual([]);
287
+ });
288
+
289
+ it("drops the trap and the listeners when the scope is torn down mid-drag", () => {
290
+ const { items } = strip("x");
291
+ const { handle, drops } = harness("x", items);
292
+ const scope = scopes[scopes.length - 1];
293
+
294
+ press(handle, items, 0, { x: 10 });
295
+ move(70);
296
+ scope.stop();
297
+ expect(handle.dragging.value).toBe(false);
298
+ up(110);
299
+ expect(drops).toEqual([]);
300
+ });
301
+
302
+ it("ignores a press that starts on an interactive control", () => {
303
+ const { items } = strip("x");
304
+ const { handle, drops } = harness("x", items);
305
+ const button = document.createElement("button");
306
+ items[0].appendChild(button);
307
+
308
+ const event = new PointerEvent("pointerdown", {
309
+ bubbles: true,
310
+ cancelable: true,
311
+ pointerId: 1,
312
+ button: 0,
313
+ buttons: 1,
314
+ clientX: 10,
315
+ clientY: 0,
316
+ });
317
+ button.dispatchEvent(event);
318
+ handle.start(event, 0);
319
+
320
+ move(110);
321
+ expect(handle.dragging.value).toBe(false);
322
+ up(110);
323
+ expect(drops).toEqual([]);
324
+ });
325
+
326
+ it("ignores a non-primary button and a second pointer gesturing at once", () => {
327
+ const { items } = strip("x");
328
+ const { handle, drops } = harness("x", items);
329
+
330
+ const right = new PointerEvent("pointerdown", {
331
+ bubbles: true,
332
+ cancelable: true,
333
+ pointerId: 1,
334
+ button: 2,
335
+ buttons: 2,
336
+ clientX: 10,
337
+ clientY: 0,
338
+ });
339
+ items[0].dispatchEvent(right);
340
+ handle.start(right, 0);
341
+ move(110);
342
+ expect(handle.dragging.value, "the right button never drags").toBe(false);
343
+
344
+ // A live drag belongs to ONE pointer: a second finger is not a move.
345
+ press(handle, items, 0, { x: 10 }, 1);
346
+ move(70, 0, 1);
347
+ expect(handle.dragOver.value).toBe(1);
348
+ move(110, 0, 2);
349
+ expect(handle.dragOver.value, "the second pointer is ignored").toBe(1);
350
+ up(110, 0, 2);
351
+ expect(drops).toEqual([]);
352
+ up(70);
353
+ expect(drops).toEqual([[0, 1]]);
354
+ });
355
+
356
+ it("resolves against the pointer's own LINE on a wrapping strip", () => {
357
+ // Two lines of chips with different widths (a real wrap):
358
+ // line 1: [0] x0-100 (mid 50) [1] x100-200 (mid 150)
359
+ // line 2: [2] x0-60 (mid 30) [3] x60-120 (mid 90)
360
+ const items: HTMLElement[] = [];
361
+ const geometry = [
362
+ { left: 0, right: 100, top: 0, bottom: 40 },
363
+ { left: 100, right: 200, top: 0, bottom: 40 },
364
+ { left: 0, right: 60, top: 40, bottom: 80 },
365
+ { left: 60, right: 120, top: 40, bottom: 80 },
366
+ ];
367
+ for (const box of geometry) {
368
+ const el = document.createElement("div");
369
+ Object.defineProperty(el, "getBoundingClientRect", {
370
+ configurable: true,
371
+ value: () =>
372
+ ({ ...box, width: box.right - box.left, height: box.bottom - box.top, x: box.left, y: box.top }) as DOMRect,
373
+ });
374
+ document.body.appendChild(el);
375
+ items.push(el);
376
+ }
377
+ const { handle, drops } = harness("x", items);
378
+
379
+ // Item 2 dragged right along ITS line lands on item 3. Measured against
380
+ // the whole strip instead, line 1's midpoints would answer 1.
381
+ press(handle, items, 2, { x: 5, y: 60 });
382
+ move(100, 60);
383
+ expect(handle.dragOver.value).toBe(3);
384
+ up(100, 60);
385
+ expect(drops).toEqual([[2, 3]]);
386
+
387
+ // A pointer below every line takes the NEAREST line, not the first one.
388
+ press(handle, items, 2, { x: 5, y: 60 });
389
+ move(100, 400);
390
+ expect(handle.dragOver.value).toBe(3);
391
+ up(100, 400);
392
+ expect(drops).toEqual([
393
+ [2, 3],
394
+ [2, 3],
395
+ ]);
396
+ });
397
+
398
+ it("retires a stale click trap when a new press starts", () => {
399
+ const { items } = strip("x");
400
+ const { handle } = harness("x", items);
401
+ const inner = document.createElement("span");
402
+ items[0].appendChild(inner);
403
+ const clicks: string[] = [];
404
+ items[0].addEventListener("click", () => clicks.push("row"));
405
+
406
+ // A drag whose trailing click never arrives leaves the trap armed…
407
+ press(handle, items, 0, { x: 10 });
408
+ move(70);
409
+ up(70);
410
+ // …and the very next press retires it, so the click of the NEW gesture
411
+ // is a real user click and not the previous drag's ghost.
412
+ press(handle, items, 1, { x: 50 });
413
+ up(51);
414
+ inner.dispatchEvent(new MouseEvent("click", { bubbles: true }));
415
+ expect(clicks).toEqual(["row"]);
416
+ });
417
+
418
+ it("retires a stale trap even when the next press is rejected", () => {
419
+ const { items } = strip("x");
420
+ const { handle } = harness("x", items);
421
+ const button = document.createElement("button");
422
+ items[0].appendChild(button);
423
+ const clicks: string[] = [];
424
+ button.addEventListener("click", () => clicks.push("button"));
425
+
426
+ // A drag whose trailing click never arrived leaves the trap armed…
427
+ press(handle, items, 0, { x: 10 });
428
+ move(70);
429
+ up(70);
430
+
431
+ // …and a press the composable REFUSES (a control inside the strip) still
432
+ // ends that window: the × must not look dead for the next 400ms.
433
+ const rejected = new PointerEvent("pointerdown", {
434
+ bubbles: true,
435
+ cancelable: true,
436
+ pointerId: 1,
437
+ pointerType: "mouse",
438
+ button: 0,
439
+ buttons: 1,
440
+ clientX: 10,
441
+ clientY: 0,
442
+ });
443
+ button.dispatchEvent(rejected);
444
+ handle.start(rejected, 0);
445
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
446
+ expect(clicks).toEqual(["button"]);
447
+ });
448
+
449
+ it("abandons the drag when a move arrives with no button held", () => {
450
+ const { items } = strip("x");
451
+ const { handle, drops } = harness("x", items);
452
+
453
+ press(handle, items, 0, { x: 10 });
454
+ move(70);
455
+ expect(handle.dragging.value).toBe(true);
456
+
457
+ // The release happened where the page could not see it (the pointer
458
+ // left the window); the next move reports no buttons and ends it.
459
+ move(110, 0, 1, 0);
460
+ expect(handle.dragging.value).toBe(false);
461
+ expect(handle.dragFrom.value).toBe(-1);
462
+
463
+ // Whatever arrives afterwards — including a stray `up` at a position
464
+ // the user never chose — must not drop the dead drag.
465
+ up(110);
466
+ expect(drops).toEqual([]);
467
+
468
+ // …and the strip is not wedged: the next press drags normally.
469
+ press(handle, items, 0, { x: 10 });
470
+ move(70);
471
+ up(70);
472
+ expect(drops).toEqual([[0, 1]]);
473
+ });
474
+
475
+ it("abandons the drag when the window loses focus", () => {
476
+ const { items } = strip("x");
477
+ const { handle, drops } = harness("x", items);
478
+
479
+ press(handle, items, 0, { x: 10 });
480
+ move(70);
481
+ expect(handle.dragging.value).toBe(true);
482
+
483
+ window.dispatchEvent(new Event("blur"));
484
+ expect(handle.dragging.value).toBe(false);
485
+ expect(handle.dragFrom.value, "the strip is idle, not wedged").toBe(-1);
486
+ up(200);
487
+ expect(drops).toEqual([]);
488
+
489
+ press(handle, items, 0, { x: 10 });
490
+ move(70);
491
+ up(70);
492
+ expect(drops).toEqual([[0, 1]]);
493
+ });
494
+
495
+ it("abandons the drag when the page is hidden", () => {
496
+ const { items } = strip("x");
497
+ const { handle, drops } = harness("x", items);
498
+ const hidden = Object.getOwnPropertyDescriptor(Document.prototype, "hidden");
499
+ Object.defineProperty(document, "hidden", { configurable: true, value: true });
500
+ try {
501
+ press(handle, items, 0, { x: 10 });
502
+ move(70);
503
+ document.dispatchEvent(new Event("visibilitychange"));
504
+ expect(handle.dragging.value).toBe(false);
505
+ up(200);
506
+ expect(drops).toEqual([]);
507
+ } finally {
508
+ if (hidden) Object.defineProperty(Document.prototype, "hidden", hidden);
509
+ else delete (document as unknown as Record<string, unknown>).hidden;
510
+ }
511
+ });
512
+
513
+ it("pulls a long drag along its scroller near the bottom edge", async () => {
514
+ // A 2-row band (80px) over a 5-row strip (40px rows): the last row can
515
+ // only be reached by scrolling.
516
+ const { container, items } = scrollStrip("y", 5, 40, 80);
517
+ const { handle, drops } = harness("y", items, undefined, () => container);
518
+
519
+ press(handle, items, 0, { y: 10 });
520
+ // Hold near the bottom edge (zone 56..80, pointer at 78).
521
+ move(0, 78);
522
+ expect(handle.dragging.value).toBe(true);
523
+ // …with nothing scrolled yet, the pointer can only reach the third row.
524
+ expect(handle.dragOver.value).toBe(1);
525
+
526
+ await frames(3);
527
+ expect(container.scrollTop, "the strip is being pulled along").toBeGreaterThan(0);
528
+ expect(handle.dragOver.value, "the slot follows the scrolled content").toBeGreaterThan(1);
529
+
530
+ // Held at the edge long enough for the last row to arrive, the release
531
+ // lands there — the whole point of the scroll.
532
+ for (let i = 0; i < 40 && container.scrollTop < 140; i += 1) await frames(1);
533
+ expect(container.scrollTop).toBeGreaterThanOrEqual(140);
534
+ expect(handle.dragOver.value).toBe(4);
535
+ up(0, 78);
536
+ expect(drops).toEqual([[0, 4]]);
537
+ // The drag is over: nothing keeps scrolling.
538
+ const settled = container.scrollTop;
539
+ await frames(3);
540
+ expect(container.scrollTop).toBe(settled);
541
+ });
542
+
543
+ it("pulls the strip back near the top edge", async () => {
544
+ const { container, items } = scrollStrip("y", 5, 40, 80);
545
+ const { handle, drops } = harness("y", items, undefined, () => container);
546
+ container.scrollTop = 120;
547
+
548
+ press(handle, items, 3, { y: 70 });
549
+ move(0, 2);
550
+ expect(handle.dragging.value).toBe(true);
551
+ await frames(3);
552
+ expect(container.scrollTop, "the strip is pulled back").toBeLessThan(120);
553
+ expect(container.scrollTop).toBeGreaterThanOrEqual(0);
554
+ expect(handle.dragOver.value, "the slot follows the content back").toBeLessThan(3);
555
+
556
+ // Carried all the way back, the row lands at the front.
557
+ for (let i = 0; i < 40 && container.scrollTop > 20; i += 1) await frames(1);
558
+ expect(handle.dragOver.value).toBe(0);
559
+ up(0, 2);
560
+ expect(drops).toEqual([[3, 0]]);
561
+ });
562
+
563
+ it("stops scrolling when the pointer leaves the edge zone", async () => {
564
+ const { container, items } = scrollStrip("y", 5, 40, 80);
565
+ const { handle } = harness("y", items, undefined, () => container);
566
+
567
+ press(handle, items, 0, { y: 10 });
568
+ move(0, 78);
569
+ await frames(2);
570
+ const scrolled = container.scrollTop;
571
+ expect(scrolled).toBeGreaterThan(0);
572
+
573
+ // Back to the middle of the band: the loop stops where it is.
574
+ move(0, 40);
575
+ await frames(3);
576
+ expect(container.scrollTop).toBe(scrolled);
577
+
578
+ // …and a move back into the zone picks the pull up again.
579
+ move(0, 79);
580
+ await frames(2);
581
+ expect(container.scrollTop).toBeGreaterThan(scrolled);
582
+ up(0, 40);
583
+ });
584
+
585
+ it("never scrolls without a live drag", async () => {
586
+ const { container, items } = scrollStrip("y", 5, 40, 80);
587
+ const { handle } = harness("y", items, undefined, () => container);
588
+
589
+ // A pointer hovering in the zone with no press at all.
590
+ move(0, 79);
591
+ move(0, 2);
592
+ await frames(3);
593
+ expect(container.scrollTop).toBe(0);
594
+
595
+ // A press that never became a drag (under the threshold).
596
+ press(handle, items, 0, { y: 78 });
597
+ move(0, 78);
598
+ await frames(3);
599
+ expect(container.scrollTop).toBe(0);
600
+ up(0, 78);
601
+
602
+ // A live drag DOES scroll…
603
+ press(handle, items, 0, { y: 10 });
604
+ move(0, 79);
605
+ await frames(2);
606
+ expect(container.scrollTop).toBeGreaterThan(0);
607
+ // …and the loop is gone with the drop.
608
+ up(0, 79);
609
+ const settled = container.scrollTop;
610
+ await frames(3);
611
+ expect(container.scrollTop).toBe(settled);
612
+
613
+ // A cancelled drag likewise.
614
+ press(handle, items, 0, { y: 10 });
615
+ move(0, 79);
616
+ await frames(2);
617
+ expect(container.scrollTop).toBeGreaterThan(settled);
618
+ cancel();
619
+ const cancelled = container.scrollTop;
620
+ await frames(3);
621
+ expect(container.scrollTop).toBe(cancelled);
622
+ });
623
+
624
+ it("swallows the click a drag would deliver, and only that one", () => {
625
+ const { items } = strip("x");
626
+ const { handle, drops } = harness("x", items);
627
+ const inner = document.createElement("span");
628
+ items[0].appendChild(inner);
629
+ const clicks: string[] = [];
630
+ items[0].addEventListener("click", () => clicks.push("row"));
631
+
632
+ // A plain press/release is a click: nothing is swallowed.
633
+ press(handle, items, 0, { x: 10 });
634
+ up(11);
635
+ inner.dispatchEvent(new MouseEvent("click", { bubbles: true }));
636
+ expect(clicks).toEqual(["row"]);
637
+
638
+ // A real drag is not: the trailing click is eaten at the window
639
+ // capture phase, so the row's own handler never sees it.
640
+ press(handle, items, 0, { x: 10 });
641
+ move(70);
642
+ up(70);
643
+ expect(drops).toEqual([[0, 1]]);
644
+ inner.dispatchEvent(new MouseEvent("click", { bubbles: true }));
645
+ expect(clicks).toEqual(["row"]);
646
+
647
+ // …and only that one: the next user click is delivered again.
648
+ inner.dispatchEvent(new MouseEvent("click", { bubbles: true }));
649
+ expect(clicks).toEqual(["row", "row"]);
650
+ });
651
+ });