@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.
- package/.rush/temp/chunked-rush-logs/gesture-handler.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/gesture-handler.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/gesture-handler.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/gesture-handler.ts +1211 -0
- package/index.css +5 -0
- package/index.ts +29 -0
- package/package.json +52 -0
- package/rush-logs/gesture-handler.apply-exports.cache.log +1 -0
- package/rush-logs/gesture-handler.apply-exports.log +1 -0
- package/rush-logs/gesture-handler.build_docs.cache.log +1 -0
- package/rush-logs/gesture-handler.build_docs.log +1 -0
- package/rush-logs/gesture-handler.build_package-metas.cache.log +1 -0
- package/rush-logs/gesture-handler.build_package-metas.log +1 -0
- package/src/gesture-handler.css +207 -0
- package/support/custom-elements.json +989 -0
- package/support/demos/carousel.html +44 -0
- package/support/demos/pinch.html +10 -0
- package/support/demos/sheet.html +50 -0
- package/support/demos/swipe.html +24 -0
- package/support/dist-docs/gesture-handler.md +339 -0
- package/support/docs/README.md +92 -0
- package/support/package-meta.json +489 -0
- package/support/tests/carousel.view.test.ts +67 -0
- package/support/tests/gesture-handler.test.ts +625 -0
- package/support/tests/pointer-utils.ts +62 -0
- package/support/tests/sheet.view.test.ts +112 -0
- package/support/tests/swipe.view.test.ts +44 -0
- 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
|
+
};
|