Author SHA1 Message Date
Claude caf2ad692a 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
2026-11-03 05:56:26 +00:00
Claude 6889fc0112 Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 08:22:11 +00:00
Claude d4d7feb3b7 Fix touch gestures cancelling, backward flicks, and late onCommit
- Only the host's own lostpointercapture cancels a gesture. Taking
  capture makes the touched child lose its implicit touch capture, and
  that bubbling event was cancelling every touch gesture immediately.
- A flick at least commitVelocity fast now decides by its direction:
  backward cancels even past the distance threshold, forward commits.
  Slower releases still decide by distance.
- Pointer-up finishes an already-begun operation without awaiting, so
  onCommit runs inside the pointer-up event, where iOS still allows
  haptics, audio, and clipboard writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 05:41:51 +00:00
Claude 7033814550 Trim per-release overhead in the scene
Only re-measure the viewport at begin() when a container is mounted, and
only allocate waiting machinery when an interrupted finish() actually
needs to wait for a later release. Interrupted releases now resolve with
the final decided outcome in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:28:43 +00:00
Claude a46c411ec4 Move core-v2 tests into packages/core-v2/tests
Tests no longer sit next to the implementation. They import the package
through ../src, the root tsconfig still type-checks them, and the
package build and declarations cover only src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:24:47 +00:00
Claude 1924d4b52b Close a revealed page's open drawer when its back is cancelled
discardOutgoing skipped finished resting edges, so a persistAtRest drawer
opened by a node revealed during a back operation stayed attached after
that back was cancelled and the node returned to history. Found by the
property tests (1000-seed run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:10:53 +00:00
28 changed files with 2048 additions and 231 deletions
+2 -1
View File
@@ -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",
+268 -25
View File
@@ -20,6 +20,7 @@ This document describes the experimental `0.1.0-experimental.0` API.
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations) - [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
- [Type reference](#type-reference) - [Type reference](#type-reference)
- [Errors and constraints](#errors-and-constraints) - [Errors and constraints](#errors-and-constraints)
- [Gestures in real apps](#gestures-in-real-apps)
## Gesture start recognition ## Gesture start recognition
@@ -43,6 +44,7 @@ gesture.from.top(distance);
gesture.from.bottom(distance); gesture.from.bottom(distance);
gesture.from.anywhere(); gesture.from.anywhere();
gesture.from.when(predicate); gesture.from.when(predicate);
gesture.from.scrollEdge(scroller, edge);
``` ```
`distance` accepts a number in CSS pixels or a CSS length string, including `distance` accepts a number in CSS pixels or a CSS length string, including
@@ -80,9 +82,43 @@ const dropDialog = gesture.from
.animate(dropAnimation); .animate(dropAnimation);
``` ```
Interactive form controls are ignored automatically. Add `.from.scrollEdge(scroller, edge)` admits pointer-down only while a scroll
container is scrolled all the way to `edge`. It is the drag-down-to-dismiss
rule for sheets and pushed pages: at the top, dragging down dismisses and
dragging up still scrolls; anywhere else the list just scrolls. `scroller` is
an element, a ref or getter returning one, or a CSS selector resolved inside
the gesture host. A scroller that is not mounted counts as at the edge.
```ts
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(sheetDown);
```
Start rules also come as composable predicates for `.from.when()`:
| Helper | Admits pointer-down |
| ------------------------------ | ------------------------------------------- |
| `startedIn(selector)` | On or inside an element matching `selector` |
| `nearEdge(edge, distance)` | Within `distance` of one side of the host |
| `atScrollEdge(scroller, edge)` | While `scroller` is scrolled to `edge` |
| `anyOf(...predicates)` | When any predicate does |
| `allOf(...predicates)` | When every predicate does |
```ts
gesture.from
.when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
.to.down();
```
Interactive form controls (`input`, `textarea`, `select`, `option`, and
`[contenteditable="true"]`) are ignored automatically. Add
`data-origin-gesture="ignore"` to any other element or ancestor that should not `data-origin-gesture="ignore"` to any other element or ancestor that should not
begin a gesture. begin a gesture, such as a horizontally scrolling chip row inside a host with a
horizontal gesture.
## Minimum setup ## Minimum setup
@@ -184,15 +220,12 @@ 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 with `useOriginGesture()`, forwards every pointer event to every definition, forwards every pointer event to every recognizer, and derives one
binding, and derives the least-permissive shared `touch-action`: `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).
- horizontal only: `pan-y`; The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
- vertical only: `pan-x`;
- both axes: `none`.
The `gestures` list is reactive, including its length and the derived
`touch-action`. 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
from the next pointer-down. The owning page remains the visible declaration from the next pointer-down. The owning page remains the visible declaration
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice. point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
@@ -427,6 +460,16 @@ change a definition already produced from it.
| --------------- | ------- | ------------------------------------------------- | | --------------- | ------- | ------------------------------------------------- |
| `threshold` | `8` | Directed CSS pixels required before capture | | `threshold` | `8` | Directed CSS pixels required before capture |
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture | | `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
| `distance` | host | Movement that counts as full progress |
`distance` is resolved at pointer-down: a number in pixels, a CSS length
(percentages are of the host), or a function `({ host, bounds }) => pixels`.
Set it when the moving element is smaller than the host, such as a drawer 84%
of the screen wide, so the drawer stays under the finger:
```ts
gesture.to.right({ distance: () => drawer.value?.offsetWidth ?? 300 });
```
If `.complete()` is omitted, the choreography's `commitThreshold` and If `.complete()` is omitted, the choreography's `commitThreshold` and
`commitVelocity` decide release normally. `commitVelocity` decide release normally.
@@ -452,10 +495,30 @@ const openProfile = gesture.to
.animate(slideLeft); .animate(slideLeft);
``` ```
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call Handlers run inside the `pointerup` event whenever the operation has already
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named begun, which is the usual case, so APIs that need the user's gesture (iOS
`onCommit` rather than `then` so builder stages never become promise-like haptics, audio, clipboard writes) work there. See [Haptics](#haptics).
"thenables" when awaited or returned from an async function.
The method is named `onCommit` rather than `then` so builder stages never
become promise-like "thenables" when awaited or returned from an async
function.
`.onArm(handler)` and `.onDisarm(handler)` follow the same rules and run during
the drag: `onArm` when releasing would start to commit, `onDisarm` when it no
longer would. Use them for a "release now" cue or haptic. Arming follows
distance (`progress >= commitThreshold`) because velocity changes too quickly to
drive a cue without flickering; when the definition has a `.complete()`
predicate, that predicate decides arming instead.
```ts
gesture.to
.left()
.navigate(() => forward(originView(Archive)))
.onArm(() => haptic("light"))
.onDisarm(() => haptic("soft"))
.onCommit(() => haptic("medium"))
.animate(slideLeft);
```
### `useOriginGesture(definition)` ### `useOriginGesture(definition)`
@@ -482,22 +545,50 @@ 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 listeners: Readonly<Record<string, (event: Event) => void>>;
readonly ref: (element: unknown) => void;
} }
``` ```
Apply all handlers to the same element. The returned style sets dimensions and Apply the pointer handlers, the returned style, and `listeners` to the same
`touch-action` so native scrolling remains available on the cross-axis. 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: 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. Directed movement reaches `threshold`. 3. Directed movement from the pointer-down point reaches `threshold`.
4. Directed movement exceeds cross-axis movement by `axisDominance`. 4. Directed movement exceeds cross-axis movement by `axisDominance`.
5. The navigation factory returns an intent. 5. The navigation factory returns an intent.
Progress is directed distance divided by host width or height. Release velocity Recognition is measured from where the pointer went down and keeps watching
is normalized by the same dimension. 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
choreographies as `overshoot`. Release velocity is normalized by the same
distance and measured as the trend of the last 100ms of samples, so uneven
touch timing does not distort it and a pointer held still before release has
no flick.
An asynchronous navigation factory is supported. If it resolves after the An asynchronous navigation factory is supported. If it resolves after the
pointer was released or cancelled, the stale result is discarded. pointer was released or cancelled, the stale result is discarded.
@@ -582,13 +673,18 @@ can run repeatedly during rendering and animation.
### Commit thresholds ### Commit thresholds
When `finish()` does not explicitly override the decision, a target commits When `finish()` does not explicitly override the decision, the release velocity
when either: decides first, then distance:
- `progress >= commitThreshold`, default `0.36`; or - a flick at least `commitVelocity` fast (default `0.2` progress units per
- release `velocity >= commitVelocity`, default `0.3` progress units per second, about 80px/s across a 390px phone) decides by its direction: forward
second, at any progress. Velocity is normalized by the gesture host's size, commits, backward cancels, regardless of progress;
so `1` is one full width (or height) per second. - a slower release commits when `progress >= commitThreshold`, default `0.25`.
The included presets use these defaults.
Velocity is normalized by the gesture host's size, so `1` is one full width (or
height) per second.
The operation's intent becomes final before its spring settles. The operation's intent becomes final before its spring settles.
@@ -775,6 +871,21 @@ Node-local action context:
| `originRect` | Origin bounds captured before target mounting | | `originRect` | Origin bounds captured before target mounting |
| `targetRect` | Target bounds measured after mounting | | `targetRect` | Target bounds measured after mounting |
| `viewport` | Scene-container bounds, with browser viewport fallback | | `viewport` | Scene-container bounds, with browser viewport fallback |
| `overshoot` | Drag distance past either end of `progress`, else `0` |
`overshoot` is negative when a drag is pulled back beyond the start and
positive beyond full progress. Use it for rubber-banding:
```ts
effects: ({ progress, overshoot }) => ({
target: {
transform: `translateX(${(1 - progress) * 100 - overshoot * 20}%)`,
},
});
```
The settling spring starts from the overshot position, so a rubber-banded drag
springs back continuously.
Rect values are viewport CSS pixels. Rect values are viewport CSS pixels.
@@ -828,3 +939,135 @@ cause:
- A choreography creates one target. Chaining supports any number of - A choreography creates one target. Chaining supports any number of
simultaneously mounted targets. simultaneously mounted targets.
- Reduced-motion preference resolves settling immediately. - Reduced-motion preference resolves settling immediately.
## Gestures in real apps
### Scrolling
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:
| 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` |
The browser therefore scrolls the free axis natively from the first frame,
and touches on a gesture's axis reach the recognizer untouched.
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
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. 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
Animate `transform` and `opacity` only. Browsers composite them without
layout or paint. Animating `filter`, `clip-path`, `box-shadow`, or
`border-radius` through `effect.style` repaints every frame and is the usual
cause of janky transitions; if you need one of them, set it once at rest
rather than per frame.
### Continuity between choreographies
In development builds the scene warns when an operation's first frame moves a
node that was visibly at rest. This catches the one-frame "pop" when a close
choreography's progress-0 effects do not match the pose the previous
choreography left behind, typically a `persistAtRest` drawer or sheet whose
open (at 1) and close (at 0) effects disagree. The check runs only in
development (`process.env.NODE_ENV !== "production"`, which bundlers replace)
and only in browsers, since it reads rendered styles.
### Haptics
`onArm`, `onDisarm`, and `onCommit` run inside the pointer event, which is
where platforms allow feedback:
- **Android, Chrome**: `navigator.vibrate(10)`.
- **iOS Safari**: there is no Vibration API. Haptics are only reachable
through a native wrapper, or through an undocumented technique: toggling a
hidden `<input type="checkbox" switch>`, which iOS 18+ Safari renders as a
switch with a system haptic. It only produces a haptic during a user gesture,
so call it from these handlers, never after an `await`, and expect it to
change between iOS releases.
- **Capacitor**: `Haptics.impact()` from `@capacitor/haptics`.
### Operating-system edge gestures
The screen edges belong to the operating system first. On iOS, a swipe from the
left edge goes back in Safari's history, including in apps installed to the
home screen (`display: standalone`), which have no back button. On Android
with gesture navigation, a swipe from either side edge is the system back
gesture. Pages cannot claim those touches, so `.from.left()` or `.from.right()`
with a narrow zone competes with them. Prefer a zone that starts a little
inside the edge, or a wider one (`.from.when(nearEdge("left", 56))`), and give
every back gesture an on-screen alternative.
Web apps cannot disable the operating system's gesture, but an installed
progressive web app can make it do the right thing, so it backs out of the
scene instead of leaving the app:
- **Android (Chrome 120+)**: listen for close requests with `CloseWatcher`.
The system back gesture and back button send one; route it to the scene's
back operation. A watcher must be created during a user activation (a tap
or a gesture), and after it fires it cannot fire again until the user
interacts again, so recreate it after each navigation:
```ts
let watcher: CloseWatcher | undefined;
function watchSystemBack(origin: UseOrigin) {
watcher?.destroy();
if (!("CloseWatcher" in window) || !origin.canGoBack.value) return;
watcher = new CloseWatcher();
watcher.onclose = () => origin.perform(back(slideRight));
}
// Call from a gesture's .onCommit() or a click handler, after navigating.
```
Without `CloseWatcher`, push a history entry when the scene navigates
forward (`history.pushState({ scene: true }, "")`) and handle `popstate` by
performing the scene's back. The system gesture then pops that entry instead
of leaving the app.
- **iOS (standalone)**: the edge swipe navigates browser history. Scenes never
add history entries, so in an installed app with no history to go back to
the swipe should have nothing to do. If the app also uses the History API,
map `popstate` to the scene's back as above.
- **Browser history swipes** (two-finger trackpad swipes, and horizontal
overscroll navigation in Chromium): `overscroll-behavior-x: none` on the page's
scroll container stops horizontal overscroll from triggering navigation.
Verify the behaviour on real devices: these platform gestures are not part of
any web standard and change between releases.
+4
View File
@@ -308,6 +308,10 @@ Each module depends only on the ones listed above it.
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` | | `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host | | `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
Tests live in `tests/`, outside `src/`. `tests/property.test.ts` checks the
scene's structural invariants after every step of random operation sequences
and pointer streams; set `PROPERTY_SEEDS` to run more than the default 60.
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
`composeNodeStyle`) are module-level `export const`s that the class calls, so `composeNodeStyle`) are module-level `export const`s that the class calls, so
they can be tested directly without widening the class's interface. they can be tested directly without widening the class's interface.
+29 -6
View File
@@ -78,6 +78,12 @@ export interface OriginEffectSet {
export interface OriginChoreographyContext { export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */ /** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number; progress: number;
/**
* How far a drag has gone past the ends of `progress`: negative when pulled
* back beyond the start, positive beyond full progress, otherwise `0`. Use
* it for rubber-banding or resistance; most choreographies ignore it.
*/
overshoot: number;
/** /**
* Normalized progress units per second. Positive velocity moves toward * Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation. * commit; negative velocity moves back toward cancellation.
@@ -111,7 +117,7 @@ export interface OriginChoreography {
/** /**
* Gesture progress required to retain the target after release. * Gesture progress required to retain the target after release.
* *
* @defaultValue `0.36` * @defaultValue `0.25`
*/ */
readonly commitThreshold?: number; readonly commitThreshold?: number;
/** /**
@@ -119,7 +125,7 @@ export interface OriginChoreography {
* regardless of progress. Velocity is normalized by the gesture host's * regardless of progress. Velocity is normalized by the gesture host's
* size, so `1` means one full host width (or height) per second. * size, so `1` means one full host width (or height) per second.
* *
* @defaultValue `0.3` * @defaultValue `0.2`
*/ */
readonly commitVelocity?: number; readonly commitVelocity?: number;
/** /**
@@ -136,6 +142,27 @@ export interface OriginChoreography {
* @defaultValue `false` * @defaultValue `false`
*/ */
readonly persistAtRest?: boolean; readonly persistAtRest?: boolean;
/**
* Settling spring after release. A drawer, a sheet, and a full-page push
* usually want different springs.
*/
readonly spring?: OriginSpring;
}
/** Damped-spring settings for settling a released operation. */
export interface OriginSpring {
/**
* Pull toward the resting position. Higher settles faster.
*
* @defaultValue `280`
*/
readonly stiffness?: number;
/**
* Resistance to motion. Lower overshoots and bounces.
*
* @defaultValue `30`
*/
readonly damping?: number;
} }
/** /**
@@ -250,8 +277,6 @@ const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
*/ */
export const slideLeft = defineOriginChoreography({ export const slideLeft = defineOriginChoreography({
name: "slide-left", name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }) => ({ effects: ({ progress }) => ({
source: { source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`, transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
@@ -269,8 +294,6 @@ export const slideLeft = defineOriginChoreography({
*/ */
export const slideRight = defineOriginChoreography({ export const slideRight = defineOriginChoreography({
name: "slide-right", name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }) => ({ effects: ({ progress }) => ({
source: { source: {
transform: `translate3d(${percent(progress)}, 0, 0)`, transform: `translate3d(${percent(progress)}, 0, 0)`,
@@ -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"
@@ -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"
+181 -16
View File
@@ -1,8 +1,9 @@
import type { CSSProperties } 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 { 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
@@ -77,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. */
@@ -93,8 +103,27 @@ export interface OriginGestureDirectionOptions {
* @defaultValue `1.15` * @defaultValue `1.15`
*/ */
readonly axisDominance?: number; readonly axisDominance?: number;
/**
* Movement that counts as full progress (`1`), resolved at pointer-down.
* A CSS length (numbers are pixels, `%` is of the host), or a function
* returning pixels, for example to measure a drawer narrower than the host.
*
* @defaultValue the host's width (horizontal) or height (vertical)
*/
readonly distance?: OriginGestureProgressDistance;
} }
/**
* Movement that counts as full progress: a CSS length, or a function that
* returns pixels, measured at pointer-down.
*/
export type OriginGestureProgressDistance =
| OriginGestureDistance
| ((context: {
readonly host: HTMLElement;
readonly bounds: OriginRect;
}) => number);
/** Values available when a custom `.complete()` policy runs on pointer-up. */ /** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext { export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */ /** Component instance that originated the gesture. */
@@ -170,6 +199,10 @@ export interface OriginGestureDefinition {
readonly choreography: OriginChoreography; readonly choreography: OriginChoreography;
/** Handlers run, in order, when a release commits. See `.onCommit()`. */ /** Handlers run, in order, when a release commits. See `.onCommit()`. */
readonly commitHandlers?: readonly OriginGestureCommitHandler[]; readonly commitHandlers?: readonly OriginGestureCommitHandler[];
/** Handlers run when a drag starts to qualify to commit. See `.onArm()`. */
readonly armHandlers?: readonly OriginGestureCommitHandler[];
/** Handlers run when a drag stops qualifying to commit. */
readonly disarmHandlers?: readonly OriginGestureCommitHandler[];
} }
/** /**
@@ -216,8 +249,24 @@ export interface OriginGestureFromBuilder {
anywhere(): OriginGestureFromSelection; anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */ /** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection; when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
/**
* Admit pointer-down only while `scroller` is scrolled to `edge`, for
* example drag-down-to-dismiss only at the top of a list. Movement the
* other way is left to the scroller. See {@link atScrollEdge}.
*/
scrollEdge(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureFromSelection;
} }
/**
* A scroll container: an element, a ref or getter returning one, or a CSS
* selector resolved inside the gesture host.
*/
export type OriginGestureScroller =
MaybeRefOrGetter<Element | null | undefined> | string;
/** Builder stage after direction is known and completion remains optional. */ /** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder { export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */ /** Override the choreography's default release decision. */
@@ -230,6 +279,13 @@ export interface OriginGestureDirectedBuilder {
): OriginGestureNavigationBuilder; ): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */ /** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder; onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
} }
/** Builder stage after a custom completion policy has been selected. */ /** Builder stage after a custom completion policy has been selected. */
@@ -240,6 +296,13 @@ export interface OriginGestureCompletedBuilder {
): OriginGestureNavigationBuilder; ): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */ /** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder; onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
} }
/** Final builder stage waiting for visual choreography. */ /** Final builder stage waiting for visual choreography. */
@@ -248,6 +311,13 @@ export interface OriginGestureNavigationBuilder {
animate(choreography: OriginChoreography): OriginGestureDefinition; animate(choreography: OriginChoreography): OriginGestureDefinition;
/** Run `handler` when a release commits. Handlers accumulate in order. */ /** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder; onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
} }
/** Root of the immutable fluent gesture-definition API. */ /** Root of the immutable fluent gesture-definition API. */
@@ -263,8 +333,10 @@ export interface OriginGestureBuilder {
/** /**
* DOM bindings returned by {@link useOriginGesture}. * DOM bindings returned by {@link useOriginGesture}.
* *
* Attach all five handlers to the same `HTMLElement`. Apply {@link style} as * Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
* well so native scrolling is preserved on the cross-axis. * 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 {
/** /**
@@ -285,6 +357,29 @@ export interface OriginGestureBinding {
* ends without a pointer-up, so the operation never stays stuck mid-way. * ends without a pointer-up, so the operation never stays stuck mid-way.
*/ */
readonly onLostpointercapture: (event: PointerEvent) => void; readonly onLostpointercapture: (event: PointerEvent) => void;
/**
* 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 ---------------------------------------------------------- // Component props ----------------------------------------------------------
@@ -342,16 +437,33 @@ interface GestureDraft {
readonly recognition: Readonly<OriginGestureDirectionOptions>; readonly recognition: Readonly<OriginGestureDirectionOptions>;
readonly completion?: OriginGestureCompletionPredicate; readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[]; readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly armHandlers: readonly OriginGestureCommitHandler[];
readonly disarmHandlers: readonly OriginGestureCommitHandler[];
} }
const withCommitHandler = <Draft extends GestureDraft>( type HandlerList = "commitHandlers" | "armHandlers" | "disarmHandlers";
/**
* `.onCommit()`, `.onArm()`, and `.onDisarm()` for a stage: each rebuilds the
* same stage from the draft with the handler appended.
*/
function hookMethods<Draft extends GestureDraft, Stage>(
draft: Draft, draft: Draft,
handler: OriginGestureCommitHandler, stage: (draft: Draft) => Stage,
): Draft => ) {
Object.freeze({ const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) =>
...draft, stage(
commitHandlers: Object.freeze([...draft.commitHandlers, handler]), Object.freeze({
}); ...draft,
[list]: Object.freeze([...draft[list], handler]),
}),
);
return {
onCommit: append("commitHandlers"),
onArm: append("armHandlers"),
onDisarm: append("disarmHandlers"),
};
}
function navigationStage( function navigationStage(
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory }, draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
@@ -367,9 +479,10 @@ function navigationStage(
navigation: draft.navigation, navigation: draft.navigation,
choreography, choreography,
commitHandlers: draft.commitHandlers, commitHandlers: draft.commitHandlers,
armHandlers: draft.armHandlers,
disarmHandlers: draft.disarmHandlers,
}), }),
onCommit: (handler: OriginGestureCommitHandler) => ...hookMethods(draft, navigationStage),
navigationStage(withCommitHandler(draft, handler)),
}); });
} }
@@ -380,8 +493,7 @@ const navigateFrom =
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder { function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
return Object.freeze({ return Object.freeze({
navigate: navigateFrom(draft), navigate: navigateFrom(draft),
onCommit: (handler: OriginGestureCommitHandler) => ...hookMethods(draft, completedStage),
completedStage(withCommitHandler(draft, handler)),
}); });
} }
@@ -390,8 +502,7 @@ function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
complete: (completion: OriginGestureCompletionPredicate) => complete: (completion: OriginGestureCompletionPredicate) =>
completedStage(Object.freeze({ ...draft, completion })), completedStage(Object.freeze({ ...draft, completion })),
navigate: navigateFrom(draft), navigate: navigateFrom(draft),
onCommit: (handler: OriginGestureCommitHandler) => ...hookMethods(draft, directedStage),
directedStage(withCommitHandler(draft, handler)),
}); });
} }
@@ -405,6 +516,8 @@ function toStage(start: OriginGestureStart): OriginGestureToBuilder {
direction, direction,
recognition: Object.freeze({ ...options }), recognition: Object.freeze({ ...options }),
commitHandlers: Object.freeze([]), commitHandlers: Object.freeze([]),
armHandlers: Object.freeze([]),
disarmHandlers: Object.freeze([]),
}), }),
); );
return Object.freeze({ return Object.freeze({
@@ -431,8 +544,60 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({
anywhere: () => fromStage({ kind: "anywhere" }), anywhere: () => fromStage({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) => when: (predicate: OriginGestureStartPredicate) =>
fromStage({ kind: "when", predicate }), fromStage({ kind: "when", predicate }),
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
fromStage({ kind: "scroll-edge", scroller, edge }),
}); });
// Start rules ---------------------------------------------------------------
/*
* Composable `.from.when()` predicates. Each returns a start predicate, so
* they combine with anyOf() and allOf():
*
* gesture.from
* .when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
* .to.down()
*/
/** Admit pointer-down on an element matching `selector`, or inside one. */
export const startedIn =
(selector: string): OriginGestureStartPredicate =>
({ event }) =>
event.target instanceof Element && event.target.closest(selector) !== null;
/** Admit pointer-down within `distance` of one side of the gesture host. */
export const nearEdge =
(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStartPredicate =>
({ host, bounds, point }) =>
withinEdge(edge, distance, host, bounds, point);
/**
* Admit pointer-down while `scroller` is scrolled all the way to `edge`.
* A scroller that is missing (not yet mounted) counts as at the edge.
*/
export const atScrollEdge =
(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureStartPredicate =>
({ host }) =>
isAtScrollEdge(scroller, edge, host);
/** Admit pointer-down when any of `predicates` does. */
export const anyOf =
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
(context) =>
predicates.some((predicate) => predicate(context));
/** Admit pointer-down only when all of `predicates` do. */
export const allOf =
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
(context) =>
predicates.every((predicate) => predicate(context));
/** /**
* Root of the immutable gesture builder. * Root of the immutable gesture builder.
* *
+405 -109
View File
@@ -1,6 +1,12 @@
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 { 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";
import type { OriginContext, OriginOperationHandle } from "../scene/types"; import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, warn, type OriginRect } from "../shared"; import { clamp01, measureRect, warn, type OriginRect } from "../shared";
@@ -10,7 +16,10 @@ import type {
OriginGestureCompletionContext, OriginGestureCompletionContext,
OriginGestureDirection, OriginGestureDirection,
OriginGestureDistance, OriginGestureDistance,
OriginGestureEdge,
OriginGesturePoint, OriginGesturePoint,
OriginGestureProgressDistance,
OriginGestureScroller,
OriginGestureStart, OriginGestureStart,
} from "./definition"; } from "./definition";
@@ -52,23 +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 host recognizing the given directions: native * Whether the browser supports direction-specific `touch-action` values
* scrolling is preserved on any axis no gesture claims. * (`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 = ( export const touchActionFor = (
directions: Iterable<OriginGestureDirection>, definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
): "none" | "pan-x" | "pan-y" | "auto" => { atEdge: (
let horizontal = false; definition: Pick<OriginGestureDefinition, "start">,
let vertical = false; ) => boolean = () => false,
for (const direction of directions) { directional = supportsDirectionalPan(),
if (isHorizontal(direction)) horizontal = true; ): string => {
else vertical = true; 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 (horizontal && vertical) return "none"; if (!any) return "auto";
if (horizontal) return "pan-y"; if (pans.size === 4) return "manipulation";
if (vertical) return "pan-x"; if (pans.size === 0) return "none";
return "auto"; 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 ----------------------------------------------------
@@ -167,27 +237,67 @@ 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);
const horizontal = start.edge === "left" || start.edge === "right"; return withinEdge(start.edge, start.distance, host, bounds, point);
const distance = resolveCssDistance( };
start.distance,
/**
* 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,
distance: OriginGestureDistance,
host: HTMLElement,
bounds: OriginRect,
point: OriginGesturePoint,
): boolean => {
const horizontal = edge === "left" || edge === "right";
const limit = resolveCssDistance(
distance,
horizontal ? "horizontal" : "vertical", horizontal ? "horizontal" : "vertical",
host, host,
bounds, bounds,
); );
switch (start.edge) { switch (edge) {
case "left": case "left":
return point.localX >= 0 && point.localX <= distance; return point.localX >= 0 && point.localX <= limit;
case "right": case "right":
return ( return (
point.localX <= bounds.width && bounds.width - point.localX <= distance point.localX <= bounds.width && bounds.width - point.localX <= limit
); );
case "top": case "top":
return point.localY >= 0 && point.localY <= distance; return point.localY >= 0 && point.localY <= limit;
case "bottom": case "bottom":
return ( return (
point.localY <= bounds.height && point.localY <= bounds.height && bounds.height - point.localY <= limit
bounds.height - point.localY <= distance
); );
} }
}; };
@@ -196,54 +306,49 @@ export const matchesStart = (
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">; type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
/** Shortest frame interval assumed when computing velocity, in ms. */ /** Velocity is the trend of the samples in this many recent milliseconds. */
const MIN_SAMPLE_INTERVAL = 8; const VELOCITY_WINDOW = 100;
/** /** Shortest span, in ms, velocity is measured over, so bursts stay finite. */
* A release without movement after this many ms is treated as a hold, so the const MIN_VELOCITY_SPAN = 8;
* last move's flick velocity no longer applies.
*/
const HOLD_RELEASE_INTERVAL = 80;
/** /**
* Measures one pointer sequence's movement along a gesture direction. * Measures one pointer sequence's movement along a gesture direction.
* *
* Progress and velocity are normalized by the host's size along the gesture * Progress and velocity are normalized by the gesture distance (by default
* axis, so `1` means one full host width (or height). * the host's size along the gesture axis), so `1` means one full distance.
*/ */
export class DirectionalTracker { export class DirectionalTracker {
/** Directed distance from the start, in CSS pixels. */ /** Directed distance from the start, in CSS pixels. */
distance = 0; distance = 0;
/** Absolute cross-axis distance from the start, in CSS pixels. */ /** Absolute cross-axis distance from the start, in CSS pixels. */
crossDistance = 0; crossDistance = 0;
/** Directed distance as a fraction of host size, clamped to `0..1`. */ /** Directed distance ÷ gesture distance, unclamped so it can overshoot. */
progress = 0; rawProgress = 0;
/** Latest directed velocity in progress units per second. */ /**
* Directed velocity in progress units per second: the least-squares trend
* of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a
* final held-still moment do not distort a flick.
*/
velocity = 0; velocity = 0;
private readonly horizontal: boolean; private readonly samples: { time: number; position: number }[];
/** Whether progress grows as the axis coordinate decreases. */
private readonly reversed: boolean;
private lastCoordinate: number;
private lastTime: number;
constructor( constructor(
private readonly direction: OriginGestureDirection, private readonly direction: OriginGestureDirection,
private readonly start: PointerSample, private readonly start: PointerSample,
/** Host size along the gesture axis, read at every sample. */ /** Gesture distance in CSS pixels, read at every sample. */
private readonly axisSize: () => number, private readonly axisSize: () => number,
) { ) {
this.horizontal = isHorizontal(direction); this.samples = [{ time: start.timeStamp, position: 0 }];
this.reversed = direction === "left" || direction === "up";
this.lastCoordinate = this.coordinateOf(start);
this.lastTime = start.timeStamp;
} }
private coordinateOf(event: PointerSample) { /** Directed distance ÷ gesture distance, clamped to `0..1`. */
return this.horizontal ? event.clientX : event.clientY; get progress(): number {
return clamp01(this.rawProgress);
} }
/** 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 {
@@ -253,27 +358,38 @@ export class DirectionalTracker {
} }
/** Record a sample, updating distance, progress, and velocity. */ /** Record a sample, updating distance, progress, and velocity. */
sample(event: PointerSample, release = false): void { sample(event: PointerSample): void {
const { distance, crossDistance } = this.displacement(event); const { distance, crossDistance } = this.displacement(event);
this.distance = distance; this.distance = distance;
this.crossDistance = crossDistance; this.crossDistance = crossDistance;
const size = Math.max(1, this.axisSize()); const size = Math.max(1, this.axisSize());
const coordinate = this.coordinateOf(event); this.rawProgress = distance / size;
const step = this.reversed
? this.lastCoordinate - coordinate
: coordinate - this.lastCoordinate;
const interval = event.timeStamp - this.lastTime;
this.progress = clamp01(distance / size);
// Pointer-up commonly repeats the final pointer-move coordinate. Preserve
// that move's flick velocity for a prompt release, but decay it when the
// pointer was held still long enough for the flick to have ended.
if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL)
this.velocity =
(step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size);
this.lastCoordinate = coordinate; const samples = this.samples;
this.lastTime = event.timeStamp; samples.push({ time: event.timeStamp, position: distance });
// Keep the window, but always at least two samples to measure between.
const since = event.timeStamp - VELOCITY_WINDOW;
while (samples.length > 2 && samples[1]!.time <= since) samples.shift();
this.velocity = (DirectionalTracker.trend(samples) * 1000) / size;
}
/** Least-squares slope of position over time, in pixels per ms. */
private static trend(samples: readonly { time: number; position: number }[]) {
const first = samples[0]!;
const last = samples[samples.length - 1]!;
if (last.time - first.time < MIN_VELOCITY_SPAN)
return (last.position - first.position) / MIN_VELOCITY_SPAN;
const meanTime =
samples.reduce((sum, { time }) => sum + time, 0) / samples.length;
const meanPosition =
samples.reduce((sum, { position }) => sum + position, 0) / samples.length;
let covariance = 0;
let variance = 0;
for (const { time, position } of samples) {
covariance += (time - meanTime) * (position - meanPosition);
variance += (time - meanTime) ** 2;
}
return covariance / variance;
} }
} }
@@ -284,6 +400,28 @@ 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;
/** Resolve a definition's progress distance at pointer-down, in pixels. */
export const resolveProgressDistance = (
distance: OriginGestureProgressDistance | undefined,
direction: OriginGestureDirection,
host: HTMLElement,
bounds: OriginRect,
): (() => number) => {
const horizontal = isHorizontal(direction);
if (distance === undefined)
return () => (horizontal ? host.clientWidth : host.clientHeight);
const pixels =
typeof distance === "function"
? distance({ host, bounds })
: resolveCssDistance(
distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
return () => pixels;
};
/** The origin capabilities a recognizer needs. */ /** The origin capabilities a recognizer needs. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">; export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
@@ -299,10 +437,24 @@ interface PointerSession {
readonly start: OriginGesturePoint; readonly start: OriginGesturePoint;
readonly startTime: number; readonly startTime: number;
readonly tracker: DirectionalTracker; readonly tracker: DirectionalTracker;
/**
* For a gesture sharing its axis with scrolling: whether it has claimed
* this touch from native scrolling (`ours`) or not yet decided.
*/
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()`. */
armed: boolean;
/** The scene operation, once navigation resolved and `begin()` was called. */ /** The scene operation, once navigation resolved and `begin()` was called. */
operation: Promise<OriginOperationHandle | null> | null; operation: Promise<OriginOperationHandle | null> | null;
/**
* The operation's handle once `begin()` resolved, so pointer-up can finish
* it synchronously, inside the event, without awaiting the promise.
*/
handle: OriginOperationHandle | null;
} }
/** /**
@@ -333,6 +485,40 @@ export class GestureRecognizer {
private readonly origin: RecognizerOrigin, private readonly origin: RecognizerOrigin,
) {} ) {}
/**
* 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.
*/
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 { pointerDown(event: PointerEvent): void {
if (!event.isPrimary) return; if (!event.isPrimary) return;
// Only one primary pointer exists at a time, so any session still open // Only one primary pointer exists at a time, so any session still open
@@ -354,7 +540,6 @@ export class GestureRecognizer {
// The component containing this declaration is the operation's origin. // The component containing this declaration is the operation's origin.
event.stopPropagation(); event.stopPropagation();
const horizontal = isHorizontal(definition.direction);
this.session = { this.session = {
definition, definition,
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -363,11 +548,22 @@ export class GestureRecognizer {
origin, origin,
start: pointOf(event, bounds), start: pointOf(event, bounds),
startTime: event.timeStamp, startTime: event.timeStamp,
tracker: new DirectionalTracker(definition.direction, event, () => tracker: new DirectionalTracker(
horizontal ? host.clientWidth : host.clientHeight, definition.direction,
event,
resolveProgressDistance(
definition.recognition.distance,
definition.direction,
host,
bounds,
),
), ),
scroll: "pending",
last: { clientX: event.clientX, clientY: event.clientY },
captured: false, captured: false,
armed: false,
operation: null, operation: null,
handle: null,
}; };
} }
@@ -375,12 +571,14 @@ 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 (!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);
event.preventDefault(); event.preventDefault();
session.tracker.sample(event); session.tracker.sample(event);
this.updateArming(session, event);
const action = await this.resolveAction(session.definition); const action = await this.resolveAction(session.definition);
// The pointer may have been released or cancelled meanwhile. // The pointer may have been released or cancelled meanwhile.
if (this.session !== session) return; if (this.session !== session) return;
@@ -388,24 +586,28 @@ export class GestureRecognizer {
this.session = null; this.session = null;
return; return;
} }
session.operation = this.origin.begin(action).catch((error) => { session.operation = this.origin.begin(action).then(
// Another recognizer on this origin won the race; that is expected. (handle) => (session.handle = handle),
if (!( (error) => {
error instanceof OriginSceneError && error.code === "origin-busy" // Another recognizer on this origin won the race; that is expected.
)) if (!(
warn("A gesture could not begin its operation.", error); error instanceof OriginSceneError && error.code === "origin-busy"
return null; ))
}); warn("A gesture could not begin its operation.", error);
return null;
},
);
} else { } else {
event.preventDefault(); event.preventDefault();
session.tracker.sample(event); session.tracker.sample(event);
this.updateArming(session, event);
} }
// Progress is read after the await so the latest buffered sample wins. // Progress is read after the await so the latest buffered sample wins.
const pending = session.operation; const pending = session.operation;
const handle = pending ? await pending : null; const handle = pending ? await pending : null;
if (this.session === session && session.operation === pending) if (this.session === session && session.operation === pending)
handle?.update(session.tracker.progress, session.tracker.velocity); handle?.update(session.tracker.rawProgress, session.tracker.velocity);
} }
async pointerUp(event: PointerEvent): Promise<void> { async pointerUp(event: PointerEvent): Promise<void> {
@@ -415,25 +617,16 @@ export class GestureRecognizer {
if (!session.captured) return; if (!session.captured) return;
const { tracker, definition } = session; const { tracker, definition } = session;
tracker.sample(event, true); tracker.sample(event);
const context: OriginGestureCompletionContext = { const context = GestureRecognizer.contextOf(session, event);
origin: session.origin,
direction: definition.direction,
progress: tracker.progress,
velocity: tracker.velocity,
distance: tracker.distance,
crossDistance: tracker.crossDistance,
duration: Math.max(0, event.timeStamp - session.startTime),
event,
host: session.host,
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
};
const commit = definition.completion?.(context); const commit = definition.completion?.(context);
const { progress, velocity } = tracker; const { rawProgress: progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null; // Usually the operation began long ago: finish it without awaiting, so
// commit handlers run inside this pointer-up (iOS only allows haptics,
// audio, and clipboard writes during the user's gesture).
const handle =
session.handle ?? (session.operation ? await session.operation : null);
if (!handle) return; if (!handle) return;
handle.update(progress, velocity); handle.update(progress, velocity);
// finish() fixes the outcome synchronously, before the spring settles. // finish() fixes the outcome synchronously, before the spring settles.
@@ -441,13 +634,20 @@ export class GestureRecognizer {
commit === undefined ? undefined : { commit }, commit === undefined ? undefined : { commit },
); );
if (handle.intent === "commit") if (handle.intent === "commit")
GestureRecognizer.notifyCommit(definition, context); GestureRecognizer.notify(definition.commitHandlers ?? [], context);
await settled; await settled;
} }
/** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */ /**
* The host lost pointer capture without a pointer-up; cancel like
* pointer-cancel. The event bubbles, and taking capture makes the touched
* child lose its implicit touch capture, so only the host's own loss counts.
*/
pointerCaptureLost(event: PointerEvent): Promise<void> { pointerCaptureLost(event: PointerEvent): Promise<void> {
return this.sessionFor(event) ? this.abandon() : Promise.resolve(); const session = this.sessionFor(event);
return session && event.target === session.host
? this.abandon()
: Promise.resolve();
} }
pointerCancel(): Promise<void> { pointerCancel(): Promise<void> {
@@ -463,6 +663,45 @@ export class GestureRecognizer {
await handle?.cancel(); await handle?.cancel();
} }
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
private updateArming(session: PointerSession, event: PointerEvent) {
const { definition, tracker } = session;
const armHandlers = definition.armHandlers ?? [];
const disarmHandlers = definition.disarmHandlers ?? [];
if (!armHandlers.length && !disarmHandlers.length) return;
const context = GestureRecognizer.contextOf(session, event);
// Distance decides arming unless the definition supplies .complete():
// velocity changes too quickly to drive a cue without flickering.
const armed = definition.completion
? definition.completion(context)
: tracker.progress >=
(definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
if (armed === session.armed) return;
session.armed = armed;
GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context);
}
private static contextOf(
session: PointerSession,
event: PointerEvent,
): OriginGestureCompletionContext {
const { tracker } = session;
return {
origin: session.origin,
direction: session.definition.direction,
progress: tracker.progress,
velocity: tracker.velocity,
distance: tracker.distance,
crossDistance: tracker.crossDistance,
duration: Math.max(0, event.timeStamp - session.startTime),
event,
host: session.host,
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
};
}
private sessionFor(event: PointerEvent) { private sessionFor(event: PointerEvent) {
const session = this.session; const session = this.session;
return session && session.pointerId === event.pointerId ? session : null; return session && session.pointerId === event.pointerId ? session : null;
@@ -506,12 +745,12 @@ export class GestureRecognizer {
} }
} }
/** Run commit handlers; a failing handler never interrupts the others. */ /** Run handlers in order; a failing handler never interrupts the others. */
private static notifyCommit( private static notify(
definition: OriginGestureDefinition, handlers: readonly ((context: OriginGestureCompletionContext) => void)[],
context: OriginGestureCompletionContext, context: OriginGestureCompletionContext,
) { ) {
for (const handler of definition.commitHandlers ?? []) { for (const handler of handlers) {
try { try {
handler(context); handler(context);
} catch (error) { } catch (error) {
@@ -549,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);
}, },
@@ -583,6 +878,7 @@ export const useOriginGestures = (
for (const recognizer of current()) for (const recognizer of current())
void recognizer.pointerCaptureLost(event); void recognizer.pointerCaptureLost(event);
}, },
onTouchmove,
}; };
}; };
+9 -1
View File
@@ -28,6 +28,7 @@ export type {
OriginEffectSet, OriginEffectSet,
OriginOperationIntent, OriginOperationIntent,
OriginOperationPhase, OriginOperationPhase,
OriginSpring,
} from "./choreography"; } from "./choreography";
export { export {
defineOriginChoreography, defineOriginChoreography,
@@ -58,7 +59,14 @@ export {
} from "./scene/scene"; } from "./scene/scene";
export type * from "./gesture/definition"; export type * from "./gesture/definition";
export { gesture } from "./gesture/definition"; export {
allOf,
anyOf,
atScrollEdge,
gesture,
nearEdge,
startedIn,
} from "./gesture/definition";
export { useOriginGesture } from "./gesture/recognizer"; export { useOriginGesture } from "./gesture/recognizer";
/** /**
+17 -9
View File
@@ -65,25 +65,30 @@ export class OriginSceneError extends Error {
* Progress at which a release commits when the choreography does not supply * Progress at which a release commits when the choreography does not supply
* its own `commitThreshold`. * its own `commitThreshold`.
*/ */
export const DEFAULT_COMMIT_THRESHOLD = 0.36; export const DEFAULT_COMMIT_THRESHOLD = 0.25;
/** /**
* Release velocity (progress units per second) that commits a flick at any * Release velocity (progress units per second) that commits a flick at any
* progress when the choreography does not supply its own `commitVelocity`. * progress when the choreography does not supply its own `commitVelocity`.
*/ */
export const DEFAULT_COMMIT_VELOCITY = 0.3; export const DEFAULT_COMMIT_VELOCITY = 0.2;
/** Decide a release outcome from explicit options or progress/velocity. */ /**
* Decide a release outcome from explicit options, or from velocity and
* progress: a flick at least `commitVelocity` fast decides by its direction,
* forward committing and backward cancelling, regardless of distance. Slower
* releases commit once progress reaches `commitThreshold`.
*/
export const shouldCommit = ( export const shouldCommit = (
operation: MutableOriginOperation, operation: MutableOriginOperation,
options: OriginFinishOptions, options: OriginFinishOptions,
): boolean => { ): boolean => {
if (options.commit !== undefined) return options.commit; if (options.commit !== undefined) return options.commit;
const { commitThreshold, commitVelocity } = operation.choreography; const { commitThreshold, commitVelocity } = operation.choreography;
return ( const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) || if (operation.velocity >= flick) return true;
operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY) if (operation.velocity <= -flick) return false;
); return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
}; };
/** /**
@@ -269,6 +274,7 @@ export class SceneGraph {
const operation = this.operations.get(id); const operation = this.operations.get(id);
if (!operation || operation.phase === "finished") return; if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress); operation.progress = clamp01(progress);
operation.overshoot = progress - operation.progress;
operation.velocity = velocity; operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive"; if (operation.phase !== "preparing") operation.phase = "interactive";
} }
@@ -292,6 +298,7 @@ export class SceneGraph {
if (operation.history === "push" && operation.choreography.persistAtRest) { if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1; operation.progress = 1;
operation.overshoot = 0;
operation.velocity = 0; operation.velocity = 0;
operation.intent = "commit"; operation.intent = "commit";
operation.phase = "finished"; operation.phase = "finished";
@@ -540,6 +547,7 @@ export class SceneGraph {
placement: init.placement, placement: init.placement,
history: init.history, history: init.history,
progress: 0, progress: 0,
overshoot: 0,
velocity: 0, velocity: 0,
phase: "preparing", phase: "preparing",
intent: "undecided", intent: "undecided",
@@ -570,7 +578,7 @@ export class SceneGraph {
} }
/** /**
* Resolve the live operations a node started, because the node is leaving * Resolve the operations a node started, because the node is leaving
* the visible graph: nodes they created (push/replace targets, including * the visible graph: nodes they created (push/replace targets, including
* replacements whose history skips this node) are removed, and retained * replacements whose history skips this node) are removed, and retained
* entries they revealed (back targets) are restored as if that back * entries they revealed (back targets) are restored as if that back
@@ -590,7 +598,7 @@ export class SceneGraph {
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void { private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) { for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
if (operation.phase === "finished") continue; // Includes finished resting edges: an open drawer closes with its page.
if (operation.history === "back") { if (operation.history === "back") {
this.cancel(operation.id); this.cancel(operation.id);
} else { } else {
+157 -43
View File
@@ -13,11 +13,14 @@ import {
normalizedEffect, normalizedEffect,
type OriginEffect, type OriginEffect,
type OriginEffectSet, type OriginEffectSet,
type OriginSpring,
type OriginOperationIntent, type OriginOperationIntent,
type OriginOperationPhase, type OriginOperationPhase,
} from "../choreography"; } from "../choreography";
import type { OriginAction, OriginView } from "../navigation"; import type { OriginAction, OriginView } from "../navigation";
import { import {
DEV,
warn,
clamp, clamp,
clamp01, clamp01,
measureRect, measureRect,
@@ -136,8 +139,8 @@ export const composeNodeStyle = (
* easing on custom choreographies. * easing on custom choreographies.
*/ */
const STIFFNESS = 280; const DEFAULT_STIFFNESS = 280;
const DAMPING = 30; const DEFAULT_DAMPING = 30;
/** Fixed integration step, in seconds. */ /** Fixed integration step, in seconds. */
const STEP = 1 / 120; const STEP = 1 / 120;
/** Frame durations are clamped to this range, in seconds. */ /** Frame durations are clamped to this range, in seconds. */
@@ -163,13 +166,16 @@ export const stepSpring = (
state: SpringState, state: SpringState,
target: number, target: number,
elapsedMs: number, elapsedMs: number,
spring: OriginSpring = {},
): SpringState & { readonly done: boolean } => { ): SpringState & { readonly done: boolean } => {
const stiffness = spring.stiffness ?? DEFAULT_STIFFNESS;
const damping = spring.damping ?? DEFAULT_DAMPING;
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME); const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
const iterations = Math.max(1, Math.ceil(elapsed / STEP)); const iterations = Math.max(1, Math.ceil(elapsed / STEP));
const dt = elapsed / iterations; const dt = elapsed / iterations;
let { position, velocity } = state; let { position, velocity } = state;
for (let index = 0; index < iterations; index += 1) { for (let index = 0; index < iterations; index += 1) {
const acceleration = (target - position) * STIFFNESS - velocity * DAMPING; const acceleration = (target - position) * stiffness - velocity * damping;
velocity += acceleration * dt; velocity += acceleration * dt;
position += velocity * dt; position += velocity * dt;
} }
@@ -183,13 +189,14 @@ export const stepSpring = (
* Run a spring on `requestAnimationFrame` until it rests at `target` or * Run a spring on `requestAnimationFrame` until it rests at `target` or
* `onFrame` returns `false` because its subject no longer exists. * `onFrame` returns `false` because its subject no longer exists.
* *
* Every reported progress is clamped to `0..1`; the final frame reports * Each frame reports the raw position; the final frame reports exactly
* exactly `target` with zero velocity. * `target` with zero velocity.
*/ */
export const runSpring = ( export const runSpring = (
from: SpringState, from: SpringState,
target: number, target: number,
onFrame: (progress: number, velocity: number) => boolean, onFrame: (position: number, velocity: number) => boolean,
spring: OriginSpring = {},
): Promise<void> => ): Promise<void> =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
let state: SpringState = { let state: SpringState = {
@@ -203,18 +210,66 @@ export const runSpring = (
let previous = performance.now(); let previous = performance.now();
const frame = (time: number) => { const frame = (time: number) => {
const next = stepSpring(state, target, time - previous); const next = stepSpring(state, target, time - previous, spring);
previous = time; previous = time;
state = next; state = next;
const alive = next.done const alive = next.done
? onFrame(target, 0) ? onFrame(target, 0)
: onFrame(clamp01(next.position), next.velocity); : onFrame(next.position, next.velocity);
if (!alive || next.done) resolve(); if (!alive || next.done) resolve();
else window.requestAnimationFrame(frame); else window.requestAnimationFrame(frame);
}; };
window.requestAnimationFrame(frame); window.requestAnimationFrame(frame);
}); });
// Continuity check (development only) ---------------------------------------
/** A host's rendered transform as a 4×4 matrix, plus its opacity. */
interface Pose {
readonly matrix: readonly number[];
readonly opacity: number;
}
const warnedChoreographies = new WeakSet<object>();
/** Read a visible host's rendered pose; `null` if hidden or unreadable. */
const poseOf = (host: HTMLElement): Pose | null => {
const style = getComputedStyle(host);
if (style.visibility === "hidden") return null;
const matrix = parseMatrix(style.transform);
const opacity = Number(style.opacity);
return matrix && Number.isFinite(opacity) ? { matrix, opacity } : null;
};
/** Parse a computed `transform` ("none", matrix() or matrix3d()). */
const parseMatrix = (transform: string): number[] | null => {
if (!transform || transform === "none")
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const values = /^matrix(3d)?\(([^)]*)\)$/.exec(transform);
if (!values) return null;
const numbers = values[2]!.split(",").map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return null;
if (values[1]) return numbers.length === 16 ? numbers : null;
if (numbers.length !== 6) return null;
const [a, b, c, d, e, f] = numbers as [
number,
number,
number,
number,
number,
number,
];
return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1];
};
/** Equal within half a pixel of translation and 1% of anything else. */
const posesMatch = (a: Pose, b: Pose): boolean =>
Math.abs(a.opacity - b.opacity) <= 0.01 &&
a.matrix.every((value, index) => {
const tolerance = index === 12 || index === 13 || index === 14 ? 0.5 : 0.01;
return Math.abs(value - b.matrix[index]!) <= tolerance;
});
// Scene --------------------------------------------------------------------- // Scene ---------------------------------------------------------------------
/** Imperative controller for one operation, returned by `Scene.begin()`. */ /** Imperative controller for one operation, returned by `Scene.begin()`. */
@@ -285,9 +340,12 @@ class Scene implements OriginScene {
*/ */
/** The latest release (finish call) of each operation. */ /** The latest release (finish call) of each operation. */
private readonly releases = new WeakMap<MutableOriginOperation, object>(); private readonly releases = new WeakMap<MutableOriginOperation, object>();
private readonly outcomes = new WeakMap< /** Final outcome of each operation, once a release has decided it. */
private readonly outcomes = new WeakMap<MutableOriginOperation, boolean>();
/** Interrupted releases waiting for a later release to decide. */
private readonly waiters = new WeakMap<
MutableOriginOperation, MutableOriginOperation,
{ promise: Promise<boolean>; resolve(committed: boolean): void } Array<(committed: boolean) => void>
>(); >();
private readonly effectSets = new WeakMap< private readonly effectSets = new WeakMap<
MutableOriginOperation, MutableOriginOperation,
@@ -322,7 +380,10 @@ class Scene implements OriginScene {
originKey: string, originKey: string,
action: OriginAction, action: OriginAction,
): Promise<OriginOperationHandle> { ): Promise<OriginOperationHandle> {
this.refreshViewport(); if (this.container) this.refreshViewport();
// Only a scene at rest has a pose the new operation must continue from.
const poses =
DEV && this.operations.value.length === 0 ? this.capturePoses() : null;
const operation = this.graph.begin( const operation = this.graph.begin(
originKey, originKey,
action, action,
@@ -335,6 +396,7 @@ class Scene implements OriginScene {
await nextTick(); await nextTick();
operation.targetRect = this.measure(targetKey); operation.targetRect = this.measure(targetKey);
operation.phase = "interactive"; operation.phase = "interactive";
if (poses) this.warnOnJump(poses, operation);
return new OperationHandle(this, operation, targetKey); return new OperationHandle(this, operation, targetKey);
} }
@@ -383,36 +445,40 @@ class Scene implements OriginScene {
current, current,
); );
const outcome = this.outcomeOf(operation); // Another release already decided this operation.
const decided = this.outcomes.get(operation);
if (decided !== undefined) return decided;
// A later operation may already have finalized this edge. // A later operation may already have finalized this edge.
const finished = (operation.phase as OriginOperationPhase) === "finished"; const finished = (operation.phase as OriginOperationPhase) === "finished";
if (!this.graph.operation(operation.id) || finished) { if (!this.graph.operation(operation.id) || finished)
const committed = this.graph.operation(operation.id) return this.decide(
? operation.intent === "commit" operation,
: commit; finished ? operation.intent === "commit" : commit,
outcome.resolve(committed); );
return committed; // Caught by update() or superseded by a later finish(): wait for the
} // release that decides.
// Caught by update() or superseded by a later finish(). if (!current())
if (!current()) return outcome.promise; return new Promise<boolean>((resolve) =>
this.waiters.set(operation, [
...(this.waiters.get(operation) ?? []),
resolve,
]),
);
const committed = commit return this.decide(
? this.graph.commit(operation.id) operation,
: (this.graph.cancel(operation.id), false); commit
outcome.resolve(committed); ? this.graph.commit(operation.id)
return committed; : (this.graph.cancel(operation.id), false),
);
} }
/** Resolves with an operation's final outcome, whichever release decides. */ /** Record an operation's final outcome and settle interrupted releases. */
private outcomeOf(operation: MutableOriginOperation) { private decide(operation: MutableOriginOperation, committed: boolean) {
let outcome = this.outcomes.get(operation); this.outcomes.set(operation, committed);
if (!outcome) { for (const resolve of this.waiters.get(operation) ?? []) resolve(committed);
let resolve!: (committed: boolean) => void; this.waiters.delete(operation);
const promise = new Promise<boolean>((done) => (resolve = done)); return committed;
outcome = { promise, resolve };
this.outcomes.set(operation, outcome);
}
return outcome;
} }
registerElement(nodeKey: string, element: HTMLElement | null): void { registerElement(nodeKey: string, element: HTMLElement | null): void {
@@ -454,16 +520,63 @@ class Scene implements OriginScene {
) { ) {
if (!animate || prefersReducedMotion()) { if (!animate || prefersReducedMotion()) {
operation.progress = target; operation.progress = target;
operation.overshoot = 0;
operation.velocity = 0; operation.velocity = 0;
return; return;
} }
const from = { position: operation.progress, velocity: operation.velocity }; // Start from the raw position so a rubber-banded drag springs back
await runSpring(from, target, (progress, velocity) => { // continuously instead of snapping its overshoot away.
if (!this.graph.operation(operation.id) || !current()) return false; const from = {
operation.progress = progress; position: operation.progress + operation.overshoot,
operation.velocity = velocity; velocity: operation.velocity,
return true; };
}); await runSpring(
from,
target,
(position, velocity) => {
if (!this.graph.operation(operation.id) || !current()) return false;
operation.progress = clamp01(position);
operation.overshoot = position - operation.progress;
operation.velocity = velocity;
return true;
},
operation.choreography.spring,
);
}
/** Development only: each visible host's rendered transform and opacity. */
private capturePoses(): Map<string, Pose> {
const poses = new Map<string, Pose>();
for (const [key, host] of this.hosts) {
const pose = poseOf(host);
if (pose) poses.set(key, pose);
}
return poses;
}
/**
* Development only: warn when an operation's first frame moved a node that
* was visibly at rest, the one-frame "pop" of mismatched choreographies.
*/
private warnOnJump(
before: Map<string, Pose>,
operation: MutableOriginOperation,
) {
const { choreography } = operation;
if (warnedChoreographies.has(choreography)) return;
for (const [key, pose] of before) {
const host = this.hosts.get(key);
const after = host && poseOf(host);
if (!after || posesMatch(pose, after)) continue;
warnedChoreographies.add(choreography);
warn(
`"${choreography.name ?? "anonymous"}" moved node "${key}" on its ` +
"first frame. At progress 0 its effects should match the pose the " +
"scene was resting in, usually the previous choreography at " +
"progress 1; otherwise the view jumps.",
);
return;
}
} }
private measure(nodeKey: string): OriginRect | undefined { private measure(nodeKey: string): OriginRect | undefined {
@@ -492,6 +605,7 @@ class Scene implements OriginScene {
effects = computed(() => effects = computed(() =>
operation.choreography.effects({ operation.choreography.effects({
progress: operation.progress, progress: operation.progress,
overshoot: operation.overshoot,
velocity: operation.velocity, velocity: operation.velocity,
phase: operation.phase, phase: operation.phase,
intent: operation.intent, intent: operation.intent,
+7 -2
View File
@@ -68,6 +68,8 @@ export interface OriginOperation {
readonly history: OriginHistoryMode; readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */ /** Normalized progress in the inclusive range `0..1`. */
readonly progress: number; readonly progress: number;
/** Drag distance past either end of `progress`; usually `0`. */
readonly overshoot: number;
/** Latest normalized velocity in progress units per second. */ /** Latest normalized velocity in progress units per second. */
readonly velocity: number; readonly velocity: number;
/** Current operation lifecycle phase. */ /** Current operation lifecycle phase. */
@@ -126,8 +128,9 @@ export interface OriginOperationHandle {
/** /**
* Set interactive progress and optional velocity. * Set interactive progress and optional velocity.
* *
* Progress is clamped to `0..1`. Velocity is normalized to progress units * Progress is clamped to `0..1`; any excess is reported to choreographies as
* per second and is used by the choreography's flick threshold. * `overshoot`. Velocity is normalized to progress units per second and is
* used by the choreography's flick threshold.
*/ */
update(progress: number, velocity?: number): void; update(progress: number, velocity?: number): void;
/** /**
@@ -212,6 +215,8 @@ export interface MutableOriginOperation {
history: OriginHistoryMode; history: OriginHistoryMode;
/** Normalized progress. */ /** Normalized progress. */
progress: number; progress: number;
/** Drag distance past either end of `progress`. */
overshoot: number;
/** Normalized velocity. */ /** Normalized velocity. */
velocity: number; velocity: number;
/** Current lifecycle phase. */ /** Current lifecycle phase. */
+7
View File
@@ -56,3 +56,10 @@ export function warn(message: string, error?: unknown): void {
if (typeof console !== "undefined") if (typeof console !== "undefined")
console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : [])); console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : []));
} }
// Bundlers replace `process.env.NODE_ENV` in dependencies at build time, the
// same convention Vue and Vue Router use for their development-only checks.
declare const process: { env: { NODE_ENV?: string } };
/** Whether development-only checks and warnings are enabled. */
export const DEV: boolean = process.env.NODE_ENV !== "production";
+17
View File
@@ -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;
@@ -13,7 +13,7 @@ import {
useOriginGesture, useOriginGesture,
type OriginGestureBinding, type OriginGestureBinding,
type OriginGestureCompletionContext, type OriginGestureCompletionContext,
} from "."; } from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = []; const mountedApps: Array<ReturnType<typeof createApp>> = [];
@@ -19,7 +19,7 @@ import {
originView, originView,
useOriginGesture, useOriginGesture,
type OriginGestureDefinition, type OriginGestureDefinition,
} from "."; } from "../src";
const apps: Array<ReturnType<typeof createApp>> = []; const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => { afterEach(() => {
@@ -247,6 +247,32 @@ describe(".onCommit()", () => {
} }
}); });
it("runs synchronously inside the pointer-up event", async () => {
let calledDuringDispatch = false;
let dispatching = false;
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
calledDuringDispatch = dispatching;
})
.animate(motion),
);
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
host.dispatchEvent(pointer("pointermove", 40, 1000));
await flush();
// No awaiting between the event and the handler: iOS only allows
// haptics, audio, and clipboard writes during the user's gesture.
dispatching = true;
host.dispatchEvent(pointer("pointerup", 40, 1000));
dispatching = false;
expect(calledDuringDispatch).toBe(true);
});
it("fires at release, before the settling animation finishes", async () => { it("fires at release, before the settling animation finishes", async () => {
const frames: FrameRequestCallback[] = []; const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
+400
View File
@@ -0,0 +1,400 @@
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
allOf,
anyOf,
atScrollEdge,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
nearEdge,
OriginGesture,
OriginGestureSurface,
OriginScene,
originView,
startedIn,
type OriginChoreographyContext,
type OriginGestureDefinition,
type OriginGestureStartContext,
} from "../src";
import { DirectionalTracker } from "../src/gesture/recognizer";
import { stepSpring } from "../src/scene/scene";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const contexts: OriginChoreographyContext[] = [];
const motion = defineOriginChoreography({
name: "v2-test",
effects: (context) => {
contexts.push(context);
return { target: { opacity: context.progress } };
},
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
clientY: number,
timeStamp: number,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
clientX,
clientY,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
function touchmove() {
return new TouchEvent("touchmove", { bubbles: true, cancelable: true });
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
async function mount(root: Component) {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const scene = createOriginScene({ initial: originView(root) });
const element = document.createElement("div");
document.body.append(element);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(element);
await nextTick();
const host = element.querySelector("#host") as HTMLElement;
Object.defineProperties(host, {
clientWidth: { configurable: true, value: 200 },
clientHeight: { configurable: true, value: 400 },
});
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
const hostFor = (
definition: OriginGestureDefinition,
children?: () => unknown,
) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
children ? children() : "host",
),
});
describe("recognition and native scrolling", () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
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));
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
await flush();
const move = touchmove();
host.dispatchEvent(move);
// 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");
});
});
describe("scroll edges", () => {
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
async function mountWithScroller(scrollTop: number) {
const Root = hostFor(dismiss, () =>
h("div", {
id: "list",
ref: (element) => (scroller.value = element as HTMLElement | null),
}),
);
const mounted = await mount(Root);
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));
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);
});
});
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));
const move = touchmove();
atTop.host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
});
});
describe("start-rule helpers", () => {
const host = document.createElement("div");
host.innerHTML = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
const context = (target: Element, localX = 100): OriginGestureStartContext =>
({
event: { target } as unknown as PointerEvent,
origin: {} as never,
host,
bounds: { top: 0, left: 0, width: 200, height: 400 },
point: { clientX: localX, clientY: 10, localX, localY: 10 },
}) as OriginGestureStartContext;
it("matches pointer-down inside a selector", () => {
expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe(
true,
);
expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe(
false,
);
});
it("composes with anyOf and allOf", () => {
const body = host.querySelector("#body")!;
const nearLeft = nearEdge("left", 40);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe(
false,
);
expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true);
expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false);
});
it("checks scroll edges, treating a missing scroller as at the edge", () => {
const list = document.createElement("div");
Object.defineProperties(list, {
scrollTop: { value: 0 },
clientHeight: { value: 100 },
scrollHeight: { value: 300 },
});
const body = host.querySelector("#body")!;
expect(atScrollEdge(list, "top")(context(body))).toBe(true);
expect(atScrollEdge(list, "bottom")(context(body))).toBe(false);
expect(atScrollEdge(null, "top")(context(body))).toBe(true);
expect(atScrollEdge(".missing", "top")(context(body))).toBe(true);
});
});
describe("progress", () => {
it("measures against a custom distance and reports overshoot", async () => {
contexts.length = 0;
const drawer = gesture.to
.right({ distance: 100 })
.navigate(toTarget)
.animate(motion);
const { scene, host } = await mount(hostFor(drawer));
host.dispatchEvent(pointer("pointerdown", 0, 100, 0));
host.dispatchEvent(pointer("pointermove", 50, 100, 16));
await flush();
host.dispatchEvent(pointer("pointermove", 130, 100, 32));
await flush();
// Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot.
expect(scene.operations.value[0]).toMatchObject({ progress: 1 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3);
scene.styleForNode(scene.operations.value[0]!.targetKey);
expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3);
host.dispatchEvent(pointer("pointermove", -20, 100, 48));
await flush();
expect(scene.operations.value[0]).toMatchObject({ progress: 0 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2);
});
it("averages velocity over recent samples instead of the last step", () => {
const tracker = new DirectionalTracker(
"right",
{ clientX: 0, clientY: 0, timeStamp: 0 },
() => 100,
);
// Steady 1px/ms with one jittery, late sample.
for (const [x, t] of [
[16, 16],
[32, 32],
[48, 48],
[52, 50],
[80, 80],
] as const)
tracker.sample({ clientX: x, clientY: 0, timeStamp: t });
expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px.
// Held still for longer than the window: the flick is over.
tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 });
expect(tracker.velocity).toBe(0);
});
});
describe("springs", () => {
it("uses the choreography's stiffness and damping", () => {
const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 80,
});
const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 600,
});
expect(stiff.position).toBeGreaterThan(soft.position);
const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16);
expect(unchanged.position).toBeGreaterThan(soft.position);
});
});
describe(".onArm() and .onDisarm()", () => {
it("fire when the drag crosses the commit threshold", async () => {
const events: string[] = [];
const definition = gesture.to
.left()
.navigate(toTarget)
.onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`))
.onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`))
.animate(motion);
const { host } = await mount(hostFor(definition));
host.dispatchEvent(pointer("pointerdown", 190, 100, 0));
for (const [x, t] of [
[180, 16],
[160, 32],
[130, 48],
[100, 64],
[150, 80],
[90, 96],
] as const) {
host.dispatchEvent(pointer("pointermove", x, 100, t));
await flush();
}
// Default arming is distance-based: 25% of the 200px host is 50px.
expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]);
});
});
@@ -23,9 +23,9 @@ import {
under, under,
type OriginAction, type OriginAction,
type OriginOperationHandle, type OriginOperationHandle,
} from "."; } from "../src";
import type { SceneGraph } from "./scene/graph"; import type { SceneGraph } from "../src/scene/graph";
import type { OriginScene as Scene } from "./scene/types"; import type { OriginScene as Scene } from "../src/scene/types";
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60); const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
@@ -19,8 +19,8 @@ import {
OriginSceneError, OriginSceneError,
originView, originView,
type OriginGestureDefinition, type OriginGestureDefinition,
} from "."; } from "../src";
import { shouldCommit } from "./scene/graph"; import { shouldCommit } from "../src/scene/graph";
const apps: Array<ReturnType<typeof createApp>> = []; const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => { afterEach(() => {
@@ -108,6 +108,26 @@ describe("stuck gestures", () => {
expect(scene.nodes.value).toHaveLength(1); expect(scene.nodes.value).toHaveLength(1);
}); });
it("ignores a child losing implicit touch capture to the host", async () => {
const Root = defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
h("span", { id: "child" }, "x"),
),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
// Taking capture on the host makes the touched child lose its implicit
// capture; that event bubbles to the host.
host
.querySelector("#child")!
.dispatchEvent(pointer("lostpointercapture", 120, 110));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("ignores lostpointercapture after a normal release", async () => { it("ignores lostpointercapture after a normal release", async () => {
const { scene, host } = await mount(gestureHost(() => definition)); const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host); await dragLeft(host);
@@ -227,10 +247,11 @@ describe("reactive gesture props", () => {
host.dispatchEvent(pointer("pointerdown", 180, 0)); host.dispatchEvent(pointer("pointerdown", 180, 0));
current.value = rightward; current.value = rightward;
await nextTick(); await nextTick();
host.dispatchEvent(pointer("pointermove", 120, 100)); host.dispatchEvent(pointer("pointermove", 140, 100));
await flush(); await flush();
expect(scene.operations.value).toHaveLength(1); expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("pointerup", 120, 1000)); // A short, held release (20%) cancels, leaving the origin interactive.
host.dispatchEvent(pointer("pointerup", 140, 1000));
await flush(); await flush();
// The next touch uses the rightward definition. // The next touch uses the rightward definition.
@@ -307,9 +328,9 @@ describe("commitVelocity", () => {
choreography: { effects: () => ({}), commitVelocity }, choreography: { effects: () => ({}), commitVelocity },
}) as unknown as Parameters<typeof shouldCommit>[0]; }) as unknown as Parameters<typeof shouldCommit>[0];
it("defaults to 0.3", () => { it("defaults to 0.2", () => {
expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false); expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
expect(shouldCommit(operation(undefined, 0.3), {})).toBe(true); expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true);
}); });
it("uses the choreography's value when supplied", () => { it("uses the choreography's value when supplied", () => {
@@ -318,6 +339,22 @@ describe("commitVelocity", () => {
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true); expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
}); });
it("lets a fast flick's direction veto the distance", () => {
const at = (progress: number, velocity: number) =>
({
progress,
velocity,
choreography: { effects: () => ({}) },
}) as unknown as Parameters<typeof shouldCommit>[0];
// Past the 0.36 threshold, but flicked back toward the start: cancels.
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
// Barely started, but flicked forward: commits.
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
// Slow releases fall back to distance, in either direction.
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
});
it("still yields to an explicit decision", () => { it("still yields to an explicit decision", () => {
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false); expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
}); });
@@ -18,7 +18,7 @@ import {
originView, originView,
replace, replace,
under, under,
} from "."; } from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = []; const mountedApps: Array<ReturnType<typeof createApp>> = [];
+1 -2
View File
@@ -9,6 +9,5 @@
"outDir": "dist", "outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo" "tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
}, },
"include": ["src/**/*.ts", "src/**/*.vue"], "include": ["src/**/*.ts", "src/**/*.vue"]
"exclude": ["src/**/*.test.ts"]
} }
+1
View File
@@ -1 +1,2 @@
.baseline/ .baseline/
browser/app/dist/
+22 -1
View File
@@ -15,6 +15,27 @@ ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.conf
Run each implementation in its own process with `ONLY=`; running both in one Run each implementation in its own process with `ONLY=`; running both in one
process biases whichever runs second. process biases whichever runs second.
Behavioural verification lives with the unit tests: `src/property.test.ts` 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 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`, 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
npm run test:feel
```
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
@@ -0,0 +1,34 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
html,
body,
#app {
margin: 0;
height: 100%;
overflow: hidden;
}
.page {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.list {
flex: 1;
overflow: auto;
}
.row {
height: 80px;
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
import { createApp, defineComponent, h, ref } from "vue";
import "../../../src/style.css";
import {
OriginGesture,
OriginGestureSurface,
OriginScene,
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
originView,
slideLeft,
useOrigin,
} from "../../../src";
declare global {
interface Window {
log: string[];
inPointerup: boolean;
scene: ReturnType<typeof createOriginScene>;
scrollTopOf: (selector: string) => number;
begin: (name: "matched" | "mismatched") => Promise<void>;
}
}
window.log = [];
window.inPointerup = false;
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
window.addEventListener("pointerup", () =>
setTimeout(() => (window.inPointerup = false)),
);
const warn = console.warn;
console.warn = (...args: unknown[]) => {
window.log.push("warn: " + String(args[0]));
warn(...args);
};
const rows = (n: number) =>
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
const Detail = defineComponent({
name: "Detail",
setup() {
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((c) => (c.canGoBack ? back() : null))
.onCommit(() => window.log.push("dismiss committed"))
.animate(
defineOriginChoreography({
name: "dismiss",
effects: ({ progress }) => ({
source: { transform: `translateY(${progress * 100}%)` },
}),
}),
);
return () =>
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
);
},
});
const Home = defineComponent({
name: "Home",
setup() {
const open = gesture.to
.left()
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
.onCommit(() =>
window.log.push(`open committed inPointerup=${window.inPointerup}`),
)
.animate(slideLeft);
return () =>
h(
OriginGestureSurface,
{ gestures: [open], class: "page", id: "home" },
() => h("div", { class: "list", id: "home-list" }, rows(40)),
);
},
});
// Continuity check pair: a drawer that rests at 80% and a close that starts
// either from the same pose (matched) or from 0 (mismatched).
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 80}%)` },
}),
});
const closes = {
matched: defineOriginChoreography({
name: "drawer-close-matched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 80}%)` },
}),
}),
mismatched: defineOriginChoreography({
name: "drawer-close-mismatched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 50}%)` },
}),
}),
};
const Drawer = defineComponent({
name: "Drawer",
render: () => h("div", { class: "page" }, "drawer"),
});
const Plain = defineComponent({
name: "Plain",
setup() {
const origin = useOrigin();
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
() =>
origin.perform(
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
);
return () => h("div", { class: "page", id: "plain" }, "plain");
},
});
const params = new URLSearchParams(location.search);
const initial = params.get("view") === "plain" ? Plain : Home;
const scene = createOriginScene({
initial: originView(initial, undefined, { key: "root" }),
});
window.scene = scene;
window.scrollTopOf = (selector) =>
document.querySelector(selector)?.scrollTop ?? -1;
window.begin = async (name) => {
await (
window as unknown as { openDrawer: () => Promise<boolean> }
).openDrawer();
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
const handle = await scene.begin(drawerKey, back(closes[name]));
await handle.cancel({ animate: false });
await handle.finish({ commit: false, animate: false });
};
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");
@@ -0,0 +1,8 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
root: import.meta.dirname,
base: "./",
plugins: [vue()],
build: { outDir: "dist", emptyOutDir: true, minify: false },
});
+256
View File
@@ -0,0 +1,256 @@
import { chromium } from "@playwright/test";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
const root = join(import.meta.dirname, "app/dist");
const types = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
};
const server = createServer(async (req, res) => {
const path = join(
root,
new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"),
);
let body;
try {
body = await readFile(path);
} catch {
res.writeHead(404);
return res.end();
}
res.writeHead(200, {
"content-type": types[extname(path)] ?? "application/octet-stream",
});
res.end(body);
}).listen(0);
const base = `http://127.0.0.1:${server.address().port}/`;
// CHROMIUM_PATH overrides Playwright's bundled Chromium.
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH,
});
const context = await browser.newContext({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
const results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
};
async function open(query = "") {
const page = await context.newPage();
page.on("pageerror", (e) => console.log("pageerror", e.message));
await page.goto(base + query);
await page.waitForFunction(() => window.scene);
return { page, cdp: await context.newCDPSession(page) };
}
async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: from[0], y: from[1] }],
});
for (let i = 1; i <= steps; i++) {
const x = from[0] + ((to[0] - from[0]) * i) / steps;
const y = from[1] + ((to[1] - from[1]) * i) / steps;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y }],
});
await page.waitForTimeout(stepMs);
}
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(700);
}
const states = (page) =>
page.evaluate(() =>
window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "),
);
const log = (page) => page.evaluate(() => window.log.slice());
// 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel).
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
const s = await states(t.page);
check(
"horizontal swipe over a scroller opens Detail",
s === "root:parked detail:active",
s,
);
const l = await log(t.page);
check(
"onCommit runs inside the real pointerup event",
l.includes("open committed inPointerup=true"),
l.join(" | "),
);
await t.page.close();
}
// 2. Vertical drag on the same host scrolls natively and leaves the gesture alone.
{
const t = await open();
await drag(t, [200, 700], [205, 300]);
const s = await states(t.page);
const top = await t.page.evaluate(() => window.scrollTopOf("#home-list"));
check(
"vertical drag scrolls the list natively",
top > 100,
`scrollTop=${top}`,
);
check(
"vertical drag does not start the horizontal gesture",
s === "root:active",
s,
);
await t.page.close();
}
// 3. Scroll-edge dismiss.
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
check(
"(setup) Detail open",
(await states(t.page)) === "root:parked detail:active",
);
await drag(t, [200, 700], [200, 300]); // scroll the list down
const scrolled = await t.page.evaluate(() =>
window.scrollTopOf("#detail-list"),
);
check(
"drag up scrolls the detail list",
scrolled > 100,
`scrollTop=${scrolled}`,
);
await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss
const afterDown = await t.page.evaluate(() =>
window.scrollTopOf("#detail-list"),
);
check(
"drag down while scrolled scrolls the list, not dismiss",
afterDown < scrolled &&
(await states(t.page)) === "root:parked detail:active",
`scrollTop ${scrolled} → ${afterDown}`,
);
await t.page.evaluate(
() => (document.querySelector("#detail-list").scrollTop = 0),
);
await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses
const s = await states(t.page);
const l = await log(t.page);
check(
"drag down at the top dismisses",
s === "root:active" && l.includes("dismiss committed"),
s,
);
await t.page.close();
}
// 3b. At the top, an upward drag still scrolls the list.
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
await drag(t, [200, 700], [200, 300]);
const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list"));
check(
"at the top, drag up still scrolls (dismiss lets go)",
top > 100 && (await states(t.page)) === "root:parked detail:active",
`scrollTop=${top}`,
);
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],
["mismatched", true],
]) {
const t = await open("?view=plain");
await t.page.evaluate((n) => window.begin(n), name);
await t.page.waitForTimeout(100);
const warned = (await log(t.page)).some((l) => l.includes("moved node"));
check(
`continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`,
warned === expectWarning,
(await log(t.page)).join(" | "),
);
await t.page.close();
}
await browser.close();
server.close();
const failed = results.filter((r) => !r.ok).length;
console.log(`\n${results.length - failed}/${results.length} passed`);
process.exit(failed ? 1 : 0);
+2 -1
View File
@@ -24,6 +24,7 @@
"apps/demo/src/**/*.ts", "apps/demo/src/**/*.ts",
"apps/demo/src/**/*.vue", "apps/demo/src/**/*.vue",
"packages/*/src/**/*.ts", "packages/*/src/**/*.ts",
"packages/*/src/**/*.vue" "packages/*/src/**/*.vue",
"packages/*/tests/**/*.ts"
] ]
} }