@excom/gesture-handler 0.1.0

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.
Files changed (39) hide show
  1. package/.rush/temp/chunked-rush-logs/gesture-handler.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/gesture-handler.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/gesture-handler.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +6 -0
  15. package/gesture-handler.ts +1211 -0
  16. package/index.css +5 -0
  17. package/index.ts +29 -0
  18. package/package.json +52 -0
  19. package/rush-logs/gesture-handler.apply-exports.cache.log +1 -0
  20. package/rush-logs/gesture-handler.apply-exports.log +1 -0
  21. package/rush-logs/gesture-handler.build_docs.cache.log +1 -0
  22. package/rush-logs/gesture-handler.build_docs.log +1 -0
  23. package/rush-logs/gesture-handler.build_package-metas.cache.log +1 -0
  24. package/rush-logs/gesture-handler.build_package-metas.log +1 -0
  25. package/src/gesture-handler.css +207 -0
  26. package/support/custom-elements.json +989 -0
  27. package/support/demos/carousel.html +44 -0
  28. package/support/demos/pinch.html +10 -0
  29. package/support/demos/sheet.html +50 -0
  30. package/support/demos/swipe.html +24 -0
  31. package/support/dist-docs/gesture-handler.md +339 -0
  32. package/support/docs/README.md +92 -0
  33. package/support/package-meta.json +489 -0
  34. package/support/tests/carousel.view.test.ts +67 -0
  35. package/support/tests/gesture-handler.test.ts +625 -0
  36. package/support/tests/pointer-utils.ts +62 -0
  37. package/support/tests/sheet.view.test.ts +112 -0
  38. package/support/tests/swipe.view.test.ts +44 -0
  39. package/tsconfig.json +5 -0
