/** * Where an application can trigger haptic feedback with the current API. */ import { createApp, defineComponent, h, nextTick, watch, type Component, } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createOriginScene, defineOriginChoreography, forward, gesture, OriginScene, originView, useOriginGesture, type OriginGestureDefinition, } from "../src"; const apps: Array> = []; afterEach(() => { for (const app of apps.splice(0)) app.unmount(); document.body.innerHTML = ""; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); const motion = defineOriginChoreography({ name: "haptics-test", effects: ({ progress }) => ({ target: { opacity: progress } }), }); const Target: Component = defineComponent({ render: () => h("div", "T") }); function pointer(type: string, clientX: number, timeStamp: number) { const event = new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, isPrimary: true, pointerId: 1, clientX, clientY: 100, }); Object.defineProperty(event, "timeStamp", { value: timeStamp }); return event; } async function flush() { for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0)); } /** Mount a scene whose root installs `definition`, then return its host. */ async function mount(definition: OriginGestureDefinition) { // Reduced motion settles instantly, so release outcomes apply at once. vi.stubGlobal("matchMedia", () => ({ matches: true })); const Root = defineComponent({ setup() { const binding = useOriginGesture(definition); return () => h("div", { id: "host", style: binding.style, onPointerdown: binding.onPointerdown, onPointermove: binding.onPointermove, onPointerup: binding.onPointerup, }); }, }); const scene = createOriginScene({ initial: originView(Root) }); const root = document.createElement("div"); document.body.append(root); const app = createApp({ render: () => h(OriginScene, { scene }) }); apps.push(app); app.mount(root); await nextTick(); const host = root.querySelector("#host") as HTMLElement; Object.defineProperty(host, "clientWidth", { value: 200 }); host.getBoundingClientRect = () => ({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect; return { scene, host }; } /** Swipe left by `distance` px, slowly enough that velocity cannot commit. */ async function swipe(host: HTMLElement, distance: number, log: string[]) { host.dispatchEvent(pointer("pointerdown", 190, 0)); host.dispatchEvent(pointer("pointermove", 180, 500)); await flush(); log.push("recognized"); host.dispatchEvent(pointer("pointermove", 190 - distance, 1000)); await flush(); host.dispatchEvent(pointer("pointerup", 190 - distance, 1000)); await flush(); log.push("released"); } describe("haptic feedback", () => { it("can vibrate when a gesture is recognized, from .navigate()", async () => { const log: string[] = []; const vibrate = vi.fn(() => log.push("vibrate") > 0); vi.stubGlobal("navigator", { ...navigator, vibrate }); const { host } = await mount( gesture.to .left() .navigate(() => { navigator.vibrate?.(10); return forward(originView(Target)); }) .animate(motion), ); // A short swipe that is cancelled on release still vibrated, because // .navigate() runs at recognition, before the outcome is known. await swipe(host, 20, log); expect(log).toEqual(["vibrate", "recognized", "released"]); }); it("can vibrate only when a release commits, by watching operation intent", async () => { const log: string[] = []; const vibrate = vi.fn(() => log.push("vibrate") > 0); vi.stubGlobal("navigator", { ...navigator, vibrate }); const { scene, host } = await mount( gesture.to .left() .navigate(() => forward(originView(Target))) .animate(motion), ); // Intent becomes "commit" synchronously at release, before settling. const seen = new Set(); watch( () => scene.operations.value.filter((o) => o.intent === "commit"), (committed) => { for (const operation of committed) if (!seen.has(operation.id)) { seen.add(operation.id); navigator.vibrate?.(10); } }, { flush: "sync" }, ); await swipe(host, 20, log); // 10% progress: cancelled expect(vibrate).not.toHaveBeenCalled(); await swipe(host, 150, log); // 75% progress: committed expect(log).toEqual([ "recognized", "released", "recognized", "vibrate", "released", ]); }); it("can vibrate on release from a custom .complete() decision", async () => { const vibrate = vi.fn(() => true); vi.stubGlobal("navigator", { ...navigator, vibrate }); const { host } = await mount( gesture.to .left() .complete(({ progress }) => { const commit = progress > 0.5; if (commit) navigator.vibrate?.(10); return commit; }) .navigate(() => forward(originView(Target))) .animate(motion), ); await swipe(host, 20, []); expect(vibrate).not.toHaveBeenCalled(); await swipe(host, 150, []); expect(vibrate).toHaveBeenCalledTimes(1); }); }); describe(".onCommit()", () => { it("vibrates once when a release commits, and not when it cancels", async () => { const vibrate = vi.fn((_ms: number) => true); const { host } = await mount( gesture.to .left() .navigate(() => forward(originView(Target))) .onCommit(() => vibrate(10)) .animate(motion), ); await swipe(host, 20, []); // 10% progress: cancelled expect(vibrate).not.toHaveBeenCalled(); await swipe(host, 150, []); // 75% progress: committed expect(vibrate).toHaveBeenCalledTimes(1); expect(vibrate).toHaveBeenCalledWith(10); }); it("is available at every stage after the direction, in order", async () => { const calls: string[] = []; const definition = gesture.to .left() .onCommit(() => calls.push("directed")) .complete(({ progress }) => progress > 0.5) .onCommit(() => calls.push("completed")) .navigate(() => forward(originView(Target))) .onCommit((context) => calls.push(`navigation ${context.direction}`)) .animate(motion); expect(definition.commitHandlers).toHaveLength(3); expect(Object.isFrozen(definition.commitHandlers)).toBe(true); const { host } = await mount(definition); await swipe(host, 150, []); expect(calls).toEqual(["directed", "completed", "navigation left"]); }); it("keeps earlier builder stages unchanged", () => { const base = gesture.to.left().navigate(() => null); const withHandler = base.onCommit(() => {}); expect(base.animate(motion).commitHandlers).toEqual([]); expect(withHandler.animate(motion).commitHandlers).toHaveLength(1); }); it("reports a failing handler without stopping later handlers or the commit", async () => { const errors: unknown[] = []; const onError = (error: unknown) => errors.push(error); process.on("uncaughtException", onError); try { const after = vi.fn(); const { scene, host } = await mount( gesture.to .left() .navigate(() => forward(originView(Target))) .onCommit(() => { throw new Error("no vibration motor"); }) .onCommit(after) .animate(motion), ); await swipe(host, 150, []); expect(after).toHaveBeenCalledTimes(1); expect(scene.nodes.value.map((node) => node.state)).toEqual([ "parked", "active", ]); expect(errors).toEqual([new Error("no vibration motor")]); } finally { process.off("uncaughtException", onError); } }); it("runs synchronously inside the pointer-up event", async () => { let calledDuringDispatch = false; let dispatching = false; const { host } = await mount( gesture.to .left() .navigate(() => forward(originView(Target))) .onCommit(() => { calledDuringDispatch = dispatching; }) .animate(motion), ); host.dispatchEvent(pointer("pointerdown", 190, 0)); host.dispatchEvent(pointer("pointermove", 180, 500)); await flush(); host.dispatchEvent(pointer("pointermove", 40, 1000)); await flush(); // No awaiting between the event and the handler: iOS only allows // haptics, audio, and clipboard writes during the user's gesture. dispatching = true; host.dispatchEvent(pointer("pointerup", 40, 1000)); dispatching = false; expect(calledDuringDispatch).toBe(true); }); it("fires at release, before the settling animation finishes", async () => { const frames: FrameRequestCallback[] = []; vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb), ); const log: string[] = []; const { scene, host } = await mount( gesture.to .left() .navigate(() => forward(originView(Target))) .onCommit(() => log.push("commit")) .animate(motion), ); vi.stubGlobal("matchMedia", () => ({ matches: false })); await swipe(host, 150, log); expect(log).toEqual(["recognized", "commit", "released"]); // The spring is still waiting for frames; the operation is settling. expect(scene.operations.value[0]?.phase).toBe("settling"); expect(frames.length).toBeGreaterThan(0); }); });