- Only the host's own lostpointercapture cancels a gesture. Taking capture makes the touched child lose its implicit touch capture, and that bubbling event was cancelling every touch gesture immediately. - A flick at least commitVelocity fast now decides by its direction: backward cancels even past the distance threshold, forward commits. Slower releases still decide by distance. - Pointer-up finishes an already-begun operation without awaiting, so onCommit runs inside the pointer-up event, where iOS still allows haptics, audio, and clipboard writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
/**
|
|
* 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<ReturnType<typeof createApp>> = [];
|
|
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<number>();
|
|
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);
|
|
});
|
|
});
|