V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements.
This commit is contained in:
248
packages/core-v2/src/gesture.test.ts
Normal file
248
packages/core-v2/src/gesture.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
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 { createOriginScene, originView } from "./scene";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
OriginGestureCompletionContext,
|
||||
} from "./types";
|
||||
|
||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const testMotion = defineOriginChoreography({
|
||||
name: "gesture-test",
|
||||
effects: ({ progress }) => ({
|
||||
source: { opacity: 1 - progress },
|
||||
target: { opacity: progress },
|
||||
}),
|
||||
});
|
||||
|
||||
function component(name: string): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
render: () => h("div", name),
|
||||
});
|
||||
}
|
||||
|
||||
function pointer(
|
||||
type: string,
|
||||
init: Pick<PointerEventInit, "clientX" | "clientY">,
|
||||
) {
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId: 7,
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsyncHandlers() {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("gesture builder", () => {
|
||||
it("installs multiple page-owned definitions on a policy-neutral surface", async () => {
|
||||
const horizontal = gesture.to
|
||||
.left()
|
||||
.navigate(() => null)
|
||||
.animate(testMotion);
|
||||
const vertical = gesture.from
|
||||
.top("12%")
|
||||
.to.down()
|
||||
.navigate(() => null)
|
||||
.animate(testMotion);
|
||||
const Initial = defineComponent({
|
||||
name: "SurfaceInitial",
|
||||
render: () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{
|
||||
as: "section",
|
||||
id: "multi-gesture-surface",
|
||||
gestures: [horizontal, vertical],
|
||||
},
|
||||
() => "surface",
|
||||
),
|
||||
});
|
||||
const scene = createOriginScene({
|
||||
initial: originView(Initial, undefined, { key: "surface-initial" }),
|
||||
});
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
|
||||
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
|
||||
expect(surface.tagName).toBe("SECTION");
|
||||
expect(surface.classList.contains("nvo-gesture")).toBe(true);
|
||||
expect(surface.style.touchAction).toBe("none");
|
||||
expect(surface.style.width).toBe("100%");
|
||||
expect(surface.style.height).toBe("100%");
|
||||
});
|
||||
|
||||
it("keeps builder navigation intents separate from complete actions", () => {
|
||||
const target = originView(component("Target"));
|
||||
|
||||
expect(forward(target)).toEqual({
|
||||
target,
|
||||
placement: "above",
|
||||
history: "push",
|
||||
});
|
||||
expect(back()).toEqual({
|
||||
placement: "under",
|
||||
history: "back",
|
||||
});
|
||||
expect(forward(target, testMotion)).toMatchObject({
|
||||
target,
|
||||
choreography: testMotion,
|
||||
history: "push",
|
||||
});
|
||||
expect(back(testMotion)).toMatchObject({
|
||||
choreography: testMotion,
|
||||
history: "back",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {
|
||||
const definition = gesture.to
|
||||
.right({ threshold: 12 })
|
||||
.navigate(() => back())
|
||||
.animate(testMotion);
|
||||
|
||||
expect(definition).toMatchObject({
|
||||
kind: "origin-gesture-definition",
|
||||
start: { kind: "anywhere" },
|
||||
direction: "right",
|
||||
recognition: { threshold: 12 },
|
||||
choreography: testMotion,
|
||||
});
|
||||
expect(Object.isFrozen(definition)).toBe(true);
|
||||
expect(Object.isFrozen(definition.recognition)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps start predicates independent from movement direction", () => {
|
||||
const predicate = vi.fn(() => true);
|
||||
const complete = vi.fn(() => true);
|
||||
const definition = gesture.from
|
||||
.when(predicate)
|
||||
.to.down({ axisDominance: 1.4 })
|
||||
.complete(complete)
|
||||
.navigate(() => forward(originView(component("Dialog"))))
|
||||
.animate(testMotion);
|
||||
|
||||
expect(definition.start).toEqual({ kind: "when", predicate });
|
||||
expect(definition.direction).toBe("down");
|
||||
expect(definition.recognition.axisDominance).toBe(1.4);
|
||||
expect(definition.completion).toBe(complete);
|
||||
});
|
||||
|
||||
it("recognizes .to.right anywhere and lets .complete override release", async () => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({ matches: true }) as MediaQueryList),
|
||||
);
|
||||
const Target = component("Target");
|
||||
let binding: OriginGestureBinding | undefined;
|
||||
let completion: OriginGestureCompletionContext | undefined;
|
||||
const definition = gesture.to
|
||||
.right()
|
||||
.complete((context) => {
|
||||
completion = context;
|
||||
return false;
|
||||
})
|
||||
.navigate(() => forward(originView(Target, undefined, { key: "target" })))
|
||||
.animate(testMotion);
|
||||
|
||||
const Initial = defineComponent({
|
||||
name: "Initial",
|
||||
setup() {
|
||||
binding = useOriginGesture(definition);
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
id: "gesture-host",
|
||||
style: binding!.style,
|
||||
onPointerdown: binding!.onPointerdown,
|
||||
onPointermove: binding!.onPointermove,
|
||||
onPointerup: binding!.onPointerup,
|
||||
onPointercancel: binding!.onPointercancel,
|
||||
},
|
||||
"Initial",
|
||||
);
|
||||
},
|
||||
});
|
||||
const scene = createOriginScene({
|
||||
initial: originView(Initial, undefined, { key: "initial" }),
|
||||
});
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
|
||||
const host = root.querySelector("#gesture-host") as HTMLElement;
|
||||
Object.defineProperties(host, {
|
||||
clientWidth: { configurable: true, value: 200 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
});
|
||||
host.getBoundingClientRect = () =>
|
||||
({
|
||||
top: 20,
|
||||
left: 100,
|
||||
right: 300,
|
||||
bottom: 420,
|
||||
width: 200,
|
||||
height: 400,
|
||||
x: 100,
|
||||
y: 20,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
// x=250 is nowhere near the left edge. With no `.from`, it is eligible.
|
||||
host.dispatchEvent(pointer("pointerdown", { clientX: 250, clientY: 100 }));
|
||||
host.dispatchEvent(pointer("pointermove", { clientX: 330, clientY: 102 }));
|
||||
await flushAsyncHandlers();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
host.dispatchEvent(pointer("pointerup", { clientX: 350, clientY: 102 }));
|
||||
await flushAsyncHandlers();
|
||||
|
||||
expect(completion).toMatchObject({
|
||||
direction: "right",
|
||||
progress: 0.5,
|
||||
distance: 100,
|
||||
crossDistance: 2,
|
||||
});
|
||||
expect(completion?.start).toMatchObject({
|
||||
clientX: 250,
|
||||
localX: 150,
|
||||
});
|
||||
expect(completion?.current).toMatchObject({
|
||||
clientX: 350,
|
||||
localX: 250,
|
||||
});
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"Initial",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user