Add .onCommit() to the gesture builder

`.onCommit(handler)` is available at every builder stage after the
direction, returns the same stage, and accumulates handlers in order.
Handlers run once, synchronously at release, when the operation commits
and before the settling animation, with the same context `.complete()`
receives. A throwing handler is reported without stopping later handlers
or the operation. Intended for haptic feedback such as
`navigator.vibrate`.

Named onCommit rather than then so builder stages never become
thenables. The builder now threads one frozen draft through its stages.

OriginOperationHandle gains a read-only `intent`, which the recognizer
reads right after finish() to know whether the release committed.

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-16 03:08:12 +00:00
parent 511e58c198
commit 8448e910ed
6 changed files with 255 additions and 43 deletions
+25
View File
@@ -411,6 +411,7 @@ The stages have distinct responsibilities:
| `.to.*` | Required movement direction and intent-recognition options |
| `.complete(predicate)` | Optional release commit/cancel decision |
| `.navigate(factory)` | Required target and retained-history intent |
| `.onCommit(handler)` | Optional side effect when a release commits |
| `.animate(routine)` | Required source/target/frame choreography |
The builder is persistent and immutable. Reusing an earlier stage cannot
@@ -432,6 +433,27 @@ velocity, directed pixel distance, cross-axis distance, duration, pointer-up
event, host, bounds, and start/current points. Completion predicates are
synchronous because they select operation intent at release.
`.onCommit(handler)` is available at every stage after `.to.*`, returns the
same stage, and may be called more than once; handlers run in order. A handler
runs once, synchronously at release, after the operation commits and before
the settling animation, and receives the same context as `.complete()`. It
never runs for a cancelled release. A throwing handler is reported as an
uncaught error without stopping later handlers or the operation. It is the
place for haptic feedback:
```ts
const openProfile = gesture.to
.left()
.navigate(() => forward(originView(ProfileView)))
.onCommit(() => navigator.vibrate?.(10))
.animate(slideLeft);
```
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named
`onCommit` rather than `then` so builder stages never become promise-like
"thenables" when awaited or returned from an async function.
### `useOriginGesture(definition)`
Creates one primary-pointer, single-axis recognizer owned by the component that
@@ -640,6 +662,9 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
### `OriginOperationHandle`
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It
changes synchronously when `finish()` or `cancel()` is called.
| Member | Description |
| ----------------------------- | --------------------------------------------------- |
| `id` | Unique operation ID |
+86 -35
View File
@@ -168,8 +168,22 @@ export interface OriginGestureDefinition {
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
}
/**
* Side effect run when a released gesture commits, such as haptic feedback.
*
* It runs once, synchronously at release, after the commit decision and
* before the settling animation. It receives the same context as a
* `.complete()` predicate. Errors are reported without interrupting the
* operation.
*/
export type OriginGestureCommitHandler = (
context: OriginGestureCompletionContext,
) => void;
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
@@ -214,6 +228,8 @@ export interface OriginGestureDirectedBuilder {
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
@@ -222,12 +238,16 @@ export interface OriginGestureCompletedBuilder {
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
}
/** Root of the immutable fluent gesture-definition API. */
@@ -368,55 +388,86 @@ export interface OriginGestureSurfaceProps {
// Builder ------------------------------------------------------------------
/*
* Immutable fluent builder. Each stage is a frozen object closing over the
* choices made so far; the final `.animate()` produces a frozen definition.
* Immutable fluent builder. Each stage is a frozen object holding the choices
* made so far in a frozen draft; the final `.animate()` produces a frozen
* definition. `.onCommit()` is available at every stage after the direction
* and returns the same stage with the handler appended.
*
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
*/
function navigationStage(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
) {
return (
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder =>
Object.freeze({
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
}),
});
/** Choices accumulated by the builder before `.animate()`. */
interface GestureDraft {
readonly start: OriginGestureStart;
readonly direction: OriginGestureDirection;
readonly recognition: Readonly<OriginGestureDirectionOptions>;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
}
function directedStage(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
): OriginGestureDirectedBuilder {
const recognition = Object.freeze({ ...options });
const withCommitHandler = <Draft extends GestureDraft>(
draft: Draft,
handler: OriginGestureCommitHandler,
): Draft =>
Object.freeze({
...draft,
commitHandlers: Object.freeze([...draft.commitHandlers, handler]),
});
function navigationStage(
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
): OriginGestureNavigationBuilder {
return Object.freeze({
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
Object.freeze({
kind: "origin-gesture-definition",
start: draft.start,
direction: draft.direction,
recognition: draft.recognition,
completion: draft.completion,
navigation: draft.navigation,
choreography,
commitHandlers: draft.commitHandlers,
}),
onCommit: (handler: OriginGestureCommitHandler) =>
navigationStage(withCommitHandler(draft, handler)),
});
}
const navigateFrom =
(draft: GestureDraft) => (navigation: OriginGestureNavigationFactory) =>
navigationStage(Object.freeze({ ...draft, navigation }));
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
return Object.freeze({
navigate: navigateFrom(draft),
onCommit: (handler: OriginGestureCommitHandler) =>
completedStage(withCommitHandler(draft, handler)),
});
}
function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
return Object.freeze({
complete: (completion: OriginGestureCompletionPredicate) =>
Object.freeze({
navigate: navigationStage(start, direction, recognition, completion),
}),
navigate: navigationStage(start, direction, recognition, undefined),
completedStage(Object.freeze({ ...draft, completion })),
navigate: navigateFrom(draft),
onCommit: (handler: OriginGestureCommitHandler) =>
directedStage(withCommitHandler(draft, handler)),
});
}
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
const direct =
(direction: OriginGestureDirection) =>
(options?: OriginGestureDirectionOptions) =>
directedStage(start, direction, options);
(options: OriginGestureDirectionOptions = {}) =>
directedStage(
Object.freeze({
start,
direction,
recognition: Object.freeze({ ...options }),
commitHandlers: Object.freeze([]),
}),
);
return Object.freeze({
left: direct("left"),
right: direct("right"),
+27 -3
View File
@@ -4,6 +4,8 @@ import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, type OriginRect } from "../shared";
import type {
OriginGestureBinding,
OriginGestureCommitHandler,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
@@ -290,6 +292,7 @@ export interface RecognizerPolicy {
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly action: (context: OriginContext) => MaybeOriginAction;
}
@@ -372,6 +375,7 @@ export class GestureRecognizer {
axisDominance:
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
completion: definition.completion,
commitHandlers: definition.commitHandlers ?? [],
// Pair the resolved navigation intent with the definition's animation.
action: async (context) => {
const navigation = await definition.navigation(context);
@@ -402,6 +406,7 @@ export class GestureRecognizer {
threshold: options.threshold ?? DEFAULT_THRESHOLD,
axisDominance: DEFAULT_AXIS_DOMINANCE,
action: options.action,
commitHandlers: [],
};
}
@@ -475,7 +480,7 @@ export class GestureRecognizer {
const { tracker } = session;
tracker.sample(event, true);
const commit = this.policy.completion?.({
const context: OriginGestureCompletionContext = {
origin: session.origin,
direction: this.policy.direction,
progress: tracker.progress,
@@ -488,13 +493,32 @@ export class GestureRecognizer {
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
});
};
const commit = this.policy.completion?.(context);
const { progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null;
if (!handle) return;
handle.update(progress, velocity);
await handle.finish(commit === undefined ? undefined : { commit });
// finish() fixes the outcome synchronously, before the spring settles.
const settled = handle.finish(
commit === undefined ? undefined : { commit },
);
if (handle.intent === "commit") this.notifyCommit(context);
await settled;
}
/** Run commit handlers; a failing handler never interrupts the others. */
private notifyCommit(context: OriginGestureCompletionContext) {
for (const handler of this.policy.commitHandlers) {
try {
handler(context);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
}
async pointerCancel(): Promise<void> {
+94
View File
@@ -175,3 +175,97 @@ describe("haptic feedback", () => {
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
describe(".onCommit()", () => {
it("vibrates once when a release commits, and not when it cancels", async () => {
const vibrate = vi.fn((_ms: number) => true);
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => vibrate(10))
.animate(motion),
);
await swipe(host, 20, []); // 10% progress: cancelled
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, []); // 75% progress: committed
expect(vibrate).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledWith(10);
});
it("is available at every stage after the direction, in order", async () => {
const calls: string[] = [];
const definition = gesture.to
.left()
.onCommit(() => calls.push("directed"))
.complete(({ progress }) => progress > 0.5)
.onCommit(() => calls.push("completed"))
.navigate(() => forward(originView(Target)))
.onCommit((context) => calls.push(`navigation ${context.direction}`))
.animate(motion);
expect(definition.commitHandlers).toHaveLength(3);
expect(Object.isFrozen(definition.commitHandlers)).toBe(true);
const { host } = await mount(definition);
await swipe(host, 150, []);
expect(calls).toEqual(["directed", "completed", "navigation left"]);
});
it("keeps earlier builder stages unchanged", () => {
const base = gesture.to.left().navigate(() => null);
const withHandler = base.onCommit(() => {});
expect(base.animate(motion).commitHandlers).toEqual([]);
expect(withHandler.animate(motion).commitHandlers).toHaveLength(1);
});
it("reports a failing handler without stopping later handlers or the commit", async () => {
const errors: unknown[] = [];
const onError = (error: unknown) => errors.push(error);
process.on("uncaughtException", onError);
try {
const after = vi.fn();
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
throw new Error("no vibration motor");
})
.onCommit(after)
.animate(motion),
);
await swipe(host, 150, []);
expect(after).toHaveBeenCalledTimes(1);
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
expect(errors).toEqual([new Error("no vibration motor")]);
} finally {
process.off("uncaughtException", onError);
}
});
it("fires at release, before the settling animation finishes", async () => {
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
frames.push(cb),
);
const log: string[] = [];
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => log.push("commit"))
.animate(motion),
);
vi.stubGlobal("matchMedia", () => ({ matches: false }));
await swipe(host, 150, log);
expect(log).toEqual(["recognized", "commit", "released"]);
// The spring is still waiting for frames; the operation is settling.
expect(scene.operations.value[0]?.phase).toBe("settling");
expect(frames.length).toBeGreaterThan(0);
});
});
+18 -5
View File
@@ -12,6 +12,7 @@ import {
normalizedEffect,
type OriginEffect,
type OriginEffectSet,
type OriginOperationIntent,
} from "../choreography";
import type { OriginAction, OriginView } from "../navigation";
import {
@@ -225,13 +226,25 @@ export const runSpring = (
/** Imperative controller for one operation, returned by `Scene.begin()`. */
class OperationHandle implements OriginOperationHandle {
readonly id: number;
readonly originKey: string;
readonly targetKey: string;
constructor(
private readonly scene: Scene,
readonly id: number,
readonly originKey: string,
readonly targetKey: string,
/** Kept after the graph drops it, so `intent` stays readable. */
private readonly operation: MutableOriginOperation,
/** The target at creation; later edge rewrites do not change it. */
targetKey: string,
) {
markRaw(this);
this.id = operation.id;
this.originKey = operation.originKey;
this.targetKey = targetKey;
}
get intent(): OriginOperationIntent {
return this.operation.intent;
}
update(progress: number, velocity = 0): void {
@@ -299,14 +312,14 @@ class Scene implements OriginScene {
action,
this.measure(originKey),
);
const { id, targetKey } = operation;
const { targetKey } = operation;
// Let Vue mount the target before measuring it. Gesture recognizers
// buffer pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = this.measure(targetKey);
operation.phase = "interactive";
return new OperationHandle(this, id, originKey, targetKey);
return new OperationHandle(this, operation, targetKey);
}
async perform(originKey: string, action: OriginAction): Promise<boolean> {
+5
View File
@@ -118,6 +118,11 @@ export interface OriginOperationHandle {
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* The operation's selected outcome. It becomes `commit` or `cancel`
* synchronously when `finish()` or `cancel()` is called, before settling.
*/
readonly intent: OriginOperationIntent;
/**
* Set interactive progress and optional velocity.
*