diff --git a/package.json b/package.json index 723d914..b76b382 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "electron": "npm run build && electron apps/electron/main.mjs", "cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", - "prepare": "npm run build --workspace packages/core-v2" + "prepare": "npm run build --workspace packages/core-v2", + "test:feel": "NODE_ENV=development vite build --mode development --config packages/core-v2/verify/browser/app/vite.config.mjs && node packages/core-v2/verify/browser/run.mjs" }, "devDependencies": { "@capacitor/app": "^8.0.0", diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index a9acf52..65cc135 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -220,11 +220,10 @@ const gestures = [forwardGesture, backGesture] as const; | `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules | The component adds no recognition or navigation policy. It installs each -definition, forwards every pointer and touch event to every recognizer, and -lets the recognizers share the touch: each locks onto a touch moving along its -own axis and direction and lets go of any other, so a horizontal drawer, a -pull-up sheet, and vertical scrolling can live on one host. See -[Scrolling](#scrolling). +definition, forwards every pointer event to every recognizer, and derives one +`touch-action` that leaves native scrolling on every pan no gesture needs. A +horizontal drawer and a scroll-edge pull-down can share a host with vertical +scrolling. See [Scrolling](#scrolling). The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new definition objects never interrupts a gesture in progress; each change applies @@ -546,25 +545,43 @@ interface OriginGestureBinding { readonly onPointerup: (event: PointerEvent) => void; readonly onPointercancel: () => void; readonly onLostpointercapture: (event: PointerEvent) => void; - readonly onTouchmove: (event: TouchEvent) => void; + readonly listeners: Readonly void>>; + readonly ref: (element: unknown) => void; } ``` -Apply all handlers to the same element, as non-passive listeners (Vue's -`@touchmove` is), and apply the returned style. The style sets dimensions and -`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out -of a gesture the recognizer has claimed. Without it, the browser takes over -vertical and horizontal drags with `pointercancel`. +Apply the pointer handlers, the returned style, and `listeners` to the same +element, and pass `ref` as its function ref: + +```vue +
+``` + +The style sets dimensions and the host's `touch-action` (see +[Scrolling](#scrolling)). `listeners` is empty for ordinary gestures; for +scroll-edge gestures it tracks the scroller, and where the browser lacks +directional `touch-action` it adds a `touchmove` handler. Recognition requires: 1. A primary, left-button pointer satisfies the optional start policy. 2. The target is not an ignored interactive element. -3. The first 6px of movement are along the gesture's axis and direction (see - [Scrolling](#scrolling)). -4. Directed movement reaches `threshold`. -5. Directed movement exceeds cross-axis movement by `axisDominance`. -6. The navigation factory returns an intent. +3. Directed movement from the pointer-down point reaches `threshold`. +4. Directed movement exceeds cross-axis movement by `axisDominance`. +5. The navigation factory returns an intent. + +Recognition is measured from where the pointer went down and keeps watching +until the pointer lifts, so a slightly wobbly or backward start only delays +it. Progress is directed distance divided by the gesture's `distance` (by default the host's width or height). Past either end, the excess reaches @@ -927,24 +944,43 @@ cause: ### Scrolling -Gestures and native scrolling share the same touches. Each recognizer decides -within the first 6px of movement whether a touch is its own: +Native scrolling stays in the browser's hands: gesture hosts never make it +wait for JavaScript. Each host gets a `touch-action` that keeps every pan no +gesture needs: -- **along its axis and direction**: it holds the touch, and the binding's - `onTouchmove` cancels native scrolling while it does; -- **along the other axis, or the opposite way**: it lets go, and the browser - scrolls (or another gesture on the host takes it); -- **diagonal**: it keeps watching until the movement is clear. +| Gestures on the host | `touch-action` | +| --------------------------------------------------------------- | ---------------- | +| Horizontal only | `pan-y` | +| Vertical only | `pan-x` | +| Both axes | `none` | +| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` | +| The same, list scrolled | `manipulation` | -Browsers hold back the first cancelable `touchmove` for roughly 15–20px, so the -decision is made before native scrolling can start. This is why gesture hosts -use `touch-action: manipulation`: the browser may pan either axis, and the -recognizers keep it from doing so only for touches they claim. A vertical -dismiss gesture therefore no longer disables vertical scrolling; combine it -with `.from.scrollEdge()` so it only claims a downward drag at the top. +The browser therefore scrolls the free axis natively from the first frame, +and touches on a gesture's axis reach the recognizer untouched. -Nested scrollers need nothing special. Their `touchmove` events bubble to the -gesture host, which cancels them only while it holds the touch. +A gesture that shares its axis with a scroller, such as drag-down-to-dismiss +above a list, uses `.from.scrollEdge()`. While the list is at that edge, the +host reserves only the one pan the gesture needs: at the top, dragging down is +the gesture's and dragging up still scrolls the list natively. This is the +pull-to-refresh pattern from the Pointer Events specification, and it relies +on direction-specific `touch-action` values (`pan-down` and its siblings). The +host follows the list's scroll position through a passive scroll listener, so +the right value is in place before each touch starts. + +Browsers without direction-specific values (Safari) fall back to deciding in +JavaScript: the host keeps the axis pannable and cancels native scrolling +from a `touchmove` handler for touches the gesture claims, at the browser's +first cancelable `touchmove` (after roughly 15–20px of movement). Blocking +touch handlers make the browser wait for JavaScript on every move, so this +fallback is used only for scroll-edge gestures and only where it is needed. + +Nested scroll containers: browsers apply `touch-action` only up to the nearest +scroll container, so a list scrolling inside a horizontal gesture host would +otherwise let the browser take horizontal drags and cancel the gesture. Each +host publishes its value as the inherited `--nvo-touch-action` custom property, +and the package stylesheet applies it to descendants, so nested lists keep +`pan-y` too. This requires `@native-vue-router/core-v2/style.css`. ### Ignored elements @@ -952,7 +988,9 @@ Pointer-down on `input`, `textarea`, `select`, `option`, or `[contenteditable="true"]` never starts a gesture, so text selection and form controls keep working. Mark anything else that must keep its own drags with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip -row or a slider inside a host that has a horizontal gesture. +row or a slider inside a host that has a horizontal gesture. Ignored subtrees +also get `touch-action: auto` back from the package stylesheet, so a chip row +scrolls horizontally inside a horizontal gesture host. ### Animation performance diff --git a/packages/core-v2/src/components/OriginGesture.vue b/packages/core-v2/src/components/OriginGesture.vue index c7dbcb8..95b3e13 100644 --- a/packages/core-v2/src/components/OriginGesture.vue +++ b/packages/core-v2/src/components/OriginGesture.vue @@ -20,7 +20,8 @@ const gesture = useOriginGesture(() => props.gesture);