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
This commit is contained in:
Claude
2026-10-31 08:22:11 +00:00
parent d4d7feb3b7
commit 6889fc0112
20 changed files with 1547 additions and 163 deletions
+9 -8
View File
@@ -241,16 +241,17 @@ describe("reactive gesture props", () => {
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
const current = shallowRef(leftward);
const { scene, host } = await mount(gestureHost(() => current.value));
expect(host.style.touchAction).toBe("pan-y");
expect(host.style.touchAction).toBe("manipulation");
// Swap mid-gesture: the drag in progress keeps the leftward definition.
host.dispatchEvent(pointer("pointerdown", 180, 0));
current.value = rightward;
await nextTick();
host.dispatchEvent(pointer("pointermove", 120, 100));
host.dispatchEvent(pointer("pointermove", 140, 100));
await flush();
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();
// The next touch uses the rightward definition.
@@ -294,14 +295,14 @@ describe("reactive gesture props", () => {
),
});
const { scene, host } = await mount(Root);
expect(host.style.touchAction).toBe("pan-y");
expect(host.style.touchAction).toBe("manipulation");
list.value = [
...list.value,
gesture.to.down().navigate(toTarget).animate(motion),
];
await nextTick();
expect(host.style.touchAction).toBe("none");
expect(host.style.touchAction).toBe("manipulation");
host.dispatchEvent(
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
@@ -327,9 +328,9 @@ describe("commitVelocity", () => {
choreography: { effects: () => ({}), commitVelocity },
}) as unknown as Parameters<typeof shouldCommit>[0];
it("defaults to 0.3", () => {
expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false);
expect(shouldCommit(operation(undefined, 0.3), {})).toBe(true);
it("defaults to 0.2", () => {
expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true);
});
it("uses the choreography's value when supplied", () => {