import { createApp, defineComponent, h, nextTick, shallowRef, type Component, } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import { allOf, anyOf, atScrollEdge, createOriginScene, defineOriginChoreography, forward, gesture, nearEdge, OriginGesture, OriginGestureSurface, OriginScene, originView, startedIn, type OriginChoreographyContext, type OriginGestureDefinition, type OriginGestureStartContext, } from "../src"; import { DirectionalTracker } from "../src/gesture/recognizer"; import { stepSpring } from "../src/scene/scene"; const apps: Array> = []; afterEach(() => { for (const app of apps.splice(0)) app.unmount(); document.body.innerHTML = ""; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); const contexts: OriginChoreographyContext[] = []; const motion = defineOriginChoreography({ name: "v2-test", effects: (context) => { contexts.push(context); return { target: { opacity: context.progress } }; }, }); const Target: Component = defineComponent({ render: () => h("div", "T") }); const toTarget = () => forward(originView(Target)); function pointer( type: string, clientX: number, clientY: number, timeStamp: number, ) { const event = new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, isPrimary: true, pointerId: 1, clientX, clientY, }); Object.defineProperty(event, "timeStamp", { value: timeStamp }); return event; } function touchmove() { return new TouchEvent("touchmove", { bubbles: true, cancelable: true }); } async function flush() { for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0)); } async function mount(root: Component) { vi.stubGlobal("matchMedia", () => ({ matches: true })); const scene = createOriginScene({ initial: originView(root) }); const element = document.createElement("div"); document.body.append(element); const app = createApp({ render: () => h(OriginScene, { scene }) }); apps.push(app); app.mount(element); await nextTick(); const host = element.querySelector("#host") as HTMLElement; Object.defineProperties(host, { clientWidth: { configurable: true, value: 200 }, clientHeight: { configurable: true, value: 400 }, }); host.getBoundingClientRect = () => ({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect; return { scene, host }; } const hostFor = ( definition: OriginGestureDefinition, children?: () => unknown, ) => defineComponent({ setup: () => () => h(OriginGesture, { id: "host", gesture: definition }, () => children ? children() : "host", ), }); describe("axis lock and native scrolling", () => { const leftward = gesture.to.left().navigate(toTarget).animate(motion); it("holds the touch while undecided or along its axis", async () => { const { scene, host } = await mount(hostFor(leftward)); host.dispatchEvent(pointer("pointerdown", 150, 100, 0)); const undecided = touchmove(); host.dispatchEvent(undecided); expect(undecided.defaultPrevented).toBe(true); host.dispatchEvent(pointer("pointermove", 140, 101, 16)); await flush(); const along = touchmove(); host.dispatchEvent(along); expect(along.defaultPrevented).toBe(true); expect(scene.operations.value).toHaveLength(1); }); it("lets go of a touch moving along the other axis", async () => { const { scene, host } = await mount(hostFor(leftward)); host.dispatchEvent(pointer("pointerdown", 150, 100, 0)); host.dispatchEvent(pointer("pointermove", 149, 110, 16)); const vertical = touchmove(); host.dispatchEvent(vertical); expect(vertical.defaultPrevented).toBe(false); // Later horizontal movement no longer starts the gesture. host.dispatchEvent(pointer("pointermove", 90, 115, 32)); await flush(); expect(scene.operations.value).toHaveLength(0); }); it("lets go of a touch moving the opposite way", async () => { const { scene, host } = await mount(hostFor(leftward)); host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); host.dispatchEvent(pointer("pointermove", 110, 100, 16)); const opposite = touchmove(); host.dispatchEvent(opposite); expect(opposite.defaultPrevented).toBe(false); expect(scene.operations.value).toHaveLength(0); }); it("hands a touch between gestures sharing one host", async () => { const down = gesture.to.down().navigate(toTarget).animate(motion); const Root = defineComponent({ setup: () => () => h( OriginGestureSurface, { id: "host", gestures: [leftward, down] }, () => "x", ), }); const { scene, host } = await mount(Root); expect(host.style.touchAction).toBe("manipulation"); host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); host.dispatchEvent(pointer("pointermove", 101, 140, 16)); await flush(); // The downward gesture took it; the leftward one let go. expect(scene.operations.value).toHaveLength(1); const move = touchmove(); host.dispatchEvent(move); expect(move.defaultPrevented).toBe(true); }); }); describe("scroll edges", () => { const scroller = shallowRef(null); const dismiss = gesture.from .scrollEdge(scroller, "top") .to.down() .navigate(toTarget) .animate(motion); async function mountWithScroller(scrollTop: number) { const Root = hostFor(dismiss, () => h("div", { id: "list", ref: (element) => (scroller.value = element as HTMLElement | null), }), ); const mounted = await mount(Root); Object.defineProperty(scroller.value!, "scrollTop", { value: scrollTop }); return mounted; } it("starts a downward drag only when the scroller is at the top", async () => { const atTop = await mountWithScroller(0); atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16)); await flush(); expect(atTop.scene.operations.value).toHaveLength(1); }); it("leaves a scrolled list to scroll", async () => { const scrolled = await mountWithScroller(120); scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); const move = touchmove(); scrolled.host.dispatchEvent(move); expect(move.defaultPrevented).toBe(false); scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16)); await flush(); expect(scrolled.scene.operations.value).toHaveLength(0); }); it("at the top, still lets an upward drag scroll the list", async () => { const atTop = await mountWithScroller(0); atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0)); atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16)); const move = touchmove(); atTop.host.dispatchEvent(move); expect(move.defaultPrevented).toBe(false); }); }); describe("start-rule helpers", () => { const host = document.createElement("div"); host.innerHTML = `

`; const context = (target: Element, localX = 100): OriginGestureStartContext => ({ event: { target } as unknown as PointerEvent, origin: {} as never, host, bounds: { top: 0, left: 0, width: 200, height: 400 }, point: { clientX: localX, clientY: 10, localX, localY: 10 }, }) as OriginGestureStartContext; it("matches pointer-down inside a selector", () => { expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe( true, ); expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe( false, ); }); it("composes with anyOf and allOf", () => { const body = host.querySelector("#body")!; const nearLeft = nearEdge("left", 40); expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true); expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe( false, ); expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true); expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false); }); it("checks scroll edges, treating a missing scroller as at the edge", () => { const list = document.createElement("div"); Object.defineProperties(list, { scrollTop: { value: 0 }, clientHeight: { value: 100 }, scrollHeight: { value: 300 }, }); const body = host.querySelector("#body")!; expect(atScrollEdge(list, "top")(context(body))).toBe(true); expect(atScrollEdge(list, "bottom")(context(body))).toBe(false); expect(atScrollEdge(null, "top")(context(body))).toBe(true); expect(atScrollEdge(".missing", "top")(context(body))).toBe(true); }); }); describe("progress", () => { it("measures against a custom distance and reports overshoot", async () => { contexts.length = 0; const drawer = gesture.to .right({ distance: 100 }) .navigate(toTarget) .animate(motion); const { scene, host } = await mount(hostFor(drawer)); host.dispatchEvent(pointer("pointerdown", 0, 100, 0)); host.dispatchEvent(pointer("pointermove", 50, 100, 16)); await flush(); host.dispatchEvent(pointer("pointermove", 130, 100, 32)); await flush(); // Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot. expect(scene.operations.value[0]).toMatchObject({ progress: 1 }); expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3); scene.styleForNode(scene.operations.value[0]!.targetKey); expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3); host.dispatchEvent(pointer("pointermove", -20, 100, 48)); await flush(); expect(scene.operations.value[0]).toMatchObject({ progress: 0 }); expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2); }); it("averages velocity over recent samples instead of the last step", () => { const tracker = new DirectionalTracker( "right", { clientX: 0, clientY: 0, timeStamp: 0 }, () => 100, ); // Steady 1px/ms with one jittery, late sample. for (const [x, t] of [ [16, 16], [32, 32], [48, 48], [52, 50], [80, 80], ] as const) tracker.sample({ clientX: x, clientY: 0, timeStamp: t }); expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px. // Held still for longer than the window: the flick is over. tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 }); expect(tracker.velocity).toBe(0); }); }); describe("springs", () => { it("uses the choreography's stiffness and damping", () => { const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, { stiffness: 80, }); const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, { stiffness: 600, }); expect(stiff.position).toBeGreaterThan(soft.position); const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16); expect(unchanged.position).toBeGreaterThan(soft.position); }); }); describe(".onArm() and .onDisarm()", () => { it("fire when the drag crosses the commit threshold", async () => { const events: string[] = []; const definition = gesture.to .left() .navigate(toTarget) .onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`)) .onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`)) .animate(motion); const { host } = await mount(hostFor(definition)); host.dispatchEvent(pointer("pointerdown", 190, 100, 0)); for (const [x, t] of [ [180, 16], [160, 32], [130, 48], [100, 64], [150, 80], [90, 96], ] as const) { host.dispatchEvent(pointer("pointermove", x, 100, t)); await flush(); } // Default arming is distance-based: 25% of the 200px host is 50px. expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]); }); });