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:
+2
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user