Increase complexity of test demo

This commit is contained in:
2026-07-31 07:51:50 +00:00
parent 55dad11b25
commit 056f341cd3
16 changed files with 1589 additions and 52 deletions

View File

@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
import OriginScene from "./components/OriginScene.vue";
import { gesture, useOriginGesture } from "./gesture";
import { back, defineOriginChoreography, forward } from "./motion";
import { back, defineOriginChoreography, forward, replace } from "./motion";
import { createOriginScene, originView } from "./scene";
import type {
OriginGestureBinding,
@@ -109,6 +109,11 @@ describe("gesture builder", () => {
placement: "under",
history: "back",
});
expect(replace(target)).toEqual({
target,
placement: "above",
history: "replace",
});
expect(forward(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
@@ -118,6 +123,11 @@ describe("gesture builder", () => {
choreography: testMotion,
history: "back",
});
expect(replace(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
history: "replace",
});
});
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {

View File

@@ -56,7 +56,8 @@ export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward}, `"under"` for {@link back}
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
* for {@link back}
*/
placement?: OriginPlacement;
}
@@ -135,6 +136,50 @@ export function forward(
};
}
/**
* Create a retained-history replacement intent or complete action.
*
* The target inherits the origin's previous mounted entry instead of retaining
* the origin itself. A commit unmounts the origin and activates the target, so
* a later {@link back} skips the replaced entry. A cancellation removes the
* proposed target and restores the origin unchanged.
*
* Omit choreography when declaring `.navigate()` inside a gesture builder;
* provide choreography when passing the result directly to `begin()` or
* `perform()`.
*
* @param target - View recipe created for the replacement.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free replacement intent for use with a gesture builder.
*/
export function replace(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function replace(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function replace(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "replace",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "replace",
};
}
/**
* Create a retained-history back intent or complete action.
*

View File

@@ -14,6 +14,7 @@ import {
back,
defineOriginChoreography,
forward,
replace,
under,
} from "./motion";
import { createOriginScene, originView } from "./scene";
@@ -40,6 +41,23 @@ const layeredMotion = defineOriginChoreography({
}),
});
const partialDrawerOpen = defineOriginChoreography({
name: "test-partial-drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
});
const partialDrawerClose = defineOriginChoreography({
name: "test-partial-drawer-close",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66.6667}%)` },
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
}),
});
describe("origin-relative scene graph", () => {
it("composes X→Y and Y→Z as independent transform layers", async () => {
const x = originView(component("X"), undefined, { key: "x" });
@@ -290,6 +308,215 @@ describe("origin-relative scene graph", () => {
]);
});
it("replaces the current entry and makes back skip the removed instance", async () => {
let paymentUnmounts = 0;
const home = originView(component("Home"), undefined, { key: "home" });
const Payment = defineComponent({
name: "Payment",
setup() {
onUnmounted(() => {
paymentUnmounts += 1;
});
return () => h("div", "Payment");
},
});
const payment = originView(Payment, undefined, { key: "payment" });
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
await nextTick();
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
await openPayment.finish({ commit: true, animate: false });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.finish({ commit: true, animate: false });
await nextTick();
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Confirmation", "active"],
]);
expect(paymentUnmounts).toBe(1);
const confirmationContext = scene.contextFor(confirmPayment.targetKey);
expect(confirmationContext.previous).toBe(home);
expect(confirmationContext.history).toEqual([home]);
const returnHome = await scene.begin(
confirmPayment.targetKey,
back(layeredMotion),
);
expect(returnHome.targetKey).toBe(homeKey);
await returnHome.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => node.view.name)).toEqual(["Home"]);
});
it("restores the current entry when an interactive replace is cancelled", async () => {
const home = originView(component("Home"), undefined, { key: "home" });
const payment = originView(component("Payment"), undefined, {
key: "payment",
});
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
await openPayment.finish({ commit: true, animate: false });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.cancel({ animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Payment", "active"],
]);
expect(scene.contextFor(openPayment.targetKey).history).toEqual([home]);
});
it("can replace an initial root without manufacturing history", async () => {
const scene = createOriginScene({
initial: originView(component("Welcome"), undefined, { key: "welcome" }),
});
const welcomeKey = scene.nodes.value[0]!.key;
const replacement = await scene.begin(
welcomeKey,
replace(
originView(component("Signed in"), undefined, { key: "signed-in" }),
layeredMotion,
),
);
await replacement.finish({ commit: true, animate: false });
expect(scene.roots.value).toEqual([replacement.targetKey]);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"Signed in",
]);
expect(scene.contextFor(replacement.targetKey)).toMatchObject({
canGoBack: false,
history: [],
previous: undefined,
});
});
it("can replace a target while its incoming push is still settling", async () => {
const home = originView(component("Home"), undefined, { key: "home" });
const payment = originView(component("Payment"), undefined, {
key: "payment",
});
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
openPayment.update(0.8, 1);
const paymentSettlement = openPayment.finish({ commit: true });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.finish({ commit: true, animate: false });
await paymentSettlement;
expect(scene.operations.value).toHaveLength(0);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Confirmation", "active"],
]);
expect(scene.contextFor(confirmPayment.targetKey).history).toEqual([home]);
});
it("keeps a partial push connected at rest and restores it after cancelled back", async () => {
const page = originView(component("Page"), undefined, { key: "page" });
const drawer = originView(component("Drawer"), undefined, {
key: "drawer",
});
const scene = createOriginScene({ initial: page });
const pageKey = scene.nodes.value[0]!.key;
const openDrawer = await scene.begin(
pageKey,
forward(drawer, partialDrawerOpen),
);
await openDrawer.finish({ commit: true, animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(scene.roots.value).toEqual([pageKey]);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Page", "exposed"],
["Drawer", "active"],
]);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
expect(scene.styleForNode(pageKey).pointerEvents).toBe("none");
expect(scene.styleForNode(openDrawer.targetKey).transform).toBe(
"translateX(0%)",
);
const closeDrawer = await scene.begin(
openDrawer.targetKey,
back(partialDrawerClose),
);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
await closeDrawer.cancel({ animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(scene.roots.value).toEqual([pageKey]);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Page", "exposed"],
["Drawer", "active"],
]);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
const committedClose = await scene.begin(
openDrawer.targetKey,
back(partialDrawerClose),
);
await committedClose.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
[pageKey, "active"],
]);
expect(scene.roots.value).toEqual([pageKey]);
expect(scene.styleForNode(pageKey).transform).toBe("none");
});
it("hands an immediate back gesture from a settling push to the same instances", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });

View File

@@ -155,6 +155,12 @@ export function createOriginScene(
): OriginScene {
const nodes = shallowReactive(new Map<string, SceneNodeState>());
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
/*
* A close gesture temporarily removes a connected resting edge to avoid a
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
* us restore the exact same edge and component instances.
*/
const suspendedPresentations = new Map<number, MutableOriginOperation>();
const roots = shallowRef<string[]>([]);
const elements = new Map<string, HTMLElement>();
let container: HTMLElement | null = null;
@@ -222,6 +228,10 @@ export function createOriginScene(
switch (action.history ?? "push") {
case "push":
return origin.key;
case "replace":
// Skip the origin in the retained chain. The old entry remains mounted
// until commit so an interactive replacement can still be cancelled.
return origin.previousNodeKey;
case "back":
return origin.previousNodeKey;
}
@@ -275,9 +285,7 @@ export function createOriginScene(
function visualEffects(nodeKey: string) {
const result = inheritedEffects(nodeKey);
const outgoing = [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
const outgoing = visualOutgoingFor(nodeKey);
if (!outgoing) return result;
const effects = effectSetFor(outgoing);
return [
@@ -287,6 +295,12 @@ export function createOriginScene(
];
}
function visualOutgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
}
/**
* Collapse independent effect layers into one host style. Geometry remains
* composable; arbitrary CSS properties use normal local-last precedence.
@@ -350,13 +364,15 @@ export function createOriginScene(
function outgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
(operation) =>
operation.originKey === nodeKey && operation.phase !== "finished",
);
}
function isNodeInteractive(nodeKey: string) {
const node = nodes.get(nodeKey);
if (!node || node.state === "parked") return false;
if (!node || node.state === "parked" || node.state === "exposed")
return false;
// A target being cancelled is already scheduled to disappear. A source
// settling toward commit has ceded new interactions to the retained scene
@@ -407,9 +423,12 @@ export function createOriginScene(
}
function refreshVisibleState(node: SceneNodeState) {
if (node.state === "parked") return;
if (node.state === "parked" || node.state === "exposed") return;
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
node.state =
node.incomingOperationId || outgoingFor(node.key)
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
? "transitioning"
: "active";
}
@@ -437,8 +456,9 @@ export function createOriginScene(
/**
* Finalize a committed operation.
*
* Push keeps the origin mounted and parks it. Back reuses the existing
* previous node and removes only the entry being popped.
* A normal push parks its origin and collapses the temporary visual edge.
* A connected push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*/
function commitOperation(id: number) {
const operation = operations.get(id);
@@ -447,15 +467,28 @@ export function createOriginScene(
const target = nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
target.state = "active";
refreshVisibleState(target);
return true;
}
spliceVisualTarget(origin, target);
operation.phase = "finished";
operations.delete(operation.id);
suspendedPresentations.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// A committed back pops only the current retained history entry.
// Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance.
removeNode(origin.key);
}
@@ -469,11 +502,22 @@ export function createOriginScene(
if (!operation) return;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.entryTargetKey);
const suspendedPresentation = suspendedPresentations.get(id);
operation.phase = "finished";
operations.delete(id);
suspendedPresentations.delete(id);
if (operation.history === "back") {
if (target) {
if (suspendedPresentation && origin && target) {
/*
* Put the retained page back into its former parent/root position, then
* reconnect the settled page→drawer edge. Both nodes keep their VNodes.
*/
spliceVisualTarget(origin, target);
operations.set(suspendedPresentation.id, suspendedPresentation);
origin.incomingOperationId = suspendedPresentation.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
@@ -539,15 +583,15 @@ export function createOriginScene(
) {
const operation = operations.get(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
const threshold = operation.choreography.commitThreshold ?? 0.36;
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
const shouldCommit =
options.commit ??
(operation.progress >= threshold ||
(operation.progress >= 0.00 &&
operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
(operation.progress >= 0.0 && operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
// The outcome is known synchronously at release. The target can therefore
// originate another gesture while this operation is only visually settling.
@@ -568,8 +612,10 @@ export function createOriginScene(
const origin = nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
if (origin.state === "parked")
throw new Error(`Cannot animate from parked origin "${originKey}".`);
if (origin.state === "parked" || origin.state === "exposed")
throw new Error(
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (outgoingFor(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
@@ -577,7 +623,12 @@ export function createOriginScene(
);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
"persistAtRest choreographies require retained-history push navigation.",
);
let target: SceneNodeState;
let suspendedPresentation: MutableOriginOperation | undefined;
if (history === "back") {
const previousKey = origin.previousNodeKey;
@@ -606,7 +657,22 @@ export function createOriginScene(
throw new Error(
"Cannot go back through an undecided forward operation.",
);
commitOperation(incoming.id);
if (incoming.phase !== "finished") commitOperation(incoming.id);
const connected = operations.get(incoming.id);
if (
connected?.phase === "finished" &&
connected.choreography.persistAtRest
) {
/*
* Re-root A→page→drawer as A→drawer before adding drawer→page.
* The reverse choreography starts at the same visual endpoints.
*/
operations.delete(connected.id);
spliceVisualTarget(retainedTarget, origin);
retainedTarget.incomingOperationId = undefined;
suspendedPresentation = connected;
}
}
if (outgoingFor(retainedTarget.key))
@@ -645,6 +711,8 @@ export function createOriginScene(
originRect: elementRect(elements.get(originKey)),
});
operations.set(operation.id, operation);
if (suspendedPresentation)
suspendedPresentations.set(operation.id, suspendedPresentation);
// Let Vue mount the target before measuring it. Gesture composables buffer
// pointer progress while this short preparation step is pending.
@@ -682,7 +750,10 @@ export function createOriginScene(
})) as readonly OriginSceneNode[],
),
operations: computed(
() => [...operations.values()] as readonly OriginOperation[],
() =>
[...operations.values()].filter(
(operation) => operation.phase !== "finished",
) as readonly OriginOperation[],
),
roots,
contextFor,

View File

@@ -48,9 +48,11 @@ export type OriginOperationIntent = "undecided" | "commit" | "cancel";
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "back";
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
@@ -62,9 +64,12 @@ export type OriginPlacement = "above" | "under";
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `exposed`: retained and visually presented behind/beside the active entry,
* but inert.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState = "active" | "transitioning" | "parked";
export type OriginSceneNodeState =
"active" | "transitioning" | "exposed" | "parked";
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
@@ -172,13 +177,27 @@ export interface OriginChoreography {
* @defaultValue `0.9`
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created target. A back action omits this because the
* scene resolves its already-mounted previous entry.
* Recipe for a newly created push or replacement target. A back action omits
* this because the scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
@@ -206,8 +225,8 @@ export interface OriginAction {
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created target. Back navigation omits this because the
* scene resolves the retained previous instance.
* Recipe for a newly created push or replacement target. Back navigation
* omits this because the scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
@@ -234,7 +253,11 @@ export interface OriginSceneNode {
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/** Live operation currently positioning this node as its target. */
/**
* Operation edge positioning this node as its target. This may identify a
* connected resting edge that is intentionally absent from live-operation
* diagnostics.
*/
readonly incomingOperationId?: number;
}
@@ -341,8 +364,8 @@ export interface OriginScene {
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked, or already owns an outgoing
* operation.
* @throws If the origin is missing, parked/exposed and inert, or already owns
* an outgoing operation.
*/
begin(
originKey: string,