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

@@ -305,10 +305,37 @@ again and leaves the current entry active.
`OriginNavigationActionOptions.placement` defaults to `"under"`.
### `replace(target, choreography?, options?)`
Creates a new target while removing the current history entry:
```ts
const confirmOrder = () =>
replace(
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
slideLeft,
);
```
The target inherits the origin's `previousNodeKey`, so a later back skips the
replaced entry. The origin remains mounted while the operation is interactive
or settling and is unmounted only after commit. Cancelling removes the proposed
target and restores the origin without changing history.
Without choreography, `replace(target)` creates an intent suitable for a
gesture builder:
```ts
.navigate(() => replace(originView(OrderConfirmationView)))
.animate(slideLeft)
```
`OriginNavigationActionOptions.placement` defaults to `"above"`.
### `originAction(target, choreography, options?)`
Constructs a complete low-level `OriginAction`. Prefer `forward()` and `back()`
when expressing retained navigation.
Constructs a complete low-level `OriginAction`. Prefer `forward()`, `replace()`,
and `back()` when expressing retained navigation.
```ts
const action = originAction(profile, slideLeft, {
@@ -352,10 +379,11 @@ Omitting choreography returns a navigation intent for a gesture builder.
History is a linked chain of mounted scene nodes.
| Mode | Commit behavior |
| -------- | -------------------------------------------------------------- |
| `"push"` | Park and retain the origin; activate the new target |
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
| Mode | Commit behavior |
| ----------- | -------------------------------------------------------------- |
| `"push"` | Park and retain the origin; activate the new target |
| `"replace"` | Create a new target, inherit prior history, unmount the origin |
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
input. They remain mounted until back pops them or the scene is destroyed.
@@ -476,6 +504,34 @@ const scaleIn = defineOriginChoreography({
The function returns the same object. Its value is type checking and a clear
construction point.
Set `persistAtRest: true` when progress `1` should remain as a connected visual
relationship after a committed push:
```ts
const openPartialDrawer = defineOriginChoreography({
name: "partial-drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: {
transform: `translateX(${progress * 66.6667}%)`,
},
target: {
transform: `translateX(${(progress - 1) * 66.6667}%)`,
},
}),
});
```
This leaves the retained source mounted, visible, and inert instead of parking
it. The target remains the active history entry. Beginning back suspends the
resting relationship so a reciprocal close choreography can take over;
cancelling back restores it exactly.
Connected resting effects are supported only by retained-history push actions.
They are designed for partial drawers, inspectors, and other presentations
where both mounted views remain visible after commit. They do not appear in
`scene.operations`, which reports live interactive/settling edges only.
`effects()` may return:
| Effect | Applied to |
@@ -566,9 +622,9 @@ exists.
### `scene.begin(originKey, action)`
For forward, mounts a new target and waits one Vue tick for measurement. For
back, reveals and measures the retained previous node. It then returns an
`OriginOperationHandle`.
For forward or replace, mounts a new target and waits one Vue tick for
measurement. For back, reveals and measures the retained previous node. It
then returns an `OriginOperationHandle`.
```ts
const handle = await scene.begin(nodeKey, action);
@@ -624,9 +680,9 @@ A choreography, placement, history mode, and—except for back—target recipe.
### `OriginNavigationIntent`
An animation-free target, placement, and history mutation returned by
`forward()`, `back()`, `above()`, or `under()` when choreography is omitted.
Gesture `.animate()` combines it with choreography to create the internal
action.
`forward()`, `replace()`, `back()`, `above()`, or `under()` when choreography
is omitted. Gesture `.animate()` combines it with choreography to create the
internal action.
### Gesture definition types
@@ -688,7 +744,7 @@ Rect values are viewport CSS pixels.
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
history, and incoming edge.
- `OriginSceneNodeState`: `active`, `transitioning`, or `parked`.
- `OriginSceneNodeState`: `active`, `transitioning`, `exposed`, or `parked`.
- `OriginOperation`: read-only live edge state.
- `OriginOperationPhase`: operation lifecycle phase.
- `OriginOperationIntent`: selected operation outcome.
@@ -705,7 +761,8 @@ implementation types. They are exported by the current barrel but marked
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
`OriginScene`.
- An origin can own only one outgoing operation at a time.
- Parked history entries cannot originate operations until back reveals them.
- Parked or exposed retained entries are inert and cannot originate operations.
The active connected target owns interactions until back reveals its source.
- A target can originate its own operation as soon as its incoming operation's
intent becomes commit.
- The included recognizer follows one primary pointer and one axis.

View File

@@ -21,16 +21,20 @@ From the workspace root:
npm run dev:v2
```
Open the printed URL to explore seven physical labs:
Open the printed URL to explore nine physical labs:
- a four-view chain that can keep four nodes and three edges live at once;
- one view with horizontal, vertical, and edge-only declarations;
- programmatic gallery navigation followed by gesture-owned traversal;
- a vertically presented media player with local interactive state;
- a chat that intentionally declares no back gesture;
- a predicate-gated downward gesture that drops a left-edge dialog.
- a predicate-gated downward gesture that drops a left-edge dialog;
- three nested scenes demonstrating cooperative carousels, vertical decks, and
an intentional parent/child gesture conflict.
an intentional parent/child gesture conflict; and
- a checkout flow that replaces Payment Details with Confirmation and proves
that back returns directly to the retained Hub instance; and
- a connected two-thirds drawer that keeps the translated source page visible
in the exposed final third.
The expandable inspector reports mounted Vue instances, active operation
edges, animation progress, and recent lifecycle events. In the chain lab,
@@ -156,6 +160,46 @@ The application chooses whether this is exposed as a left-edge gesture,
toolbar button, keyboard shortcut, Android hardware-back action, or not exposed
at all.
## Replacing the current entry
Use `replace()` for completed one-way flows such as Payment Details →
Confirmation:
```ts
import {
originView,
replace,
slideLeft,
useOrigin,
} from "@native-vue-router/core-v2";
import OrderConfirmationView from "./OrderConfirmationView.vue";
const origin = useOrigin();
function confirmOrder() {
return origin.perform(
replace(
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
slideLeft,
),
);
}
```
The replacement inherits the current entry's mounted predecessor. It does not
retain the entry being replaced, so back from Confirmation skips Payment
Details. The mutation is atomic: a cancelled interactive replacement removes
the proposed Confirmation and restores Payment Details unchanged.
Inside a gesture builder, omit choreography from the intent:
```ts
gesture.to
.left()
.navigate(() => replace(originView(OrderConfirmationView)))
.animate(slideLeft);
```
## Custom choreography
A choreography returns independent effects for its source, target, and their
@@ -195,6 +239,23 @@ Y transform = (X→Y target) × (Y→Z source)
Z transform = (X→Y target) × (Y→Z target)
```
For partial presentations that must keep both views visible after commit, set
`persistAtRest: true` on the opening choreography:
```ts
const openDrawer = defineOriginChoreography({
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
});
```
The retained source becomes visible-but-inert rather than parked. A reciprocal
back choreography closes the target; a cancelled close restores the connected
resting effects and both original Vue instances.
## Why scene nodes are flat
The operation graph is not represented as Vue component ancestry. Every

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,