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:
Claude
2026-11-03 05:56:26 +00:00
parent 6889fc0112
commit caf2ad692a
13 changed files with 558 additions and 226 deletions
+2 -1
View File
@@ -49,7 +49,8 @@
"electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"prepare": "npm run build --workspace packages/core-v2"
"prepare": "npm run build --workspace packages/core-v2",
"test:feel": "NODE_ENV=development vite build --mode development --config packages/core-v2/verify/browser/app/vite.config.mjs && node packages/core-v2/verify/browser/run.mjs"
},
"devDependencies": {
"@capacitor/app": "^8.0.0",
+70 -32
View File
@@ -220,11 +220,10 @@ const gestures = [forwardGesture, backGesture] as const;
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
The component adds no recognition or navigation policy. It installs each
definition, forwards every pointer and touch event to every recognizer, and
lets the recognizers share the touch: each locks onto a touch moving along its
own axis and direction and lets go of any other, so a horizontal drawer, a
pull-up sheet, and vertical scrolling can live on one host. See
[Scrolling](#scrolling).
definition, forwards every pointer event to every recognizer, and derives one
`touch-action` that leaves native scrolling on every pan no gesture needs. A
horizontal drawer and a scroll-edge pull-down can share a host with vertical
scrolling. See [Scrolling](#scrolling).
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
definition objects never interrupts a gesture in progress; each change applies
@@ -546,25 +545,43 @@ interface OriginGestureBinding {
readonly onPointerup: (event: PointerEvent) => void;
readonly onPointercancel: () => void;
readonly onLostpointercapture: (event: PointerEvent) => void;
readonly onTouchmove: (event: TouchEvent) => void;
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
readonly ref: (element: unknown) => void;
}
```
Apply all handlers to the same element, as non-passive listeners (Vue's
`@touchmove` is), and apply the returned style. The style sets dimensions and
`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out
of a gesture the recognizer has claimed. Without it, the browser takes over
vertical and horizontal drags with `pointercancel`.
Apply the pointer handlers, the returned style, and `listeners` to the same
element, and pass `ref` as its function ref:
```vue
<div
:ref="binding.ref"
:style="binding.style"
v-bind="binding.listeners"
@pointerdown="binding.onPointerdown"
@pointermove="binding.onPointermove"
@pointerup="binding.onPointerup"
@pointercancel="binding.onPointercancel"
@lostpointercapture="binding.onLostpointercapture"
/>
```
The style sets dimensions and the host's `touch-action` (see
[Scrolling](#scrolling)). `listeners` is empty for ordinary gestures; for
scroll-edge gestures it tracks the scroller, and where the browser lacks
directional `touch-action` it adds a `touchmove` handler.
Recognition requires:
1. A primary, left-button pointer satisfies the optional start policy.
2. The target is not an ignored interactive element.
3. The first 6px of movement are along the gesture's axis and direction (see
[Scrolling](#scrolling)).
4. Directed movement reaches `threshold`.
5. Directed movement exceeds cross-axis movement by `axisDominance`.
6. The navigation factory returns an intent.
3. Directed movement from the pointer-down point reaches `threshold`.
4. Directed movement exceeds cross-axis movement by `axisDominance`.
5. The navigation factory returns an intent.
Recognition is measured from where the pointer went down and keeps watching
until the pointer lifts, so a slightly wobbly or backward start only delays
it.
Progress is directed distance divided by the gesture's `distance` (by default
the host's width or height). Past either end, the excess reaches
@@ -927,24 +944,43 @@ cause:
### Scrolling
Gestures and native scrolling share the same touches. Each recognizer decides
within the first 6px of movement whether a touch is its own:
Native scrolling stays in the browser's hands: gesture hosts never make it
wait for JavaScript. Each host gets a `touch-action` that keeps every pan no
gesture needs:
- **along its axis and direction**: it holds the touch, and the binding's
`onTouchmove` cancels native scrolling while it does;
- **along the other axis, or the opposite way**: it lets go, and the browser
scrolls (or another gesture on the host takes it);
- **diagonal**: it keeps watching until the movement is clear.
| Gestures on the host | `touch-action` |
| --------------------------------------------------------------- | ---------------- |
| Horizontal only | `pan-y` |
| Vertical only | `pan-x` |
| Both axes | `none` |
| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` |
| The same, list scrolled | `manipulation` |
Browsers hold back the first cancelable `touchmove` for roughly 15–20px, so the
decision is made before native scrolling can start. This is why gesture hosts
use `touch-action: manipulation`: the browser may pan either axis, and the
recognizers keep it from doing so only for touches they claim. A vertical
dismiss gesture therefore no longer disables vertical scrolling; combine it
with `.from.scrollEdge()` so it only claims a downward drag at the top.
The browser therefore scrolls the free axis natively from the first frame,
and touches on a gesture's axis reach the recognizer untouched.
Nested scrollers need nothing special. Their `touchmove` events bubble to the
gesture host, which cancels them only while it holds the touch.
A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
above a list, uses `.from.scrollEdge()`. While the list is at that edge, the
host reserves only the one pan the gesture needs: at the top, dragging down is
the gesture's and dragging up still scrolls the list natively. This is the
pull-to-refresh pattern from the Pointer Events specification, and it relies
on direction-specific `touch-action` values (`pan-down` and its siblings). The
host follows the list's scroll position through a passive scroll listener, so
the right value is in place before each touch starts.
Browsers without direction-specific values (Safari) fall back to deciding in
JavaScript: the host keeps the axis pannable and cancels native scrolling
from a `touchmove` handler for touches the gesture claims, at the browser's
first cancelable `touchmove` (after roughly 15–20px of movement). Blocking
touch handlers make the browser wait for JavaScript on every move, so this
fallback is used only for scroll-edge gestures and only where it is needed.
Nested scroll containers: browsers apply `touch-action` only up to the nearest
scroll container, so a list scrolling inside a horizontal gesture host would
otherwise let the browser take horizontal drags and cancel the gesture. Each
host publishes its value as the inherited `--nvo-touch-action` custom property,
and the package stylesheet applies it to descendants, so nested lists keep
`pan-y` too. This requires `@native-vue-router/core-v2/style.css`.
### Ignored elements
@@ -952,7 +988,9 @@ Pointer-down on `input`, `textarea`, `select`, `option`, or
`[contenteditable="true"]` never starts a gesture, so text selection and
form controls keep working. Mark anything else that must keep its own drags
with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip
row or a slider inside a host that has a horizontal gesture.
row or a slider inside a host that has a horizontal gesture. Ignored subtrees
also get `touch-action: auto` back from the package stylesheet, so a chip row
scrolls horizontally inside a horizontal gesture host.
### Animation performance
@@ -20,7 +20,8 @@ const gesture = useOriginGesture(() => props.gesture);
<template>
<component
:is="as"
v-bind="$attrs"
:ref="gesture.ref"
v-bind="{ ...$attrs, ...gesture.listeners }"
class="nvo-gesture"
:style="gesture.style"
@pointerdown="gesture.onPointerdown"
@@ -28,7 +29,6 @@ const gesture = useOriginGesture(() => props.gesture);
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
@touchmove="gesture.onTouchmove"
>
<slot />
</component>
@@ -19,7 +19,8 @@ const surface = useOriginGestures(() => props.gestures);
<template>
<component
:is="as"
v-bind="$attrs"
:ref="surface.ref"
v-bind="{ ...$attrs, ...surface.listeners }"
class="nvo-gesture"
:style="[$attrs.style, surface.style]"
@pointerdown="surface.onPointerdown"
@@ -27,7 +28,6 @@ const surface = useOriginGestures(() => props.gestures);
@pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
@touchmove="surface.onTouchmove"
>
<slot />
</component>
+38 -27
View File
@@ -1,9 +1,9 @@
import { toValue, type CSSProperties, type MaybeRefOrGetter } from "vue";
import type { CSSProperties, MaybeRefOrGetter } from "vue";
import type { OriginChoreography } from "../choreography";
import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared";
import { withinEdge } from "./recognizer";
import { isAtScrollEdge, withinEdge } from "./recognizer";
/*
* What a gesture is: the public gesture types, component props, and the
@@ -78,6 +78,15 @@ export type OriginGestureStart =
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
}
| {
/**
* Recognize pointer-down while a scroller is at one edge. The gesture
* shares its axis with that scroller's native scrolling.
*/
readonly kind: "scroll-edge";
readonly scroller: OriginGestureScroller;
readonly edge: OriginGestureEdge;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
@@ -324,8 +333,10 @@ export interface OriginGestureBuilder {
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Attach all six handlers to the same `HTMLElement`, and apply
* {@link style}.
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
* when {@link needsTouchmove} is true), and apply
* {@link style}: its `touch-action` keeps native scrolling on every axis no
* gesture claims.
*/
export interface OriginGestureBinding {
/**
@@ -347,11 +358,28 @@ export interface OriginGestureBinding {
*/
readonly onLostpointercapture: (event: PointerEvent) => void;
/**
* Touch-move handler. Cancels native scrolling while a recognizer holds
* the touch, and leaves it alone once every recognizer has let go. It must
* be attached as a non-passive listener (Vue's `@touchmove` is).
* Touch-move handler for gestures that share an axis with native scrolling
* (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
* claims, and leaves every other touch to the browser. Attach it as a
* non-passive listener (Vue's `@touchmove` is), and only when
* {@link needsTouchmove} is true.
*/
readonly onTouchmove: (event: TouchEvent) => void;
/**
* Whether any gesture shares an axis with scrolling, so `onTouchmove` must
* be attached. Leave it off otherwise: a non-passive touch listener makes
* the browser wait for JavaScript on every touch move, which costs frames.
*/
readonly needsTouchmove: boolean;
/**
* Extra listeners the host needs, as Vue props: `v-bind` them on the host.
* Scroll-edge gestures track their scroller here (a passive scroll
* listener), plus `onTouchmove` where the browser lacks directional
* `touch-action`. Empty for ordinary gestures.
*/
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
/** Function ref for the host element: `:ref="binding.ref"`. */
readonly ref: (element: unknown) => void;
}
// Component props ----------------------------------------------------------
@@ -517,7 +545,7 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({
when: (predicate: OriginGestureStartPredicate) =>
fromStage({ kind: "when", predicate }),
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
fromStage({ kind: "when", predicate: atScrollEdge(scroller, edge) }),
fromStage({ kind: "scroll-edge", scroller, edge }),
});
// Start rules ---------------------------------------------------------------
@@ -555,25 +583,8 @@ export const atScrollEdge =
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureStartPredicate =>
({ host }) => {
const element =
typeof scroller === "string"
? host.querySelector(scroller)
: toValue(scroller);
if (!element) return true;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
const { clientHeight, clientWidth } = element;
switch (edge) {
case "top":
return scrollTop <= 0;
case "bottom":
return scrollTop + clientHeight >= scrollHeight - 1;
case "left":
return scrollLeft <= 0;
case "right":
return scrollLeft + clientWidth >= scrollWidth - 1;
}
};
({ host }) =>
isAtScrollEdge(scroller, edge, host);
/** Admit pointer-down when any of `predicates` does. */
export const anyOf =
+215 -67
View File
@@ -1,4 +1,10 @@
import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue";
import {
computed,
onScopeDispose,
shallowRef,
toValue,
type MaybeRefOrGetter,
} from "vue";
import type { OriginAction } from "../navigation";
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
import { useOrigin, type UseOrigin } from "../scene/scene";
@@ -13,6 +19,7 @@ import type {
OriginGestureEdge,
OriginGesturePoint,
OriginGestureProgressDistance,
OriginGestureScroller,
OriginGestureStart,
} from "./definition";
@@ -54,17 +61,84 @@ export const crossDistance = (
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
};
/** Whether a gesture shares its axis with native scrolling. */
export const sharesScroll = (definition: OriginGestureDefinition): boolean =>
definition.start.kind === "scroll-edge";
/**
* CSS `touch-action` for a gesture host. Recognizers arbitrate with native
* scrolling themselves (see `onTouchmove`), so the browser may pan either axis
* and is only kept from double-tap zooming; a host without gestures is left
* alone.
* Whether the browser supports direction-specific `touch-action` values
* (`pan-up`, `pan-down`, …). Chromium does; where it is missing (Safari),
* scroll-edge gestures fall back to claiming touches from JavaScript.
*/
export const supportsDirectionalPan = (): boolean =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("touch-action", "pan-down");
type PanDirection = "left" | "right" | "up" | "down";
/**
* The pan a finger moving `direction` would start. A finger moving down pans
* the content up (toward its top), which is what pull-to-refresh reserves.
*/
const PAN_OF: Record<OriginGestureDirection, PanDirection> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
/**
* CSS `touch-action` for a gesture host. The browser keeps every pan that no
* gesture needs, so native scrolling never waits for JavaScript:
*
* - a gesture that owns its axis removes that axis (`pan-y` remains for a
* horizontal gesture);
* - a gesture sharing its axis with a scroller (`.from.scrollEdge()`) removes
* only the one pan its finger direction would start, and only while the
* scroller is at its edge (`pan-x pan-down` for drag-down at the top), the
* pull-to-refresh pattern from the Pointer Events specification.
*
* @param atEdge - Whether a scroll-edge definition's scroller is at its edge.
* @param directional - Whether directional pan values are supported; without
* them, shared axes stay fully pannable and `onTouchmove` claims touches.
*/
export const touchActionFor = (
directions: Iterable<OriginGestureDirection>,
): "manipulation" | "auto" => {
for (const _ of directions) return "manipulation";
return "auto";
definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
atEdge: (
definition: Pick<OriginGestureDefinition, "start">,
) => boolean = () => false,
directional = supportsDirectionalPan(),
): string => {
const pans = new Set<PanDirection>(["left", "right", "up", "down"]);
let any = false;
for (const definition of definitions) {
any = true;
if (definition.start.kind === "scroll-edge") {
if (directional && atEdge(definition))
pans.delete(PAN_OF[definition.direction]);
continue;
}
const pair: PanDirection[] = isHorizontal(definition.direction)
? ["left", "right"]
: ["up", "down"];
for (const pan of pair) pans.delete(pan);
}
if (!any) return "auto";
if (pans.size === 4) return "manipulation";
if (pans.size === 0) return "none";
const axis = (a: PanDirection, b: PanDirection, both: string) =>
pans.has(a) && pans.has(b)
? [both]
: pans.has(a)
? [`pan-${a}`]
: pans.has(b)
? [`pan-${b}`]
: [];
return [
...axis("left", "right", "pan-x"),
...axis("up", "down", "pan-y"),
].join(" ");
};
// Pointer-down admission ----------------------------------------------------
@@ -163,10 +237,40 @@ export const matchesStart = (
const point = pointOf(event, bounds);
if (start.kind === "when")
return start.predicate({ event, origin, host, bounds, point });
if (start.kind === "scroll-edge")
return isAtScrollEdge(start.scroller, start.edge, host);
return withinEdge(start.edge, start.distance, host, bounds, point);
};
/**
* Whether `scroller` is scrolled all the way to `edge`. A scroller that is
* missing (not yet mounted) counts as at the edge.
*/
export const isAtScrollEdge = (
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
host: HTMLElement,
): boolean => {
const element =
typeof scroller === "string"
? host.querySelector(scroller)
: toValue(scroller);
if (!element) return true;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
const { clientHeight, clientWidth } = element;
switch (edge) {
case "top":
return scrollTop <= 0;
case "bottom":
return scrollTop + clientHeight >= scrollHeight - 1;
case "left":
return scrollLeft <= 0;
case "right":
return scrollLeft + clientWidth >= scrollWidth - 1;
}
};
/** Whether `point` lies within `distance` of one side of the host. */
export const withinEdge = (
edge: OriginGestureEdge,
@@ -244,7 +348,7 @@ export class DirectionalTracker {
}
/** Movement from the start without recording a sample. */
displacement(event: PointerSample) {
displacement(event: Pick<PointerSample, "clientX" | "clientY">) {
const dx = event.clientX - this.start.clientX;
const dy = event.clientY - this.start.clientY;
return {
@@ -295,12 +399,6 @@ export class DirectionalTracker {
const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */
const DEFAULT_AXIS_DOMINANCE = 1.15;
/**
* Movement in CSS pixels after which a recognizer decides whether a touch is
* along its axis. Browsers hold back the first cancelable `touchmove` for
* roughly 15–20px, so the decision is made before native scrolling can start.
*/
const AXIS_LOCK_DISTANCE = 6;
/** Resolve a definition's progress distance at pointer-down, in pixels. */
export const resolveProgressDistance = (
@@ -340,11 +438,12 @@ interface PointerSession {
readonly startTime: number;
readonly tracker: DirectionalTracker;
/**
* Whether the movement so far is along this gesture's axis and direction
* (`ours`) or still too small or too diagonal to tell (`pending`). A touch
* that turns out to be someone else's closes the session instead.
* For a gesture sharing its axis with scrolling: whether it has claimed
* this touch from native scrolling (`ours`) or not yet decided.
*/
axis: "pending" | "ours";
scroll: "pending" | "ours";
/** Latest pointer position, for the touch-move decision. */
last: { readonly clientX: number; readonly clientY: number };
/** Movement passed the recognition threshold and the pointer is captured. */
captured: boolean;
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
@@ -387,11 +486,37 @@ export class GestureRecognizer {
) {}
/**
* Whether this recognizer currently holds the touch, so native scrolling
* must not start: its session is open and has not ruled the touch out.
* Called for each `touchmove`: whether native scrolling must be cancelled.
*
* Only gestures that share an axis with scrolling take part. The browser
* sends its first cancelable `touchmove` after roughly 15–20px of movement,
* so the decision uses all of that movement: along this gesture's axis and
* direction claims the touch; clearly elsewhere lets it go to the browser
* (which then scrolls and sends `pointercancel`); unclear holds it for one
* more move.
*/
get intercepts(): boolean {
return this.session !== null;
claimTouch(): boolean {
const session = this.session;
if (!session || !sharesScroll(session.definition)) return false;
if (session.captured || session.scroll === "ours") return true;
const { distance, crossDistance } = session.tracker.displacement(
session.last,
);
const dominance =
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
const along = Math.abs(distance);
if (distance > 0 && along >= crossDistance * dominance) {
session.scroll = "ours";
return true;
}
if (
along >= crossDistance * dominance ||
crossDistance >= along * dominance
) {
this.session = null;
return false;
}
return true;
}
pointerDown(event: PointerEvent): void {
@@ -433,7 +558,8 @@ export class GestureRecognizer {
bounds,
),
),
axis: "pending",
scroll: "pending",
last: { clientX: event.clientX, clientY: event.clientY },
captured: false,
armed: false,
operation: null,
@@ -445,8 +571,8 @@ export class GestureRecognizer {
const session = this.sessionFor(event);
if (!session) return;
session.last = { clientX: event.clientX, clientY: event.clientY };
if (!session.captured) {
if (!this.lockAxis(session, event)) return;
if (!GestureRecognizer.recognizes(session, event)) return;
session.captured = true;
session.host.setPointerCapture?.(session.pointerId);
@@ -537,35 +663,6 @@ export class GestureRecognizer {
await handle?.cancel();
}
/**
* Decide, early in the movement, whether this touch is along the gesture's
* axis and direction. A touch that is clearly someone else's (the other
* axis, or the opposite way) closes the session, which hands it to native
* scrolling or to another recognizer.
*
* @returns Whether the session is still open.
*/
private lockAxis(session: PointerSession, event: PointerEvent): boolean {
if (session.axis === "ours") return true;
const { distance, crossDistance } = session.tracker.displacement(event);
const along = Math.abs(distance);
if (Math.max(along, crossDistance) < AXIS_LOCK_DISTANCE) return true;
const dominance =
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
if (along >= crossDistance * dominance && distance > 0) {
session.axis = "ours";
return true;
}
if (
along >= crossDistance * dominance ||
crossDistance >= along * dominance
) {
this.session = null;
return false;
}
return true; // Diagonal: keep watching.
}
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
private updateArming(session: PointerSession, event: PointerEvent) {
const { definition, tracker } = session;
@@ -691,24 +788,80 @@ export const useOriginGestures = (
return recognizers;
};
const style = computed(() => ({
// The host element, for scroll-edge state and selector scrollers.
const host = shallowRef<HTMLElement | null>(null);
// Bumped by scroll events; touch-action re-reads scroll edges on change.
const scrolled = shallowRef(0);
const directional = supportsDirectionalPan();
const atEdge = (definition: Pick<OriginGestureDefinition, "start">) => {
const { start } = definition;
if (start.kind !== "scroll-edge") return false;
const element = host.value;
if (!element && typeof start.scroller === "string") return true;
return isAtScrollEdge(start.scroller, start.edge, element!);
};
// A string, so hosts re-render only when touch-action actually changes,
// never merely because something scrolled.
const touchAction = computed(() => {
void scrolled.value;
return touchActionFor(toValue(definitions), atEdge, directional);
});
const sharesScrollAxis = computed(() =>
toValue(definitions).some(sharesScroll),
);
const style = computed(() => {
const value = touchAction.value;
return {
width: "100%",
height: "100%",
// Preserve native scrolling on any axis no gesture claims.
touchAction: touchActionFor(
toValue(definitions).map(({ direction }) => direction),
),
}));
// Native scrolling stays on every pan no gesture needs.
touchAction: value,
// Inherited by descendants through the package stylesheet, so nested
// scroll containers (where browsers restart touch-action) keep it too.
"--nvo-touch-action": value,
};
});
// A recognizer whose component unmounts mid-gesture cancels its operation.
onScopeDispose(() => {
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
});
// Only without directional touch-action do scroll-edge gestures need to
// claim touches from JavaScript, the one path that blocks scrolling.
const needsTouchmove = computed(() => sharesScrollAxis.value && !directional);
const onTouchmove = (event: TouchEvent) => {
if (!event.cancelable) return;
// Ask every recognizer: each one may let go of the touch as it answers.
let claimed = false;
for (const recognizer of current())
if (recognizer.claimTouch()) claimed = true;
if (claimed) event.preventDefault();
};
const onScrollCapture = () => {
scrolled.value += 1;
};
const listeners = computed(() => ({
...(sharesScrollAxis.value ? { onScrollCapture } : {}),
...(needsTouchmove.value ? { onTouchmove } : {}),
}));
return {
get style() {
return style.value;
},
get needsTouchmove() {
return needsTouchmove.value;
},
get listeners() {
return listeners.value as Record<string, (event: Event) => void>;
},
ref: (element) => {
host.value = element instanceof HTMLElement ? element : null;
scrolled.value += 1;
},
onPointerdown: (event) => {
for (const recognizer of current()) recognizer.pointerDown(event);
},
@@ -725,12 +878,7 @@ export const useOriginGestures = (
for (const recognizer of current())
void recognizer.pointerCaptureLost(event);
},
onTouchmove: (event) => {
// Native scrolling starts at the first touchmove not cancelled, so keep
// cancelling while any recognizer still holds the touch.
if (event.cancelable && current().some(({ intercepts }) => intercepts))
event.preventDefault();
},
onTouchmove,
};
};
+17
View File
@@ -30,6 +30,23 @@
user-select: none;
}
/*
* Browsers only combine touch-action up to the nearest scroll container, so a
* list scrolling inside a horizontal gesture host would otherwise let the
* browser take horizontal drags (and cancel the gesture). Each host publishes
* its touch-action as an inherited custom property; descendants adopt it.
* Zero specificity, so applications can still override any element.
*/
.nvo-gesture :where(*) {
touch-action: var(--nvo-touch-action);
}
/* Opted-out subtrees, such as horizontal chip rows, keep their own panning. */
.nvo-gesture :where([data-origin-gesture="ignore"]),
.nvo-gesture :where([data-origin-gesture="ignore"] *) {
touch-action: auto;
}
@media (prefers-reduced-motion: reduce) {
.nvo-node {
will-change: auto;
+1 -1
View File
@@ -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%");
});
+106 -61
View File
@@ -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,10 +182,40 @@ 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 };
}
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));
@@ -200,15 +227,32 @@ describe("scroll edges", () => {
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 () => {
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));
@@ -217,6 +261,7 @@ describe("scroll edges", () => {
expect(move.defaultPrevented).toBe(false);
});
});
});
describe("start-rule helpers", () => {
const host = document.createElement("div");
+3 -3
View File
@@ -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 }),
+13 -9
View File
@@ -19,19 +19,23 @@ Behavioural verification lives with the unit tests: `tests/property.test.ts`
drives random operation sequences and pointer streams and checks the scene's
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
## Real-browser touch checks
## Real-browser feel tests
`browser/` drives a development build in Chromium with emulated touch input,
which happy-dom cannot model: implicit touch pointer capture, native scrolling,
`pointercancel`, and rendered styles. It checks that swipes over scrollers
commit, that scrolling still works beside gestures, scroll-edge dismissal,
that `onCommit` runs inside `pointerup`, and the development continuity
warning.
which happy-dom cannot model: implicit touch pointer capture, native
scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
- **recognition** on human-like traces: wobbly and diagonal starts, a small
wind-up the wrong way, a twitch at lift-off, slow and short drags, flicks,
and vertical scrolls that must not trigger gestures;
- **frame pacing** while a finger drags (p95 ≤ 20ms, ≤ 5% of frames over
20ms) for a horizontal swipe and a scroll-edge dismiss;
- **behaviour**: swipes over nested scrollers, native scrolling beside
gestures, scroll-edge dismissal, `onCommit` inside `pointerup`, and the
development continuity warning.
```sh
NODE_ENV=development npx vite build --mode development \
--config packages/core-v2/verify/browser/app/vite.config.mjs
node packages/core-v2/verify/browser/run.mjs
npm run test:feel
```
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
@@ -1,4 +1,5 @@
import { createApp, defineComponent, h, ref } from "vue";
import "../../../src/style.css";
import {
OriginGesture,
OriginGestureSurface,
+67
View File
@@ -165,6 +165,73 @@ const log = (page) => page.evaluate(() => window.log.slice());
);
await t.page.close();
}
// 5. Recognition feel: human-like traces on the Home swipe (over a nested
// scroller). Real fingers rarely start cleanly; every trace that should open
// must open, and the rest must not.
{
const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]);
const traces = [
["clean swipe", steady(-200, 12), true],
["short steady drag (31%)", steady(-120, 10), true],
["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true],
["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true],
["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true],
["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true],
["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true],
["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false],
["quick flick (15%)", steady(-60, 4, 10), true],
["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false],
];
for (const [name, steps, shouldOpen] of traces) {
const t = await open();
let [x, y] = [330, 400];
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (const [dx, dy, ms] of steps) {
x += dx; y += dy;
if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
await t.page.waitForTimeout(ms);
}
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await t.page.waitForTimeout(800);
const opened = (await states(t.page)) === "root:parked detail:active";
check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed");
await t.page.close();
}
}
// 6. Frame pacing while a finger drags: no dropped frames from blocking
// touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms.
async function pacing(query, setup, from, to) {
const gaps = [];
for (let run = 0; run < 6; run++) {
const t = await open(query);
await setup(t);
await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); });
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] });
for (let i = 1; i <= 40; i++) {
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] });
await t.page.waitForTimeout(16);
}
const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; });
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
gaps.push(...f.slice(1).map((v, i) => v - f[i]));
await t.page.close();
}
gaps.sort((a, b) => a - b);
return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length };
}
const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); };
for (const [name, result] of [
["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])],
["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])],
]) {
check(
`pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`,
result.p95 <= 20 && result.over20 <= 0.05,
`p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`,
);
}
// 4. Continuity warning.
for (const [name, expectWarning] of [
["matched", false],