Consolidate core-v2 modules and move scene logic onto classes
- Merge the small modules into 12 files: shared, choreography,
navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.
Public exports, types, and behavior are unchanged; the differential
suites against main still pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Where an application can trigger haptic feedback with the current API.
|
||||
*/
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
watch,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
OriginScene,
|
||||
originView,
|
||||
useOriginGesture,
|
||||
type OriginGestureDefinition,
|
||||
} from ".";
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const motion = defineOriginChoreography({
|
||||
name: "haptics-test",
|
||||
effects: ({ progress }) => ({ target: { opacity: progress } }),
|
||||
});
|
||||
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||
|
||||
function pointer(type: string, clientX: number, timeStamp: number) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
clientX,
|
||||
clientY: 100,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
/** Mount a scene whose root installs `definition`, then return its host. */
|
||||
async function mount(definition: OriginGestureDefinition) {
|
||||
// Reduced motion settles instantly, so release outcomes apply at once.
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||
const Root = defineComponent({
|
||||
setup() {
|
||||
const binding = useOriginGesture(definition);
|
||||
return () =>
|
||||
h("div", {
|
||||
id: "host",
|
||||
style: binding.style,
|
||||
onPointerdown: binding.onPointerdown,
|
||||
onPointermove: binding.onPointermove,
|
||||
onPointerup: binding.onPointerup,
|
||||
});
|
||||
},
|
||||
});
|
||||
const scene = createOriginScene({ initial: originView(Root) });
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
const host = root.querySelector("#host") as HTMLElement;
|
||||
Object.defineProperty(host, "clientWidth", { value: 200 });
|
||||
host.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||
return { scene, host };
|
||||
}
|
||||
|
||||
/** Swipe left by `distance` px, slowly enough that velocity cannot commit. */
|
||||
async function swipe(host: HTMLElement, distance: number, log: string[]) {
|
||||
host.dispatchEvent(pointer("pointerdown", 190, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 180, 500));
|
||||
await flush();
|
||||
log.push("recognized");
|
||||
host.dispatchEvent(pointer("pointermove", 190 - distance, 1000));
|
||||
await flush();
|
||||
host.dispatchEvent(pointer("pointerup", 190 - distance, 1000));
|
||||
await flush();
|
||||
log.push("released");
|
||||
}
|
||||
|
||||
describe("haptic feedback", () => {
|
||||
it("can vibrate when a gesture is recognized, from .navigate()", async () => {
|
||||
const log: string[] = [];
|
||||
const vibrate = vi.fn(() => log.push("vibrate") > 0);
|
||||
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
navigator.vibrate?.(10);
|
||||
return forward(originView(Target));
|
||||
})
|
||||
.animate(motion),
|
||||
);
|
||||
// A short swipe that is cancelled on release still vibrated, because
|
||||
// .navigate() runs at recognition, before the outcome is known.
|
||||
await swipe(host, 20, log);
|
||||
expect(log).toEqual(["vibrate", "recognized", "released"]);
|
||||
});
|
||||
|
||||
it("can vibrate only when a release commits, by watching operation intent", async () => {
|
||||
const log: string[] = [];
|
||||
const vibrate = vi.fn(() => log.push("vibrate") > 0);
|
||||
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||
|
||||
const { scene, host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.animate(motion),
|
||||
);
|
||||
// Intent becomes "commit" synchronously at release, before settling.
|
||||
const seen = new Set<number>();
|
||||
watch(
|
||||
() => scene.operations.value.filter((o) => o.intent === "commit"),
|
||||
(committed) => {
|
||||
for (const operation of committed)
|
||||
if (!seen.has(operation.id)) {
|
||||
seen.add(operation.id);
|
||||
navigator.vibrate?.(10);
|
||||
}
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
await swipe(host, 20, log); // 10% progress: cancelled
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
await swipe(host, 150, log); // 75% progress: committed
|
||||
expect(log).toEqual([
|
||||
"recognized",
|
||||
"released",
|
||||
"recognized",
|
||||
"vibrate",
|
||||
"released",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can vibrate on release from a custom .complete() decision", async () => {
|
||||
const vibrate = vi.fn(() => true);
|
||||
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.complete(({ progress }) => {
|
||||
const commit = progress > 0.5;
|
||||
if (commit) navigator.vibrate?.(10);
|
||||
return commit;
|
||||
})
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.animate(motion),
|
||||
);
|
||||
await swipe(host, 20, []);
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
await swipe(host, 150, []);
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user