Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2: Scrolling (#3, #4, #14) - Each recognizer locks onto a touch within its first 6px: along its axis and direction it holds the touch, otherwise it lets go. A new onTouchmove binding cancels native scrolling only while a recognizer holds the touch, so hosts use touch-action: manipulation and one vertical gesture no longer disables vertical scrolling. Several gestures on one host share touches through the same rule. - .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a scroller is at the edge (#5). Feel - recognition.distance sets what counts as full progress: pixels, a CSS length, or a measuring function (#6). - Choreographies receive overshoot, the drag past either end of the clamped progress, and the settling spring starts from it (#7). - Velocity is the least-squares trend of the last 100ms (#8). - Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the slide presets use them (#9). - Choreographies may set spring stiffness and damping (#10). Hooks and helpers - .onArm()/.onDisarm() run during a drag when releasing would start or stop committing (#12). - startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15). - Development builds warn when an operation's first frame moves a node that was visibly at rest, the one-frame "pop" of mismatched open and close choreographies (#16). Docs: scrolling, ignored elements, animation performance, haptics, and operating-system edge gestures including CloseWatcher/popstate for PWAs (#11, #17, #18). Verification: a Chromium harness with emulated touch (verify/browser) checks swipes over scrollers, native scrolling beside gestures, scroll-edge dismissal, onCommit inside pointerup, and the continuity warning. Reverting the earlier capture fix makes it fail exactly as reported ("none of the handles work"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -97,7 +97,7 @@ describe("gesture builder", () => {
|
||||
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
|
||||
expect(surface.tagName).toBe("SECTION");
|
||||
expect(surface.classList.contains("nvo-gesture")).toBe(true);
|
||||
expect(surface.style.touchAction).toBe("none");
|
||||
expect(surface.style.touchAction).toBe("manipulation");
|
||||
expect(surface.style.width).toBe("100%");
|
||||
expect(surface.style.height).toBe("100%");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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<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("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<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);
|
||||
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 = `<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"]);
|
||||
});
|
||||
});
|
||||
@@ -241,16 +241,17 @@ describe("reactive gesture props", () => {
|
||||
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
|
||||
const current = shallowRef(leftward);
|
||||
const { scene, host } = await mount(gestureHost(() => current.value));
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
expect(host.style.touchAction).toBe("manipulation");
|
||||
|
||||
// Swap mid-gesture: the drag in progress keeps the leftward definition.
|
||||
host.dispatchEvent(pointer("pointerdown", 180, 0));
|
||||
current.value = rightward;
|
||||
await nextTick();
|
||||
host.dispatchEvent(pointer("pointermove", 120, 100));
|
||||
host.dispatchEvent(pointer("pointermove", 140, 100));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
host.dispatchEvent(pointer("pointerup", 120, 1000));
|
||||
// A short, held release (20%) cancels, leaving the origin interactive.
|
||||
host.dispatchEvent(pointer("pointerup", 140, 1000));
|
||||
await flush();
|
||||
|
||||
// The next touch uses the rightward definition.
|
||||
@@ -294,14 +295,14 @@ describe("reactive gesture props", () => {
|
||||
),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
expect(host.style.touchAction).toBe("manipulation");
|
||||
|
||||
list.value = [
|
||||
...list.value,
|
||||
gesture.to.down().navigate(toTarget).animate(motion),
|
||||
];
|
||||
await nextTick();
|
||||
expect(host.style.touchAction).toBe("none");
|
||||
expect(host.style.touchAction).toBe("manipulation");
|
||||
|
||||
host.dispatchEvent(
|
||||
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
|
||||
@@ -327,9 +328,9 @@ describe("commitVelocity", () => {
|
||||
choreography: { effects: () => ({}), commitVelocity },
|
||||
}) as unknown as Parameters<typeof shouldCommit>[0];
|
||||
|
||||
it("defaults to 0.3", () => {
|
||||
expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false);
|
||||
expect(shouldCommit(operation(undefined, 0.3), {})).toBe(true);
|
||||
it("defaults to 0.2", () => {
|
||||
expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
|
||||
expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the choreography's value when supplied", () => {
|
||||
|
||||
Reference in New Issue
Block a user