249 lines
7.2 KiB
TypeScript
249 lines
7.2 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|