@@ -0,0 +1,625 @@
1
+ import {
2
+ afterEach,
3
+ describe,
4
+ expect,
5
+ fixture,
6
+ it,
7
+ vi,
8
+ wait,
9
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
10
+ import "../../index";
11
+
12
+ type PointerInit = {
13
+ id?: number;
14
+ x?: number;
15
+ y?: number;
16
+ pointerType?: string;
17
+ button?: number;
18
+ };
19
+
20
+ const pointerEvent = (type: string, init: PointerInit = {}) =>
21
+ new PointerEvent(type, {
22
+ pointerId: init.id ?? 1,
23
+ clientX: init.x ?? 0,
24
+ clientY: init.y ?? 0,
25
+ pointerType: init.pointerType ?? "touch",
26
+ button: init.button ?? 0,
27
+ bubbles: true,
28
+ cancelable: true,
29
+ });
30
+
31
+ /** Pointer down targets the surface (or `target`); moves / ups arrive on `window`, as with pointer capture. */
32
+ const down = (el: Element, init?: PointerInit, target: Element = el) =>
33
+ target.dispatchEvent(pointerEvent("pointerdown", init));
34
+ const move = (init?: PointerInit) =>
35
+ window.dispatchEvent(pointerEvent("pointermove", init));
36
+ const up = (init?: PointerInit) =>
37
+ window.dispatchEvent(pointerEvent("pointerup", init));
38
+ const cancel = (init?: PointerInit) =>
39
+ window.dispatchEvent(pointerEvent("pointercancel", init));
40
+ /** One-finger `touchmove` on `target`; returns the event to read `defaultPrevented`. */
41
+ const touchMove = (target: Element, x: number, y: number) => {
42
+ const touch = new Touch({ identifier: 1, target, clientX: x, clientY: y });
43
+ const e = new TouchEvent("touchmove", {
44
+ touches: [touch],
45
+ changedTouches: [touch],
46
+ bubbles: true,
47
+ cancelable: true,
48
+ });
49
+ target.dispatchEvent(e);
50
+ return e;
51
+ };
52
+ /** Resolves after the element's pending animation frame ran. */
53
+ const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
54
+ const cssVar = (el: HTMLElement, name: string) =>
55
+ el.style.getPropertyValue(`--gesture-${name}`);
56
+
57
+ const mount = (attrs = "", inner = "<p>surface</p>") => {
58
+ const el = fixture<HTMLGestureHandlerElement>(
59
+ `<gesture-handler ${attrs}>${inner}</gesture-handler>`
60
+ );
61
+ const events: CustomEvent[] = [];
62
+ const record = (e: Event) => events.push(e as CustomEvent);
63
+ [
64
+ "start",
65
+ "move",
66
+ "end",
67
+ "cancel",
68
+ "swipe",
69
+ "swipe-left",
70
+ "swipe-right",
71
+ "swipe-up",
72
+ "swipe-down",
73
+ "tap",
74
+ "double-tap",
75
+ "long-press",
76
+ "snap",
77
+ ].forEach((name) => el.addEventListener(`gesture-handler-${name}`, record));
78
+ const types = () => events.map((e) => e.type.replace("gesture-handler-", ""));
79
+ const last = (name: string) =>
80
+ [...events].reverse().find((e) => e.type === `gesture-handler-${name}`);
81
+ return { el, events, types, last };
82
+ };
83
+
84
+ /** A recognized single-finger pan of `dx` / `dy`, left down. */
85
+ const pan = async (el: Element, dx: number, dy: number) => {
86
+ down(el, { x: 100, y: 100 });
87
+ move({ x: 100 + dx / 2, y: 100 + dy / 2 });
88
+ await frame();
89
+ move({ x: 100 + dx, y: 100 + dy });
90
+ await frame();
91
+ };
92
+
93
+ describe("gesture-handler", () => {
94
+ afterEach(() => {
95
+ document.body.innerHTML = "";
96
+ vi.restoreAllMocks();
97
+ });
98
+
99
+ it("renders nothing of its own and starts idle", () => {
100
+ const { el } = mount();
101
+ expect(el).dom.to.equalTag(`<gesture-handler><p>surface</p></gesture-handler>`);
102
+ expect(el.gestureTypes).toEqual(["pan"]);
103
+ expect(el.pointerTypes).toEqual(["touch", "pen"]);
104
+ expect(el.hasAttribute("is-active")).toBe(false);
105
+ });
106
+
107
+ it("recognizes a pan past the threshold, writes --gesture-* and announces start / end", async () => {
108
+ const { el, types, last } = mount(`range-px="200"`);
109
+ down(el, { x: 10, y: 10 });
110
+ expect(el.hasAttribute("is-active")).toBe(true);
111
+ expect(el.getAttribute("pointer-count")).toBe("1");
112
+ move({ x: 14, y: 10 });
113
+ await frame();
114
+ // below threshold-px: values are written, nothing recognized
115
+ expect(cssVar(el, "dx")).toBe("4px");
116
+ expect(el.hasAttribute("gesture-type")).toBe(false);
117
+ expect(types()).toEqual([]);
118
+
119
+ move({ x: 60, y: 30 });
120
+ await frame();
121
+ expect(el.getAttribute("gesture-type")).toBe("pan");
122
+ expect(el.getAttribute("gesture-direction")).toBe("right");
123
+ expect(cssVar(el, "dx")).toBe("50px");
124
+ expect(cssVar(el, "dy")).toBe("20px");
125
+ expect(cssVar(el, "progress")).toBe("0.25");
126
+ expect(cssVar(el, "range-px")).toBe("200px");
127
+ expect(cssVar(el, "pointers")).toBe("1");
128
+ // derived values are CSS declarations, never written per frame
129
+ expect(cssVar(el, "distance")).toBe("");
130
+ expect(cssVar(el, "progress-px")).toBe("");
131
+ expect(cssVar(el, "angle")).toBe("");
132
+ expect(cssVar(el, "x-ratio")).toBe("");
133
+ expect(types()).toEqual(["start"]);
134
+ const start = last("start")!;
135
+ expect(start.bubbles).toBe(true);
136
+ expect(start.detail.type).toBe("pan");
137
+ expect(start.detail.pointerType).toBe("touch");
138
+ expect(el.provision?.type).toBe("pan");
139
+
140
+ up({ x: 60, y: 30 });
141
+ expect(types()).toEqual(["start", "end"]);
142
+ expect(el.hasAttribute("is-active")).toBe(false);
143
+ expect(el.hasAttribute("gesture-type")).toBe(false);
144
+ expect(el.hasAttribute("pointer-count")).toBe(false);
145
+ expect(el.getAttribute("last-gesture")).toBe("pan");
146
+ const end = last("end")!;
147
+ expect(end.cancelable).toBe(true);
148
+ expect(end.detail).toMatchObject({ type: "pan", dx: 50, dy: 20, snap: null, swipe: null });
149
+ expect(el.provision).toEqual(end.detail);
150
+ // values persist after release
151
+ expect(cssVar(el, "dx")).toBe("50px");
152
+ });
153
+
154
+ it("only fires -move with should-emit-move", async () => {
155
+ const quiet = mount();
156
+ await pan(quiet.el, 40, 0);
157
+ expect(quiet.types()).toEqual(["start"]);
158
+ up();
159
+
160
+ const loud = mount("should-emit-move");
161
+ await pan(loud.el, 40, 0); // recognized on the first frame, one move frame after
162
+ move({ x: 150, y: 100 });
163
+ await frame();
164
+ expect(loud.types()).toEqual(["start", "move", "move"]);
165
+ expect(loud.last("move")!.detail.dx).toBe(50);
166
+ });
167
+
168
+ it("ignores mouse unless listed, secondary buttons, and everything while is-disabled", async () => {
169
+ const { el, types } = mount();
170
+ down(el, { pointerType: "mouse" });
171
+ expect(el.hasAttribute("is-active")).toBe(false);
172
+
173
+ el.setAttribute("pointer-types", "mouse");
174
+ down(el, { pointerType: "mouse", button: 2 });
175
+ expect(el.hasAttribute("is-active")).toBe(false);
176
+ down(el, { pointerType: "mouse" });
177
+ expect(el.hasAttribute("is-active")).toBe(true);
178
+ up();
179
+
180
+ el.setAttribute("is-disabled", "");
181
+ down(el, { pointerType: "mouse" });
182
+ expect(el.hasAttribute("is-active")).toBe(false);
183
+ expect(types()).toEqual([]);
184
+ });
185
+
186
+ it("cancels a gesture in progress when is-disabled is set or the browser takes the pointer", async () => {
187
+ const { el, types } = mount();
188
+ await pan(el, 40, 0);
189
+ el.setAttribute("is-disabled", "");
190
+ expect(types()).toEqual(["start", "cancel"]);
191
+ expect(el.hasAttribute("is-active")).toBe(false);
192
+ el.removeAttribute("is-disabled");
193
+
194
+ await pan(el, 40, 0);
195
+ cancel({ x: 140, y: 100 });
196
+ expect(types()).toEqual(["start", "cancel", "start", "cancel"]);
197
+ expect(el.provision?.type).toBe("pan");
198
+ });
199
+
200
+ it("cancels on a real disconnect and ignores stray pointer ids", async () => {
201
+ const { el, types } = mount();
202
+ await pan(el, 40, 0);
203
+ move({ id: 9, x: 500, y: 500 });
204
+ up({ id: 9 });
205
+ expect(el.hasAttribute("is-active")).toBe(true);
206
+ el.remove();
207
+ await wait(0);
208
+ expect(types()).toEqual(["start", "cancel"]);
209
+ });
210
+
211
+ it("fires tap and double-tap for presses that never move", async () => {
212
+ const { el, types, last } = mount(`gesture-types="tap double-tap" double-tap-ms="500"`);
213
+ down(el, { x: 30, y: 40 });
214
+ await frame();
215
+ up({ x: 30, y: 40 });
216
+ expect(types()).toEqual(["tap"]);
217
+ expect(last("tap")!.detail).toEqual({ x: 30, y: 40 });
218
+ expect(el.getAttribute("last-gesture")).toBe("tap");
219
+ down(el, { x: 30, y: 40 });
220
+ up({ x: 30, y: 40 });
221
+ expect(types()).toEqual(["tap", "tap", "double-tap"]);
222
+ expect(el.getAttribute("last-gesture")).toBe("double-tap");
223
+ // the pair is consumed: a third tap starts over
224
+ down(el, { x: 30, y: 40 });
225
+ up({ x: 30, y: 40 });
226
+ expect(types()).toEqual(["tap", "tap", "double-tap", "tap"]);
227
+ });
228
+
229
+ it("fires long-press after long-press-ms and then skips the tap", async () => {
230
+ const { el, types } = mount(`gesture-types="tap long-press" long-press-ms="20"`);
231
+ down(el, { x: 5, y: 5 });
232
+ await wait(40);
233
+ expect(types()).toEqual(["long-press"]);
234
+ expect(el.getAttribute("last-gesture")).toBe("long-press");
235
+ up({ x: 5, y: 5 });
236
+ expect(types()).toEqual(["long-press"]);
237
+ });
238
+
239
+ it("arm-after=long-press rejects a pan that moves before the hold", async () => {
240
+ const { el, types } = mount(`arm-after="long-press" long-press-ms="20"`);
241
+ await pan(el, 40, 0);
242
+ expect(types()).toEqual([]);
243
+ expect(el.hasAttribute("gesture-type")).toBe(false);
244
+ up();
245
+
246
+ down(el, { x: 100, y: 100 });
247
+ await wait(40);
248
+ move({ x: 140, y: 100 });
249
+ await frame();
250
+ expect(types()).toEqual(["start"]);
251
+ expect(el.getAttribute("gesture-type")).toBe("pan");
252
+ up();
253
+ expect(types()).toEqual(["start", "end"]);
254
+ });
255
+
256
+ it("pan-x / pan-y only recognize their axis and lock-axis names the dominant one", async () => {
257
+ const x = mount(`gesture-types="pan-x"`);
258
+ await pan(x.el, 0, 40);
259
+ expect(x.types()).toEqual([]);
260
+ up();
261
+ await pan(x.el, 40, 0);
262
+ expect(x.el.getAttribute("gesture-type")).toBe("pan-x");
263
+ up();
264
+
265
+ const y = mount(`gesture-types="pan-y"`);
266
+ await pan(y.el, 40, 0);
267
+ expect(y.types()).toEqual([]);
268
+ up();
269
+ await pan(y.el, 0, 40);
270
+ expect(y.el.getAttribute("gesture-type")).toBe("pan-y");
271
+ expect(y.el.getAttribute("gesture-direction")).toBe("down");
272
+ up();
273
+
274
+ const locked = mount(`lock-axis`);
275
+ await pan(locked.el, 10, -40);
276
+ expect(locked.el.getAttribute("gesture-type")).toBe("pan-y");
277
+ expect(locked.el.getAttribute("gesture-direction")).toBe("up");
278
+ up();
279
+ });
280
+
281
+ it("from-ref and from-edge gate where a gesture may start", async () => {
282
+ const { el } = mount(
283
+ `from-ref="[data-handle]" from-edge="left" edge-px="20"`,
284
+ `<p>body</p><p data-handle>handle</p>`
285
+ );
286
+ vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
287
+ left: 0,
288
+ top: 0,
289
+ right: 300,
290
+ bottom: 100,
291
+ width: 300,
292
+ height: 100,
293
+ } as DOMRect);
294
+ const [body, handle] = el.querySelectorAll("p");
295
+ down(el, { x: 150, y: 50 }, body);
296
+ expect(el.hasAttribute("is-active")).toBe(false);
297
+ down(el, { x: 150, y: 50 }, handle);
298
+ expect(el.hasAttribute("is-active")).toBe(true);
299
+ // the element's own box is measured once, when the session opens
300
+ expect(cssVar(el, "width")).toBe("300px");
301
+ expect(cssVar(el, "height")).toBe("100px");
302
+ up({ x: 150, y: 50 });
303
+ down(el, { x: 10, y: 50 }, body);
304
+ expect(el.hasAttribute("is-active")).toBe(true);
305
+ up();
306
+ });
307
+
308
+ /** A sheet whose own content scrolls: open (`progress-offset="1"`), axis up. */
309
+ const mountHandoff = (attrs = "") => {
310
+ const api = mount(
311
+ `gesture-types="pan-y swipe" progress-axis="up" progress-offset="1"
312
+ range-px="200" handoff-ref="[data-scroller]" ${attrs}`,
313
+ `<div data-scroller><p>content</p></div>`
314
+ );
315
+ const scroller = api.el.querySelector<HTMLElement>("[data-scroller]")!;
316
+ return { ...api, scroller, content: scroller.querySelector("p")! };
317
+ };
318
+
319
+ it("handoff-ref hands an overscroll at the top of the scroller to the gesture", async () => {
320
+ const { el, content, types } = mountHandoff();
321
+ await frame();
322
+ // the pointer alone starts nothing: the container may still scroll
323
+ down(el, { x: 100, y: 300 }, content);
324
+ expect(el.hasAttribute("is-active")).toBe(false);
325
+ expect(types()).toEqual([]);
326
+
327
+ // first move is downward at scrollTop 0: native scrolling is cancelled
328
+ const first = touchMove(content, 100, 306);
329
+ expect(first.defaultPrevented).toBe(true);
330
+ expect(el.hasAttribute("is-active")).toBe(true);
331
+
332
+ // from there it is an ordinary pan, measured from the pointer's origin
333
+ move({ x: 100, y: 400 });
334
+ await frame();
335
+ expect(el.getAttribute("gesture-type")).toBe("pan-y");
336
+ expect(cssVar(el, "dy")).toBe("100px");
337
+ expect(cssVar(el, "progress")).toBe("0.5");
338
+ expect(types()).toEqual(["start"]);
339
+ up({ x: 100, y: 400 });
340
+ });
341
+
342
+ it("handoff-ref keeps native scrolling while the scroller can still scroll", async () => {
343
+ const { el, scroller, content } = mountHandoff();
344
+ await frame();
345
+ scroller.scrollTop = 40;
346
+ down(el, { x: 100, y: 300 }, content);
347
+ expect(touchMove(content, 100, 306).defaultPrevented).toBe(false);
348
+ expect(el.hasAttribute("is-active")).toBe(false);
349
+ // the first move decided: reaching the top mid-scroll changes nothing
350
+ scroller.scrollTop = 0;
351
+ expect(touchMove(content, 100, 360).defaultPrevented).toBe(false);
352
+ expect(el.hasAttribute("is-active")).toBe(false);
353
+
354
+ // dropping handoff-ref drops its listeners: the scroller is ordinary
355
+ // surface again and a pointer down inside it starts at once
356
+ el.removeAttribute("handoff-ref");
357
+ down(el, { x: 100, y: 300 }, content);
358
+ expect(el.hasAttribute("is-active")).toBe(true);
359
+ up({ x: 100, y: 300 });
360
+ });
361
+
362
+ it("handoff-ref ignores a move with no progress left that way, or across the axis", async () => {
363
+ const { el, content } = mountHandoff();
364
+ await frame();
365
+ // upward, but progress-offset is already at progress-max
366
+ down(el, { x: 100, y: 300 }, content);
367
+ expect(touchMove(content, 100, 290).defaultPrevented).toBe(false);
368
+ expect(el.hasAttribute("is-active")).toBe(false);
369
+ // sideways: not along progress-axis
370
+ down(el, { x: 100, y: 300 }, content);
371
+ expect(touchMove(content, 110, 302).defaultPrevented).toBe(false);
372
+ expect(el.hasAttribute("is-active")).toBe(false);
373
+ });
374
+
375
+ it("handoff-ref reads the horizontal limit for a horizontal progress-axis", async () => {
376
+ const { el } = mount(
377
+ `gesture-types="pan-x" progress-axis="left" progress-offset="1" range-px="200"
378
+ handoff-ref="[data-scroller]"`,
379
+ `<div data-scroller><p>content</p></div>`
380
+ );
381
+ await frame();
382
+ const scroller = el.querySelector<HTMLElement>("[data-scroller]")!;
383
+ const content = scroller.querySelector("p")!;
384
+ scroller.scrollLeft = 30;
385
+ down(el, { x: 100, y: 100 }, content);
386
+ expect(touchMove(content, 106, 100).defaultPrevented).toBe(false);
387
+ expect(el.hasAttribute("is-active")).toBe(false);
388
+
389
+ scroller.scrollLeft = 0;
390
+ down(el, { x: 100, y: 100 }, content);
391
+ expect(touchMove(content, 106, 100).defaultPrevented).toBe(true);
392
+ expect(el.hasAttribute("is-active")).toBe(true);
393
+ up({ x: 106, y: 100 });
394
+ });
395
+
396
+ it("handoff-ref starts a mouse drag on the first move, with nothing to prevent", async () => {
397
+ const { el, content } = mountHandoff(`pointer-types="touch mouse"`);
398
+ await frame();
399
+ down(el, { x: 100, y: 300, pointerType: "mouse" }, content);
400
+ const first = pointerEvent("pointermove", {
401
+ x: 100,
402
+ y: 310,
403
+ pointerType: "mouse",
404
+ });
405
+ content.dispatchEvent(first);
406
+ expect(first.defaultPrevented).toBe(false);
407
+ expect(el.hasAttribute("is-active")).toBe(true);
408
+ up({ x: 100, y: 310 });
409
+ });
410
+
411
+ it("handoff-ref forgets the start on release, and leaves from-ref starts alone", async () => {
412
+ const { el, content } = mountHandoff(`pointer-types="touch mouse"`);
413
+ await frame();
414
+ down(el, { x: 100, y: 300, pointerType: "mouse" }, content);
415
+ content.dispatchEvent(pointerEvent("pointerup", { x: 100, y: 300, pointerType: "mouse" }));
416
+ const late = pointerEvent("pointermove", { x: 100, y: 330, pointerType: "mouse" });
417
+ content.dispatchEvent(late);
418
+ expect(el.hasAttribute("is-active")).toBe(false);
419
+
420
+ // a handle inside the scroller keeps starting on pointerdown
421
+ const handle = mount(
422
+ `gesture-types="pan-y" progress-axis="up" range-px="200"
423
+ handoff-ref="[data-scroller]" from-ref="[data-handle]"`,
424
+ `<div data-scroller><header data-handle>handle</header><p>content</p></div>`
425
+ );
426
+ await frame();
427
+ down(handle.el, { x: 100, y: 100 }, handle.el.querySelector("[data-handle]")!);
428
+ expect(handle.el.hasAttribute("is-active")).toBe(true);
429
+ up();
430
+ });
431
+
432
+ it("measures progress along progress-axis from range-ref, offset, bounds and resistance", async () => {
433
+ const { el } = mount(
434
+ `gesture-types="pan-y" progress-axis="up" range-ref="[data-sheet]" progress-offset="1" overshoot-resistance="0.5"`,
435
+ `<div data-sheet></div>`
436
+ );
437
+ Object.defineProperty(el.querySelector("[data-sheet]"), "offsetHeight", { value: 100 });
438
+ await pan(el, 0, 40);
439
+ expect(cssVar(el, "range-px")).toBe("100px");
440
+ expect(cssVar(el, "progress")).toBe("0.6");
441
+ // past progress-min: half of the overshoot
442
+ move({ x: 100, y: 260 });
443
+ await frame();
444
+ expect(cssVar(el, "progress")).toBe("-0.3");
445
+ // past progress-max (offset 100px + 200px up = 300px; 100px over, halved)
446
+ move({ x: 100, y: -100 });
447
+ await frame();
448
+ expect(cssVar(el, "progress")).toBe("2");
449
+ up();
450
+ });
451
+
452
+ it("clamps hard without resistance and yields no progress without a range", async () => {
453
+ const clamped = mount(`range-px="50"`);
454
+ await pan(clamped.el, 200, 0);
455
+ expect(cssVar(clamped.el, "progress")).toBe("1");
456
+ expect(cssVar(clamped.el, "range-px")).toBe("50px");
457
+ up({ x: 300, y: 100 });
458
+
459
+ const free = mount();
460
+ await pan(free.el, 200, 0);
461
+ expect(cssVar(free.el, "progress")).toBe("0");
462
+ expect(cssVar(free.el, "range-px")).toBe("0px");
463
+ up({ x: 300, y: 100 });
464
+ });
465
+
466
+ it("picks the nearest snap point, settles on it and fires -snap", async () => {
467
+ const { el, types, last } = mount(`range-px="100" snap-points="0 0.5 1"`);
468
+ await pan(el, 60, 0);
469
+ await wait(150); // let the velocity window drain: no fling
470
+ up({ x: 160, y: 100 });
471
+ expect(last("end")!.detail.snap).toBe(0.5);
472
+ expect(types()).toEqual(["start", "end"]);
473
+ // nothing to transition here (happy-dom has no getAnimations): -snap
474
+ // lands in the default action's own task
475
+ await wait(0);
476
+ expect(types()).toEqual(["start", "end", "snap"]);
477
+ expect(last("snap")!.detail).toEqual({ value: 0.5, index: 1 });
478
+ expect(cssVar(el, "progress")).toBe("0.5");
479
+ });
480
+
481
+ it("waits for the settle transition, and stays quiet when a new gesture cancels it", async () => {
482
+ // the promise is made where the element asks for it, so the rejected one
483
+ // is never briefly unhandled
484
+ const settle = async (finished: () => Promise<unknown>) => {
485
+ const api = mount(`range-px="100" snap-points="0 1"`);
486
+ Object.assign(api.el, {
487
+ getAnimations: () => [
488
+ { transitionProperty: "opacity", finished: Promise.resolve() },
489
+ { transitionProperty: "--gesture-progress", finished: finished() },
490
+ ],
491
+ });
492
+ await pan(api.el, 60, 0);
493
+ await wait(150);
494
+ up({ x: 160, y: 100 });
495
+ await wait(0); // default action, then the transition's own task
496
+ await wait(0);
497
+ return api;
498
+ };
499
+
500
+ const landed = await settle(() => Promise.resolve());
501
+ expect(landed.types()).toEqual(["start", "end", "snap"]);
502
+ expect(landed.last("snap")!.detail).toEqual({ value: 1, index: 1 });
503
+ expect(cssVar(landed.el, "progress")).toBe("1");
504
+
505
+ const cancelled = await settle(() => Promise.reject(new Error("interrupted")));
506
+ expect(cancelled.types()).toEqual(["start", "end"]);
507
+ });
508
+
509
+ it("preventDefault() on -end skips the settle; snap points outside the bounds are ignored", async () => {
510
+ const { el, types, last } = mount(`range-px="100" snap-points="0 1" progress-max="0.5"`);
511
+ el.addEventListener("gesture-handler-end", (e) => e.preventDefault());
512
+ await pan(el, 40, 0);
513
+ up({ x: 140, y: 100 });
514
+ expect(last("end")!.detail.snap).toBe(0);
515
+ await wait(40);
516
+ expect(types()).toEqual(["start", "end"]);
517
+ expect(cssVar(el, "progress")).toBe("0.4");
518
+ });
519
+
520
+ it("recognizes a swipe from release velocity and snaps in its direction", async () => {
521
+ const { el, types, last } = mount(
522
+ `gesture-types="pan-x swipe" range-px="1000" snap-points="0 0.5 1"`
523
+ );
524
+ await pan(el, 40, 0);
525
+ move({ x: 200, y: 100 });
526
+ await wait(2);
527
+ up({ x: 200, y: 100 });
528
+ expect(types().slice(0, 3)).toEqual(["start", "swipe", "swipe-right"]);
529
+ expect(last("swipe")!.detail.direction).toBe("right");
530
+ expect(last("swipe")!.detail.velocity).toBeGreaterThan(0.5);
531
+ expect(el.getAttribute("last-gesture")).toBe("swipe-right");
532
+ // progress is 0.1: a fling forward snaps to the next point, not the nearest
533
+ expect(last("end")!.detail).toMatchObject({ swipe: "right", snap: 0.5 });
534
+ });
535
+
536
+ it("filters swipes by swipe-directions and by the pan axis", async () => {
537
+ const { el, types, last } = mount(`gesture-types="pan swipe" swipe-directions="left"`);
538
+ await pan(el, 40, 0);
539
+ move({ x: 200, y: 100 });
540
+ await wait(2);
541
+ up({ x: 200, y: 100 });
542
+ expect(types()).toEqual(["start", "end"]);
543
+ expect(last("end")!.detail.swipe).toBeNull();
544
+
545
+ const axis = mount(`gesture-types="pan-x swipe"`);
546
+ await pan(axis.el, 40, 4);
547
+ move({ x: 141, y: 300 });
548
+ await wait(2);
549
+ up({ x: 141, y: 300 });
550
+ expect(axis.types()).toEqual(["start", "end"]);
551
+ });
552
+
553
+ it("tracks pinch and rotate with two pointers and keeps going when one lifts", async () => {
554
+ const { el, types, last } = mount(`gesture-types="pinch rotate"`);
555
+ down(el, { id: 1, x: 100, y: 100 });
556
+ down(el, { id: 2, x: 200, y: 100 });
557
+ down(el, { id: 3, x: 300, y: 300 }); // beyond max-pointers
558
+ expect(el.getAttribute("pointer-count")).toBe("2");
559
+ await frame();
560
+ expect(types()).toEqual(["start"]);
561
+ expect(el.getAttribute("gesture-type")).toBe("pinch");
562
+ expect(last("start")!.detail.pointers).toBe(2);
563
+
564
+ move({ id: 2, x: 300, y: 100 });
565
+ await frame();
566
+ expect(cssVar(el, "scale")).toBe("2");
567
+ expect(cssVar(el, "rotate")).toBe("0deg");
568
+ move({ id: 2, x: 100, y: 300 });
569
+ await frame();
570
+ expect(cssVar(el, "scale")).toBe("2");
571
+ expect(cssVar(el, "rotate")).toBe("90deg");
572
+
573
+ up({ id: 2, x: 100, y: 300 });
574
+ expect(el.getAttribute("pointer-count")).toBe("1");
575
+ expect(el.hasAttribute("is-active")).toBe(true);
576
+ await frame();
577
+ expect(cssVar(el, "scale")).toBe("2");
578
+ up({ id: 1, x: 100, y: 100 });
579
+ expect(types()).toEqual(["start", "end"]);
580
+ expect(last("end")!.detail.type).toBe("pinch");
581
+ });
582
+
583
+ it("a second finger can still start a pinch after a rejected single-finger move", async () => {
584
+ const { el, types } = mount(`gesture-types="pinch"`);
585
+ await pan(el, 40, 0);
586
+ expect(types()).toEqual([]);
587
+ down(el, { id: 2, x: 200, y: 200 });
588
+ await frame();
589
+ expect(types()).toEqual(["start"]);
590
+ up({ id: 1 });
591
+ up({ id: 2 });
592
+ });
593
+
594
+ it("pointer capture starts at recognition so taps stay native, and a pointer joining mid-pan keeps travel continuous", async () => {
595
+ const { el } = mount(`gesture-types="pan pinch"`);
596
+ const capture = vi.spyOn(el, "setPointerCapture").mockImplementation(() => {
597
+ throw new Error("no such pointer");
598
+ });
599
+ down(el, { id: 1, x: 100, y: 100 });
600
+ expect(capture).not.toHaveBeenCalled();
601
+ move({ id: 1, x: 150, y: 100 });
602
+ await frame();
603
+ expect(capture).toHaveBeenCalledWith(1);
604
+ down(el, { id: 2, x: 250, y: 100 });
605
+ expect(capture).toHaveBeenCalledWith(2);
606
+ await frame();
607
+ // centroid jumped to x=200, travel stays 50
608
+ expect(cssVar(el, "dx")).toBe("50px");
609
+ expect(cssVar(el, "pointers")).toBe("2");
610
+ up({ id: 1 });
611
+ up({ id: 2 });
612
+ });
613
+
614
+ it("recognizes a flick that starts and ends within one frame", async () => {
615
+ const { el, types, last } = mount(`gesture-types="pan-x swipe"`);
616
+ down(el, { x: 100, y: 100 });
617
+ move({ x: 160, y: 100 });
618
+ await wait(2);
619
+ up({ x: 160, y: 100 });
620
+ expect(types()).toEqual(["start", "swipe", "swipe-right", "end"]);
621
+ expect(last("end")!.detail.type).toBe("pan-x");
622
+ expect(el.getAttribute("last-gesture")).toBe("swipe-right");
623
+ expect(el.hasAttribute("is-active")).toBe(false);
624
+ });
625
+ });
@@ -0,0 +1,62 @@
1
+ /** Synthetic touch gestures for view tests: down on `target`, moves / ups on `window` (as pointer capture delivers them). */
2
+ const pointerEvent = (type: string, x: number, y: number) =>
3
+ new PointerEvent(type, {
4
+ pointerId: 1,
5
+ pointerType: "touch",
6
+ clientX: x,
7
+ clientY: y,
8
+ bubbles: true,
9
+ cancelable: true,
10
+ });
11
+
12
+ export const frame = () =>
13
+ new Promise<void>((r) => requestAnimationFrame(() => r()));
14
+
15
+ /** Press at `from`, move in two frames to `to`; returns the release. */
16
+ export const drag = async (
17
+ target: Element,
18
+ from: { x: number; y: number },
19
+ to: { x: number; y: number }
20
+ ) => {
21
+ target.dispatchEvent(pointerEvent("pointerdown", from.x, from.y));
22
+ window.dispatchEvent(
23
+ pointerEvent("pointermove", (from.x + to.x) / 2, (from.y + to.y) / 2)
24
+ );
25
+ await frame();
26
+ window.dispatchEvent(pointerEvent("pointermove", to.x, to.y));
27
+ await frame();
28
+ return () => window.dispatchEvent(pointerEvent("pointerup", to.x, to.y));
29
+ };
30
+ export const pull = async (
31
+ target: Element,
32
+ from: { x: number; y: number },
33
+ to: { x: number; y: number }
34
+ ) => {
35
+ target.dispatchEvent(pointerEvent("pointerdown", from.x, from.y));
36
+ const touch = new Touch({
37
+ identifier: 1,
38
+ target,
39
+ clientX: from.x + Math.sign(to.x - from.x) * 8,
40
+ clientY: from.y + Math.sign(to.y - from.y) * 8,
41
+ });
42
+ const first = new TouchEvent("touchmove", {
43
+ touches: [touch],
44
+ changedTouches: [touch],
45
+ bubbles: true,
46
+ cancelable: true,
47
+ });
48
+ target.dispatchEvent(first);
49
+ await frame();
50
+ window.dispatchEvent(pointerEvent("pointermove", to.x, to.y));
51
+ await frame();
52
+ return {
53
+ first,
54
+ release: () => window.dispatchEvent(pointerEvent("pointerup", to.x, to.y)),
55
+ };
56
+ };
57
+
58
+ export const tap = async (target: Element, x = 10, y = 10) => {
59
+ target.dispatchEvent(pointerEvent("pointerdown", x, y));
60
+ await frame();
61
+ window.dispatchEvent(pointerEvent("pointerup", x, y));
62
+ };