V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import OriginScene from "./components/OriginScene.vue";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
under,
|
||||
} from "./motion";
|
||||
import { createOriginScene, originView } from "./scene";
|
||||
|
||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function component(name: string): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
render: () => h("div", { "data-test-view": name }, name),
|
||||
});
|
||||
}
|
||||
|
||||
const layeredMotion = defineOriginChoreography({
|
||||
name: "test-layered-motion",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
|
||||
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" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const z = originView(component("Z"), undefined, { key: "z" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
xy.update(0.5);
|
||||
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
|
||||
yz.update(0.25);
|
||||
|
||||
/*
|
||||
* Y inherits the target half of X→Y, then adds its own source half of
|
||||
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
|
||||
*/
|
||||
expect(scene.styleForNode(xy.targetKey).transform).toBe(
|
||||
"translateX(50px) translateX(-25px)",
|
||||
);
|
||||
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toBe(
|
||||
"translateX(50px) translateX(75px)",
|
||||
);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splices completed operations in either order without losing descendants", async () => {
|
||||
const scene = createOriginScene({
|
||||
initial: originView(component("X"), undefined, { key: "x" }),
|
||||
});
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(
|
||||
xKey,
|
||||
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
|
||||
);
|
||||
xy.update(0.8);
|
||||
const yz = await scene.begin(
|
||||
xy.targetKey,
|
||||
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
|
||||
);
|
||||
yz.update(0.6);
|
||||
|
||||
// Completing the newer edge first parks Y while Z replaces it as the
|
||||
// visual target. Y remains mounted as Z's retained previous entry.
|
||||
await yz.finish({ commit: true, animate: false });
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
expect(
|
||||
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
|
||||
).toBe("parked");
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
|
||||
/^translateX\(19\.9.+px\)$/,
|
||||
);
|
||||
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "parked"],
|
||||
["Z", "active"],
|
||||
]);
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
|
||||
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does not remount a target when its incoming operation is collapsed", async () => {
|
||||
let yMounts = 0;
|
||||
const X = component("X");
|
||||
const Y = defineComponent({
|
||||
name: "Y",
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
yMounts += 1;
|
||||
});
|
||||
return () => h("div", "Y");
|
||||
},
|
||||
});
|
||||
const Z = component("Z");
|
||||
const scene = createOriginScene({
|
||||
initial: originView(X, undefined, { key: "x" }),
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
expect(
|
||||
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
|
||||
).toBe("relative");
|
||||
|
||||
const xy = await scene.begin(
|
||||
scene.nodes.value[0]!.key,
|
||||
above(originView(Y, undefined, { key: "y" }), layeredMotion),
|
||||
);
|
||||
const yz = await scene.begin(
|
||||
xy.targetKey,
|
||||
above(originView(Z, undefined, { key: "z" }), layeredMotion),
|
||||
);
|
||||
await nextTick();
|
||||
expect(yMounts).toBe(1);
|
||||
expect(
|
||||
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
|
||||
?.style.position,
|
||||
).toBe("absolute");
|
||||
|
||||
// Y remains the same flat, keyed host while X→Y disappears around it.
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
expect(yMounts).toBe(1);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
|
||||
await yz.cancel({ animate: false });
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses the retained previous node and pops only the current entry on back", async () => {
|
||||
let xMounts = 0;
|
||||
let xUnmounts = 0;
|
||||
let yUnmounts = 0;
|
||||
const X = defineComponent({
|
||||
name: "X",
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
xMounts += 1;
|
||||
});
|
||||
onUnmounted(() => {
|
||||
xUnmounts += 1;
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-scroll": "feed",
|
||||
style: { height: "100px", overflow: "auto" },
|
||||
},
|
||||
h("div", { style: { height: "2000px" } }, "Feed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
const Y = defineComponent({
|
||||
name: "Y",
|
||||
setup() {
|
||||
onUnmounted(() => {
|
||||
yUnmounts += 1;
|
||||
});
|
||||
return () => h("div", "Y");
|
||||
},
|
||||
});
|
||||
const x = originView(X, { message: "original" }, { key: "x" });
|
||||
const y = originView(Y, undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
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 xKey = scene.nodes.value[0]!.key;
|
||||
const originalScroller = host.querySelector(
|
||||
'[data-scroll="feed"]',
|
||||
) as HTMLElement;
|
||||
originalScroller.scrollTop = 842;
|
||||
|
||||
const xy = await scene.begin(xKey, forward(y, layeredMotion));
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
|
||||
const yContext = scene.contextFor(xy.targetKey);
|
||||
expect(yContext.previous).toBe(x);
|
||||
expect(yContext.history).toEqual([x]);
|
||||
expect(xMounts).toBe(1);
|
||||
expect(xUnmounts).toBe(0);
|
||||
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
|
||||
"parked",
|
||||
);
|
||||
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
|
||||
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await nextTick();
|
||||
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
|
||||
await yx.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
|
||||
expect(scene.contextFor(xKey).history).toEqual([]);
|
||||
expect(scene.nodes.value[0]?.state).toBe("active");
|
||||
expect(xMounts).toBe(1);
|
||||
expect(xUnmounts).toBe(0);
|
||||
expect(yUnmounts).toBe(1);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
});
|
||||
|
||||
it("re-parks the retained target when a back operation is cancelled", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
|
||||
const yx = await scene.begin(
|
||||
xy.targetKey,
|
||||
under(x, layeredMotion, { history: "back" }),
|
||||
);
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await yx.cancel({ animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
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" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
xy.update(0.8, 1);
|
||||
|
||||
const forwardSettlement = xy.finish({ commit: true });
|
||||
const yx = await scene.begin(
|
||||
xy.targetKey,
|
||||
under(x, layeredMotion, { history: "back" }),
|
||||
);
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await yx.finish({ commit: true, animate: false });
|
||||
await forwardSettlement;
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
|
||||
[xKey, "active"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user