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",
|
"electron": "npm run build && electron apps/electron/main.mjs",
|
||||||
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
|
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
"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": {
|
"devDependencies": {
|
||||||
"@capacitor/app": "^8.0.0",
|
"@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 |
|
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
||||||
|
|
||||||
The component adds no recognition or navigation policy. It installs each
|
The component adds no recognition or navigation policy. It installs each
|
||||||
definition, forwards every pointer and touch event to every recognizer, and
|
definition, forwards every pointer event to every recognizer, and derives one
|
||||||
lets the recognizers share the touch: each locks onto a touch moving along its
|
`touch-action` that leaves native scrolling on every pan no gesture needs. A
|
||||||
own axis and direction and lets go of any other, so a horizontal drawer, a
|
horizontal drawer and a scroll-edge pull-down can share a host with vertical
|
||||||
pull-up sheet, and vertical scrolling can live on one host. See
|
scrolling. See [Scrolling](#scrolling).
|
||||||
[Scrolling](#scrolling).
|
|
||||||
|
|
||||||
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
|
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
|
definition objects never interrupts a gesture in progress; each change applies
|
||||||
@@ -546,25 +545,43 @@ interface OriginGestureBinding {
|
|||||||
readonly onPointerup: (event: PointerEvent) => void;
|
readonly onPointerup: (event: PointerEvent) => void;
|
||||||
readonly onPointercancel: () => void;
|
readonly onPointercancel: () => void;
|
||||||
readonly onLostpointercapture: (event: PointerEvent) => 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
|
Apply the pointer handlers, the returned style, and `listeners` to the same
|
||||||
`@touchmove` is), and apply the returned style. The style sets dimensions and
|
element, and pass `ref` as its function ref:
|
||||||
`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out
|
|
||||||
of a gesture the recognizer has claimed. Without it, the browser takes over
|
```vue
|
||||||
vertical and horizontal drags with `pointercancel`.
|
<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:
|
Recognition requires:
|
||||||
|
|
||||||
1. A primary, left-button pointer satisfies the optional start policy.
|
1. A primary, left-button pointer satisfies the optional start policy.
|
||||||
2. The target is not an ignored interactive element.
|
2. The target is not an ignored interactive element.
|
||||||
3. The first 6px of movement are along the gesture's axis and direction (see
|
3. Directed movement from the pointer-down point reaches `threshold`.
|
||||||
[Scrolling](#scrolling)).
|
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||||
4. Directed movement reaches `threshold`.
|
5. The navigation factory returns an intent.
|
||||||
5. Directed movement exceeds cross-axis movement by `axisDominance`.
|
|
||||||
6. 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
|
Progress is directed distance divided by the gesture's `distance` (by default
|
||||||
the host's width or height). Past either end, the excess reaches
|
the host's width or height). Past either end, the excess reaches
|
||||||
@@ -927,24 +944,43 @@ cause:
|
|||||||
|
|
||||||
### Scrolling
|
### Scrolling
|
||||||
|
|
||||||
Gestures and native scrolling share the same touches. Each recognizer decides
|
Native scrolling stays in the browser's hands: gesture hosts never make it
|
||||||
within the first 6px of movement whether a touch is its own:
|
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
|
| Gestures on the host | `touch-action` |
|
||||||
`onTouchmove` cancels native scrolling while it does;
|
| --------------------------------------------------------------- | ---------------- |
|
||||||
- **along the other axis, or the opposite way**: it lets go, and the browser
|
| Horizontal only | `pan-y` |
|
||||||
scrolls (or another gesture on the host takes it);
|
| Vertical only | `pan-x` |
|
||||||
- **diagonal**: it keeps watching until the movement is clear.
|
| 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
|
The browser therefore scrolls the free axis natively from the first frame,
|
||||||
decision is made before native scrolling can start. This is why gesture hosts
|
and touches on a gesture's axis reach the recognizer untouched.
|
||||||
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.
|
|
||||||
|
|
||||||
Nested scrollers need nothing special. Their `touchmove` events bubble to the
|
A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
|
||||||
gesture host, which cancels them only while it holds the touch.
|
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
|
### Ignored elements
|
||||||
|
|
||||||
@@ -952,7 +988,9 @@ Pointer-down on `input`, `textarea`, `select`, `option`, or
|
|||||||
`[contenteditable="true"]` never starts a gesture, so text selection and
|
`[contenteditable="true"]` never starts a gesture, so text selection and
|
||||||
form controls keep working. Mark anything else that must keep its own drags
|
form controls keep working. Mark anything else that must keep its own drags
|
||||||
with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip
|
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
|
### Animation performance
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const gesture = useOriginGesture(() => props.gesture);
|
|||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="as"
|
:is="as"
|
||||||
v-bind="$attrs"
|
:ref="gesture.ref"
|
||||||
|
v-bind="{ ...$attrs, ...gesture.listeners }"
|
||||||
class="nvo-gesture"
|
class="nvo-gesture"
|
||||||
:style="gesture.style"
|
:style="gesture.style"
|
||||||
@pointerdown="gesture.onPointerdown"
|
@pointerdown="gesture.onPointerdown"
|
||||||
@@ -28,7 +29,6 @@ const gesture = useOriginGesture(() => props.gesture);
|
|||||||
@pointerup="gesture.onPointerup"
|
@pointerup="gesture.onPointerup"
|
||||||
@pointercancel="gesture.onPointercancel"
|
@pointercancel="gesture.onPointercancel"
|
||||||
@lostpointercapture="gesture.onLostpointercapture"
|
@lostpointercapture="gesture.onLostpointercapture"
|
||||||
@touchmove="gesture.onTouchmove"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const surface = useOriginGestures(() => props.gestures);
|
|||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="as"
|
:is="as"
|
||||||
v-bind="$attrs"
|
:ref="surface.ref"
|
||||||
|
v-bind="{ ...$attrs, ...surface.listeners }"
|
||||||
class="nvo-gesture"
|
class="nvo-gesture"
|
||||||
:style="[$attrs.style, surface.style]"
|
:style="[$attrs.style, surface.style]"
|
||||||
@pointerdown="surface.onPointerdown"
|
@pointerdown="surface.onPointerdown"
|
||||||
@@ -27,7 +28,6 @@ const surface = useOriginGestures(() => props.gestures);
|
|||||||
@pointerup="surface.onPointerup"
|
@pointerup="surface.onPointerup"
|
||||||
@pointercancel="surface.onPointercancel"
|
@pointercancel="surface.onPointercancel"
|
||||||
@lostpointercapture="surface.onLostpointercapture"
|
@lostpointercapture="surface.onLostpointercapture"
|
||||||
@touchmove="surface.onTouchmove"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</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 { OriginChoreography } from "../choreography";
|
||||||
import type { OriginNavigationIntent } from "../navigation";
|
import type { OriginNavigationIntent } from "../navigation";
|
||||||
import type { OriginContext } from "../scene/types";
|
import type { OriginContext } from "../scene/types";
|
||||||
import type { OriginRect } from "../shared";
|
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
|
* 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`. */
|
/** Recognize pointer-down when application policy returns `true`. */
|
||||||
readonly kind: "when";
|
readonly kind: "when";
|
||||||
readonly predicate: OriginGestureStartPredicate;
|
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. */
|
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
|
||||||
@@ -324,8 +333,10 @@ export interface OriginGestureBuilder {
|
|||||||
/**
|
/**
|
||||||
* DOM bindings returned by {@link useOriginGesture}.
|
* DOM bindings returned by {@link useOriginGesture}.
|
||||||
*
|
*
|
||||||
* Attach all six handlers to the same `HTMLElement`, and apply
|
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
|
||||||
* {@link style}.
|
* when {@link needsTouchmove} is true), and apply
|
||||||
|
* {@link style}: its `touch-action` keeps native scrolling on every axis no
|
||||||
|
* gesture claims.
|
||||||
*/
|
*/
|
||||||
export interface OriginGestureBinding {
|
export interface OriginGestureBinding {
|
||||||
/**
|
/**
|
||||||
@@ -347,11 +358,28 @@ export interface OriginGestureBinding {
|
|||||||
*/
|
*/
|
||||||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||||
/**
|
/**
|
||||||
* Touch-move handler. Cancels native scrolling while a recognizer holds
|
* Touch-move handler for gestures that share an axis with native scrolling
|
||||||
* the touch, and leaves it alone once every recognizer has let go. It must
|
* (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
|
||||||
* be attached as a non-passive listener (Vue's `@touchmove` is).
|
* 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;
|
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 ----------------------------------------------------------
|
// Component props ----------------------------------------------------------
|
||||||
@@ -517,7 +545,7 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
|||||||
when: (predicate: OriginGestureStartPredicate) =>
|
when: (predicate: OriginGestureStartPredicate) =>
|
||||||
fromStage({ kind: "when", predicate }),
|
fromStage({ kind: "when", predicate }),
|
||||||
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
|
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
|
||||||
fromStage({ kind: "when", predicate: atScrollEdge(scroller, edge) }),
|
fromStage({ kind: "scroll-edge", scroller, edge }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start rules ---------------------------------------------------------------
|
// Start rules ---------------------------------------------------------------
|
||||||
@@ -555,25 +583,8 @@ export const atScrollEdge =
|
|||||||
scroller: OriginGestureScroller,
|
scroller: OriginGestureScroller,
|
||||||
edge: OriginGestureEdge,
|
edge: OriginGestureEdge,
|
||||||
): OriginGestureStartPredicate =>
|
): OriginGestureStartPredicate =>
|
||||||
({ host }) => {
|
({ host }) =>
|
||||||
const element =
|
isAtScrollEdge(scroller, edge, host);
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Admit pointer-down when any of `predicates` does. */
|
/** Admit pointer-down when any of `predicates` does. */
|
||||||
export const anyOf =
|
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 type { OriginAction } from "../navigation";
|
||||||
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
|
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
|
||||||
import { useOrigin, type UseOrigin } from "../scene/scene";
|
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||||
@@ -13,6 +19,7 @@ import type {
|
|||||||
OriginGestureEdge,
|
OriginGestureEdge,
|
||||||
OriginGesturePoint,
|
OriginGesturePoint,
|
||||||
OriginGestureProgressDistance,
|
OriginGestureProgressDistance,
|
||||||
|
OriginGestureScroller,
|
||||||
OriginGestureStart,
|
OriginGestureStart,
|
||||||
} from "./definition";
|
} from "./definition";
|
||||||
|
|
||||||
@@ -54,17 +61,84 @@ export const crossDistance = (
|
|||||||
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
|
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
|
* Whether the browser supports direction-specific `touch-action` values
|
||||||
* scrolling themselves (see `onTouchmove`), so the browser may pan either axis
|
* (`pan-up`, `pan-down`, …). Chromium does; where it is missing (Safari),
|
||||||
* and is only kept from double-tap zooming; a host without gestures is left
|
* scroll-edge gestures fall back to claiming touches from JavaScript.
|
||||||
* alone.
|
*/
|
||||||
|
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 = (
|
export const touchActionFor = (
|
||||||
directions: Iterable<OriginGestureDirection>,
|
definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
|
||||||
): "manipulation" | "auto" => {
|
atEdge: (
|
||||||
for (const _ of directions) return "manipulation";
|
definition: Pick<OriginGestureDefinition, "start">,
|
||||||
return "auto";
|
) => 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 ----------------------------------------------------
|
// Pointer-down admission ----------------------------------------------------
|
||||||
@@ -163,10 +237,40 @@ export const matchesStart = (
|
|||||||
const point = pointOf(event, bounds);
|
const point = pointOf(event, bounds);
|
||||||
if (start.kind === "when")
|
if (start.kind === "when")
|
||||||
return start.predicate({ event, origin, host, bounds, point });
|
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);
|
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. */
|
/** Whether `point` lies within `distance` of one side of the host. */
|
||||||
export const withinEdge = (
|
export const withinEdge = (
|
||||||
edge: OriginGestureEdge,
|
edge: OriginGestureEdge,
|
||||||
@@ -244,7 +348,7 @@ export class DirectionalTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Movement from the start without recording a sample. */
|
/** 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 dx = event.clientX - this.start.clientX;
|
||||||
const dy = event.clientY - this.start.clientY;
|
const dy = event.clientY - this.start.clientY;
|
||||||
return {
|
return {
|
||||||
@@ -295,12 +399,6 @@ export class DirectionalTracker {
|
|||||||
const DEFAULT_THRESHOLD = 8;
|
const DEFAULT_THRESHOLD = 8;
|
||||||
/** Ratio by which directed movement must exceed cross-axis movement. */
|
/** Ratio by which directed movement must exceed cross-axis movement. */
|
||||||
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
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. */
|
/** Resolve a definition's progress distance at pointer-down, in pixels. */
|
||||||
export const resolveProgressDistance = (
|
export const resolveProgressDistance = (
|
||||||
@@ -340,11 +438,12 @@ interface PointerSession {
|
|||||||
readonly startTime: number;
|
readonly startTime: number;
|
||||||
readonly tracker: DirectionalTracker;
|
readonly tracker: DirectionalTracker;
|
||||||
/**
|
/**
|
||||||
* Whether the movement so far is along this gesture's axis and direction
|
* For a gesture sharing its axis with scrolling: whether it has claimed
|
||||||
* (`ours`) or still too small or too diagonal to tell (`pending`). A touch
|
* this touch from native scrolling (`ours`) or not yet decided.
|
||||||
* that turns out to be someone else's closes the session instead.
|
|
||||||
*/
|
*/
|
||||||
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. */
|
/** Movement passed the recognition threshold and the pointer is captured. */
|
||||||
captured: boolean;
|
captured: boolean;
|
||||||
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
|
/** 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
|
* Called for each `touchmove`: whether native scrolling must be cancelled.
|
||||||
* must not start: its session is open and has not ruled the touch out.
|
*
|
||||||
|
* 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 {
|
claimTouch(): boolean {
|
||||||
return this.session !== null;
|
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 {
|
pointerDown(event: PointerEvent): void {
|
||||||
@@ -433,7 +558,8 @@ export class GestureRecognizer {
|
|||||||
bounds,
|
bounds,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
axis: "pending",
|
scroll: "pending",
|
||||||
|
last: { clientX: event.clientX, clientY: event.clientY },
|
||||||
captured: false,
|
captured: false,
|
||||||
armed: false,
|
armed: false,
|
||||||
operation: null,
|
operation: null,
|
||||||
@@ -445,8 +571,8 @@ export class GestureRecognizer {
|
|||||||
const session = this.sessionFor(event);
|
const session = this.sessionFor(event);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
|
session.last = { clientX: event.clientX, clientY: event.clientY };
|
||||||
if (!session.captured) {
|
if (!session.captured) {
|
||||||
if (!this.lockAxis(session, event)) return;
|
|
||||||
if (!GestureRecognizer.recognizes(session, event)) return;
|
if (!GestureRecognizer.recognizes(session, event)) return;
|
||||||
session.captured = true;
|
session.captured = true;
|
||||||
session.host.setPointerCapture?.(session.pointerId);
|
session.host.setPointerCapture?.(session.pointerId);
|
||||||
@@ -537,35 +663,6 @@ export class GestureRecognizer {
|
|||||||
await handle?.cancel();
|
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. */
|
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
|
||||||
private updateArming(session: PointerSession, event: PointerEvent) {
|
private updateArming(session: PointerSession, event: PointerEvent) {
|
||||||
const { definition, tracker } = session;
|
const { definition, tracker } = session;
|
||||||
@@ -691,24 +788,80 @@ export const useOriginGestures = (
|
|||||||
return recognizers;
|
return recognizers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const style = computed(() => ({
|
// The host element, for scroll-edge state and selector scrollers.
|
||||||
width: "100%",
|
const host = shallowRef<HTMLElement | null>(null);
|
||||||
height: "100%",
|
// Bumped by scroll events; touch-action re-reads scroll edges on change.
|
||||||
// Preserve native scrolling on any axis no gesture claims.
|
const scrolled = shallowRef(0);
|
||||||
touchAction: touchActionFor(
|
const directional = supportsDirectionalPan();
|
||||||
toValue(definitions).map(({ direction }) => direction),
|
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%",
|
||||||
|
// 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.
|
// A recognizer whose component unmounts mid-gesture cancels its operation.
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
|
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 {
|
return {
|
||||||
get style() {
|
get style() {
|
||||||
return style.value;
|
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) => {
|
onPointerdown: (event) => {
|
||||||
for (const recognizer of current()) recognizer.pointerDown(event);
|
for (const recognizer of current()) recognizer.pointerDown(event);
|
||||||
},
|
},
|
||||||
@@ -725,12 +878,7 @@ export const useOriginGestures = (
|
|||||||
for (const recognizer of current())
|
for (const recognizer of current())
|
||||||
void recognizer.pointerCaptureLost(event);
|
void recognizer.pointerCaptureLost(event);
|
||||||
},
|
},
|
||||||
onTouchmove: (event) => {
|
onTouchmove,
|
||||||
// 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();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,23 @@
|
|||||||
user-select: none;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.nvo-node {
|
.nvo-node {
|
||||||
will-change: auto;
|
will-change: auto;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe("gesture builder", () => {
|
|||||||
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
|
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
|
||||||
expect(surface.tagName).toBe("SECTION");
|
expect(surface.tagName).toBe("SECTION");
|
||||||
expect(surface.classList.contains("nvo-gesture")).toBe(true);
|
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.width).toBe("100%");
|
||||||
expect(surface.style.height).toBe("100%");
|
expect(surface.style.height).toBe("100%");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
shallowRef,
|
shallowRef,
|
||||||
type Component,
|
type Component,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
allOf,
|
allOf,
|
||||||
anyOf,
|
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);
|
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));
|
const { scene, host } = await mount(hostFor(leftward));
|
||||||
|
// A horizontal gesture keeps native vertical panning.
|
||||||
|
expect(host.style.touchAction).toBe("pan-y");
|
||||||
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||||
const undecided = touchmove();
|
|
||||||
host.dispatchEvent(undecided);
|
|
||||||
expect(undecided.defaultPrevented).toBe(true);
|
|
||||||
|
|
||||||
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
|
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
|
||||||
await flush();
|
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();
|
const move = touchmove();
|
||||||
host.dispatchEvent(move);
|
host.dispatchEvent(move);
|
||||||
expect(move.defaultPrevented).toBe(true);
|
// Ordinary gestures never block touchmove, so the browser never waits.
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a swipe that starts with a vertical wobble", async () => {
|
||||||
|
const { scene, host } = await mount(hostFor(leftward));
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
|
||||||
|
const { scene, host } = await mount(hostFor(leftward));
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims touch-action per axis like native scrolling expects", async () => {
|
||||||
|
const down = gesture.to.down().navigate(toTarget).animate(motion);
|
||||||
|
const scroller = shallowRef<HTMLElement | null>(null);
|
||||||
|
const dismiss = gesture.from
|
||||||
|
.scrollEdge(scroller, "top")
|
||||||
|
.to.down()
|
||||||
|
.navigate(toTarget)
|
||||||
|
.animate(motion);
|
||||||
|
const surface = (gestures: OriginGestureDefinition[]) =>
|
||||||
|
defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await mount(surface([leftward, down]))).host.style.touchAction,
|
||||||
|
).toBe("none");
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
// A scroll-edge gesture at its edge reserves only its own pan: with the
|
||||||
|
// horizontal gesture claiming pan-x, only scrolling down remains.
|
||||||
|
expect(
|
||||||
|
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
|
||||||
|
).toBe("pan-down");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,36 +182,84 @@ describe("scroll edges", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const mounted = await mount(Root);
|
const mounted = await mount(Root);
|
||||||
Object.defineProperty(scroller.value!, "scrollTop", { value: scrollTop });
|
let top = scrollTop;
|
||||||
return mounted;
|
Object.defineProperty(scroller.value!, "scrollTop", {
|
||||||
|
get: () => top,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const scrollTo = async (value: number) => {
|
||||||
|
top = value;
|
||||||
|
scroller.value!.dispatchEvent(new Event("scroll"));
|
||||||
|
await nextTick();
|
||||||
|
};
|
||||||
|
await scrollTo(scrollTop);
|
||||||
|
return { ...mounted, scrollTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("starts a downward drag only when the scroller is at the top", async () => {
|
describe("with directional touch-action", () => {
|
||||||
const atTop = await mountWithScroller(0);
|
it("reserves only the dismiss pan while the list is at the top", async () => {
|
||||||
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
const { host, scrollTo } = await mountWithScroller(0);
|
||||||
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
// Dragging up still scrolls natively; dragging down is the gesture's.
|
||||||
await flush();
|
expect(host.style.touchAction).toBe("pan-x pan-down");
|
||||||
expect(atTop.scene.operations.value).toHaveLength(1);
|
await scrollTo(120);
|
||||||
|
expect(host.style.touchAction).toBe("manipulation");
|
||||||
|
await scrollTo(0);
|
||||||
|
expect(host.style.touchAction).toBe("pan-x pan-down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never blocks touchmove", async () => {
|
||||||
|
const { host } = await mountWithScroller(0);
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
|
||||||
|
const move = touchmove();
|
||||||
|
host.dispatchEvent(move);
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a downward drag only when the scroller is at the top", async () => {
|
||||||
|
const atTop = await mountWithScroller(0);
|
||||||
|
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||||
|
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
||||||
|
await flush();
|
||||||
|
expect(atTop.scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a scrolled list to scroll", async () => {
|
||||||
|
const scrolled = await mountWithScroller(120);
|
||||||
|
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||||
|
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
||||||
|
await flush();
|
||||||
|
expect(scrolled.scene.operations.value).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a scrolled list to scroll", async () => {
|
describe("without directional touch-action (Safari)", () => {
|
||||||
const scrolled = await mountWithScroller(120);
|
beforeEach(() => {
|
||||||
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
vi.stubGlobal("CSS", { supports: () => false });
|
||||||
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 () => {
|
it("keeps the axis pannable and claims touches from JavaScript", async () => {
|
||||||
const atTop = await mountWithScroller(0);
|
const atTop = await mountWithScroller(0);
|
||||||
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0));
|
expect(atTop.host.style.touchAction).toBe("manipulation");
|
||||||
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16));
|
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||||
const move = touchmove();
|
// The browser's first cancelable touchmove arrives after its slop.
|
||||||
atTop.host.dispatchEvent(move);
|
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
|
||||||
expect(move.defaultPrevented).toBe(false);
|
const first = touchmove();
|
||||||
|
atTop.host.dispatchEvent(first);
|
||||||
|
expect(first.defaultPrevented).toBe(true);
|
||||||
|
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
|
||||||
|
await flush();
|
||||||
|
expect(atTop.scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an upward drag at the top scroll the list", async () => {
|
||||||
|
const atTop = await mountWithScroller(0);
|
||||||
|
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0));
|
||||||
|
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16));
|
||||||
|
const move = touchmove();
|
||||||
|
atTop.host.dispatchEvent(move);
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ describe("reactive gesture props", () => {
|
|||||||
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
|
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
|
||||||
const current = shallowRef(leftward);
|
const current = shallowRef(leftward);
|
||||||
const { scene, host } = await mount(gestureHost(() => current.value));
|
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.
|
// Swap mid-gesture: the drag in progress keeps the leftward definition.
|
||||||
host.dispatchEvent(pointer("pointerdown", 180, 0));
|
host.dispatchEvent(pointer("pointerdown", 180, 0));
|
||||||
@@ -295,14 +295,14 @@ describe("reactive gesture props", () => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const { scene, host } = await mount(Root);
|
const { scene, host } = await mount(Root);
|
||||||
expect(host.style.touchAction).toBe("manipulation");
|
expect(host.style.touchAction).toBe("pan-y");
|
||||||
|
|
||||||
list.value = [
|
list.value = [
|
||||||
...list.value,
|
...list.value,
|
||||||
gesture.to.down().navigate(toTarget).animate(motion),
|
gesture.to.down().navigate(toTarget).animate(motion),
|
||||||
];
|
];
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(host.style.touchAction).toBe("manipulation");
|
expect(host.style.touchAction).toBe("none");
|
||||||
|
|
||||||
host.dispatchEvent(
|
host.dispatchEvent(
|
||||||
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
|
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
|
drives random operation sequences and pointer streams and checks the scene's
|
||||||
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
|
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,
|
`browser/` drives a development build in Chromium with emulated touch input,
|
||||||
which happy-dom cannot model: implicit touch pointer capture, native scrolling,
|
which happy-dom cannot model: implicit touch pointer capture, native
|
||||||
`pointercancel`, and rendered styles. It checks that swipes over scrollers
|
scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
|
||||||
commit, that scrolling still works beside gestures, scroll-edge dismissal,
|
|
||||||
that `onCommit` runs inside `pointerup`, and the development continuity
|
- **recognition** on human-like traces: wobbly and diagonal starts, a small
|
||||||
warning.
|
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
|
```sh
|
||||||
NODE_ENV=development npx vite build --mode development \
|
npm run test:feel
|
||||||
--config packages/core-v2/verify/browser/app/vite.config.mjs
|
|
||||||
node packages/core-v2/verify/browser/run.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
|
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createApp, defineComponent, h, ref } from "vue";
|
import { createApp, defineComponent, h, ref } from "vue";
|
||||||
|
import "../../../src/style.css";
|
||||||
import {
|
import {
|
||||||
OriginGesture,
|
OriginGesture,
|
||||||
OriginGestureSurface,
|
OriginGestureSurface,
|
||||||
|
|||||||
@@ -165,6 +165,73 @@ const log = (page) => page.evaluate(() => window.log.slice());
|
|||||||
);
|
);
|
||||||
await t.page.close();
|
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.
|
// 4. Continuity warning.
|
||||||
for (const [name, expectWarning] of [
|
for (const [name, expectWarning] of [
|
||||||
["matched", false],
|
["matched", false],
|
||||||
|
|||||||
Reference in New Issue
Block a user