Restore native scrolling and reliable recognition; add feel tests
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
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("manipulation");
|
||||
expect(surface.style.touchAction).toBe("none");
|
||||
expect(surface.style.width).toBe("100%");
|
||||
expect(surface.style.height).toBe("100%");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
shallowRef,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
allOf,
|
||||
anyOf,
|
||||
@@ -104,68 +104,65 @@ const hostFor = (
|
||||
),
|
||||
});
|
||||
|
||||
describe("axis lock and native scrolling", () => {
|
||||
describe("recognition and native scrolling", () => {
|
||||
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
||||
|
||||
it("holds the touch while undecided or along its axis", async () => {
|
||||
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));
|
||||
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);
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,36 +182,84 @@ describe("scroll edges", () => {
|
||||
}),
|
||||
);
|
||||
const mounted = await mount(Root);
|
||||
Object.defineProperty(scroller.value!, "scrollTop", { value: scrollTop });
|
||||
return mounted;
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
describe("without directional touch-action (Safari)", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("CSS", { supports: () => false });
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ 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("manipulation");
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
|
||||
// Swap mid-gesture: the drag in progress keeps the leftward definition.
|
||||
host.dispatchEvent(pointer("pointerdown", 180, 0));
|
||||
@@ -295,14 +295,14 @@ describe("reactive gesture props", () => {
|
||||
),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
expect(host.style.touchAction).toBe("manipulation");
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
|
||||
list.value = [
|
||||
...list.value,
|
||||
gesture.to.down().navigate(toTarget).animate(motion),
|
||||
];
|
||||
await nextTick();
|
||||
expect(host.style.touchAction).toBe("manipulation");
|
||||
expect(host.style.touchAction).toBe("none");
|
||||
|
||||
host.dispatchEvent(
|
||||
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
|
||||
|
||||
Reference in New Issue
Block a user