The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
import {
|
|
createApp,
|
|
defineComponent,
|
|
h,
|
|
nextTick,
|
|
shallowRef,
|
|
type Component,
|
|
} from "vue";
|
|
import { afterEach, beforeEach, 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<ReturnType<typeof createApp>> = [];
|
|
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("recognition and native scrolling", () => {
|
|
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
|
|
|
it("leaves scrolling to the browser through touch-action alone", async () => {
|
|
const { scene, host } = await mount(hostFor(leftward));
|
|
// A horizontal gesture keeps native vertical panning.
|
|
expect(host.style.touchAction).toBe("pan-y");
|
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
|
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
|
|
await flush();
|
|
const move = touchmove();
|
|
host.dispatchEvent(move);
|
|
// Ordinary gestures never block touchmove, so the browser never waits.
|
|
expect(move.defaultPrevented).toBe(false);
|
|
expect(scene.operations.value).toHaveLength(1);
|
|
});
|
|
|
|
it("recognizes a swipe that starts with a vertical wobble", async () => {
|
|
const { scene, host } = await mount(hostFor(leftward));
|
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
|
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
|
|
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
|
|
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
|
|
await flush();
|
|
expect(scene.operations.value).toHaveLength(1);
|
|
});
|
|
|
|
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
|
|
const { scene, host } = await mount(hostFor(leftward));
|
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
|
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
|
|
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
|
|
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
|
|
await flush();
|
|
expect(scene.operations.value).toHaveLength(1);
|
|
});
|
|
|
|
it("claims touch-action per axis like native scrolling expects", async () => {
|
|
const down = gesture.to.down().navigate(toTarget).animate(motion);
|
|
const scroller = shallowRef<HTMLElement | null>(null);
|
|
const dismiss = gesture.from
|
|
.scrollEdge(scroller, "top")
|
|
.to.down()
|
|
.navigate(toTarget)
|
|
.animate(motion);
|
|
const surface = (gestures: OriginGestureDefinition[]) =>
|
|
defineComponent({
|
|
setup: () => () =>
|
|
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
|
|
});
|
|
expect(
|
|
(await mount(surface([leftward, down]))).host.style.touchAction,
|
|
).toBe("none");
|
|
document.body.innerHTML = "";
|
|
// A scroll-edge gesture at its edge reserves only its own pan: with the
|
|
// horizontal gesture claiming pan-x, only scrolling down remains.
|
|
expect(
|
|
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
|
|
).toBe("pan-down");
|
|
});
|
|
});
|
|
|
|
describe("scroll edges", () => {
|
|
const scroller = shallowRef<HTMLElement | null>(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);
|
|
let top = scrollTop;
|
|
Object.defineProperty(scroller.value!, "scrollTop", {
|
|
get: () => top,
|
|
configurable: true,
|
|
});
|
|
const scrollTo = async (value: number) => {
|
|
top = value;
|
|
scroller.value!.dispatchEvent(new Event("scroll"));
|
|
await nextTick();
|
|
};
|
|
await scrollTo(scrollTop);
|
|
return { ...mounted, scrollTo };
|
|
}
|
|
|
|
describe("with directional touch-action", () => {
|
|
it("reserves only the dismiss pan while the list is at the top", async () => {
|
|
const { host, scrollTo } = await mountWithScroller(0);
|
|
// Dragging up still scrolls natively; dragging down is the gesture's.
|
|
expect(host.style.touchAction).toBe("pan-x pan-down");
|
|
await scrollTo(120);
|
|
expect(host.style.touchAction).toBe("manipulation");
|
|
await scrollTo(0);
|
|
expect(host.style.touchAction).toBe("pan-x pan-down");
|
|
});
|
|
|
|
it("never blocks touchmove", async () => {
|
|
const { host } = await mountWithScroller(0);
|
|
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
|
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
|
|
const move = touchmove();
|
|
host.dispatchEvent(move);
|
|
expect(move.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
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));
|
|
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
|
await flush();
|
|
expect(scrolled.scene.operations.value).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("without directional touch-action (Safari)", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("CSS", { supports: () => false });
|
|
});
|
|
|
|
it("keeps the axis pannable and claims touches from JavaScript", async () => {
|
|
const atTop = await mountWithScroller(0);
|
|
expect(atTop.host.style.touchAction).toBe("manipulation");
|
|
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
|
// The browser's first cancelable touchmove arrives after its slop.
|
|
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
|
|
const first = touchmove();
|
|
atTop.host.dispatchEvent(first);
|
|
expect(first.defaultPrevented).toBe(true);
|
|
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
|
|
await flush();
|
|
expect(atTop.scene.operations.value).toHaveLength(1);
|
|
});
|
|
|
|
it("lets an upward drag at the top 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 = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
|
|
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"]);
|
|
});
|
|
});
|