Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf2ad692a | ||
|
|
6889fc0112 | ||
|
|
d4d7feb3b7 | ||
|
|
7033814550 | ||
|
|
a46c411ec4 | ||
|
|
1924d4b52b | ||
|
|
b6f782d8af | ||
|
|
46f030630b | ||
|
|
8448e910ed | ||
|
|
511e58c198 | ||
|
|
329271691c | ||
|
|
d8303107fb | ||
|
|
435ac9a899 | ||
|
|
056f341cd3 | ||
|
|
55dad11b25 | ||
|
|
5a514906eb | ||
|
|
6aed7606ad | ||
|
|
bfe364c57d |
@@ -27,3 +27,5 @@ apps/demo/dev-dist
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
docker-compose.yml
|
||||
@@ -4,12 +4,61 @@ Native Vue Router is a gesture-first navigation runtime for Vue 3 and Vue Router
|
||||
|
||||
The repository includes a reusable headless core, a platform-adaptive visual preset, Capacitor and Electron adapters, and one messaging demo delivered as a PWA and through native hosts.
|
||||
|
||||
## Install the routeless core-v2 experiment
|
||||
|
||||
The repository root is an installable facade for
|
||||
`@native-vue-router/core-v2`. npm builds the package from source when it installs
|
||||
the Git dependency:
|
||||
|
||||
```bash
|
||||
npm install git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git
|
||||
```
|
||||
|
||||
Import it by its package name and include the compositor stylesheet:
|
||||
|
||||
```ts
|
||||
import {
|
||||
OriginScene,
|
||||
createOriginScene,
|
||||
gesture,
|
||||
originView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import "@native-vue-router/core-v2/style.css";
|
||||
```
|
||||
|
||||
Pin production applications to a commit or tag:
|
||||
|
||||
```bash
|
||||
npm install "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git#<commit-or-tag>"
|
||||
```
|
||||
|
||||
Core-v2 is routeless and does not depend on Vue Router. Its usage guide and API
|
||||
are in [packages/core-v2/README.md](packages/core-v2/README.md) and
|
||||
[packages/core-v2/API.md](packages/core-v2/API.md).
|
||||
|
||||
### Codex skill
|
||||
|
||||
The Git package also includes
|
||||
[`build-with-native-vue-router-v2`](skills/build-with-native-vue-router-v2/SKILL.md),
|
||||
a Codex skill for creating and integrating routeless core-v2 applications.
|
||||
After installing the dependency, copy the skill into Codex's skill directory:
|
||||
|
||||
```bash
|
||||
mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills"
|
||||
cp -R \
|
||||
node_modules/@native-vue-router/core-v2/skills/build-with-native-vue-router-v2 \
|
||||
"${CODEX_HOME:-$HOME/.codex}/skills/"
|
||||
```
|
||||
|
||||
Restart or reload Codex if necessary, then invoke it explicitly with
|
||||
`$build-with-native-vue-router-v2`.
|
||||
|
||||
## What works
|
||||
|
||||
- Interactive edge pop that can be held indefinitely at any progress.
|
||||
- Ordered horizontal route paging with replace-by-default history.
|
||||
- Component-originated route dragging with a live target route.
|
||||
- Interactive push, adjacent-page sibling slide, modal, sheet, fade, and application-defined presentations.
|
||||
- Interactive push, adjacent-page sibling slide, modal, safe-area-contained content/snap-point sheets with scroll-boundary handoff, fade, and application-defined presentations.
|
||||
- Concurrent `from` and `to` routes using only public Vue Router 5 APIs.
|
||||
- Guarded commits: previews do not alter the URL, and rejected navigation springs back.
|
||||
- Cold-start predictive back through declared parent routes.
|
||||
@@ -127,6 +176,7 @@ No rAF loop or browser performance observer runs before `start()`, and `stop()`
|
||||
|
||||
Design and engineering documentation:
|
||||
|
||||
- [Complete installation and usage guide](usage.md)
|
||||
- [How it works and why the pattern is uncommon](docs/how-it-works.md)
|
||||
- [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md)
|
||||
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function captureTransitions(page: Page) {
|
||||
await page.locator(".nvr-router-view").waitFor({ state: "attached" });
|
||||
await page.evaluate(() => {
|
||||
const state = window as typeof window & {
|
||||
__nvrEvents?: Array<{
|
||||
@@ -260,7 +261,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
|
||||
"page",
|
||||
);
|
||||
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||
timeout: 2_000,
|
||||
timeout: 4_000,
|
||||
});
|
||||
|
||||
const lab = page.getByTestId("runtime-lab-view");
|
||||
@@ -278,7 +279,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
|
||||
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
||||
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||
timeout: 2_000,
|
||||
timeout: 4_000,
|
||||
});
|
||||
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
|
||||
"data-mount-id",
|
||||
@@ -389,6 +390,69 @@ test("manually unloads an inactive route through the public API demo", async ({
|
||||
await expect(page.getByTestId("stories-view")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("exercises Vue built-ins, lifecycle hooks, injection, and scoped route state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/profile");
|
||||
await page.getByRole("link", { name: /Vue compatibility lab/ }).click();
|
||||
await expect(page).toHaveURL(
|
||||
/\/profile\/vue-lab\/alpha\?mode=manual#route-state$/,
|
||||
);
|
||||
|
||||
const lab = page.getByTestId("vue-compatibility-view");
|
||||
const firstInstance = await lab.getAttribute("data-instance-id");
|
||||
await expect(page.getByTestId("compat-param")).toHaveText("alpha");
|
||||
await expect(page.getByTestId("compat-query")).toContainText("manual");
|
||||
await expect(page.getByTestId("compat-hash")).toHaveText("#route-state");
|
||||
await expect(page.getByTestId("options-lifecycle-probe")).toContainText(
|
||||
"/profile/vue-lab/alpha?mode=manual#route-state",
|
||||
);
|
||||
await expect(page.getByTestId("inject-probe-route-tree")).toContainText(
|
||||
"Injected from the demo application root",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Increment A" }).click();
|
||||
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
|
||||
await page.getByTestId("compat-switch-keepalive").click();
|
||||
await expect(page.getByTestId("keep-alive-B")).toBeVisible();
|
||||
await page.getByTestId("compat-switch-keepalive").click();
|
||||
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
|
||||
|
||||
await page.getByTestId("compat-update-probes").click();
|
||||
await expect(page.getByTestId("compat-event-log")).toContainText("onUpdated");
|
||||
await page.getByTestId("compat-toggle-transition").click();
|
||||
await expect(page.getByTestId("compat-transition-card")).toHaveCount(0);
|
||||
await expect(page.getByTestId("compat-event-log")).toContainText(
|
||||
"after-leave",
|
||||
);
|
||||
|
||||
await page.getByTestId("compat-open-teleport").click();
|
||||
const teleport = page.getByTestId("compat-teleport-overlay");
|
||||
await expect(teleport).toBeVisible();
|
||||
await expect(page.getByTestId("inject-probe-teleport")).toContainText(
|
||||
"Injected from the demo application root",
|
||||
);
|
||||
await page.getByRole("button", { name: "Close teleported overlay" }).click();
|
||||
|
||||
await page.getByTestId("compat-reload-suspense").click();
|
||||
await expect(page.getByTestId("compat-suspense-fallback")).toBeVisible();
|
||||
await expect(page.getByTestId("compat-suspense-ready")).toBeVisible({
|
||||
timeout: 2_000,
|
||||
});
|
||||
|
||||
await page.getByTestId("compat-open-away").click();
|
||||
await expect(page.getByTestId("vue-compatibility-away")).toBeVisible();
|
||||
await page.getByTestId("compat-unload-return").click();
|
||||
await expect(page.getByTestId("vue-compatibility-view")).toBeVisible();
|
||||
await expect(page.getByTestId("vue-compatibility-view")).not.toHaveAttribute(
|
||||
"data-instance-id",
|
||||
firstInstance!,
|
||||
);
|
||||
await expect(page.getByTestId("compat-event-log")).toContainText(
|
||||
"onUnmounted",
|
||||
);
|
||||
});
|
||||
|
||||
test("records a navigation frame profile across route changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -421,10 +485,213 @@ test("opens and dismisses the compose sheet", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "New message" }),
|
||||
).toBeVisible();
|
||||
await waitForTransition(page);
|
||||
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
||||
"data-native-sheet-breakpoint",
|
||||
"0.62",
|
||||
);
|
||||
await expect(page.locator('[data-native-role="underlay"]')).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("contains a sheet below the safe top and supports snap and content sizes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("button", { name: "Compose" }).click();
|
||||
await expect(page).toHaveURL(/\/compose$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
const routerView = page.locator(".nvr-router-view");
|
||||
const activeLayer = page.locator('[data-native-role="active"]');
|
||||
const frame = await routerView.boundingBox();
|
||||
const layer = await activeLayer.boundingBox();
|
||||
if (!frame || !layer) throw new Error("Sheet route did not render");
|
||||
expect(layer.y).toBeGreaterThanOrEqual(frame.y + 7);
|
||||
expect(layer.y + layer.height).toBeLessThanOrEqual(
|
||||
frame.y + frame.height + 1,
|
||||
);
|
||||
|
||||
const handle = page.getByRole("slider", {
|
||||
name: "Resize or dismiss sheet",
|
||||
});
|
||||
await handle.press("ArrowUp");
|
||||
await expect(page.getByTestId("sheet-size")).toHaveText("100%");
|
||||
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
||||
"data-native-sheet-breakpoint",
|
||||
"1",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Fit content" }).click();
|
||||
await expect(page.getByTestId("sheet-size")).toHaveText("Auto");
|
||||
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
||||
"data-native-sheet-mode",
|
||||
"content",
|
||||
);
|
||||
});
|
||||
|
||||
test("animates the partial surface immediately and preserves underlay geometry", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
|
||||
|
||||
const opening = await page.evaluate(async () => {
|
||||
for (let frame = 0; frame < 60; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const routerView = document.querySelector<HTMLElement>(
|
||||
".nvr-router-view--interactive",
|
||||
);
|
||||
const surface = document.querySelector<HTMLElement>(
|
||||
'[data-native-role="to"] [data-native-sheet]',
|
||||
);
|
||||
const progress = Number(
|
||||
routerView?.style.getPropertyValue("--native-progress") ?? 0,
|
||||
);
|
||||
if (routerView && surface && progress >= 0.03) {
|
||||
const frameRect = routerView.getBoundingClientRect();
|
||||
const surfaceRect = surface.getBoundingClientRect();
|
||||
return {
|
||||
progress,
|
||||
frameBottom: frameRect.bottom,
|
||||
surfaceTop: surfaceRect.top,
|
||||
surfaceHeight: surfaceRect.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
expect(opening).toBeDefined();
|
||||
expect(opening!.surfaceTop).toBeLessThan(opening!.frameBottom);
|
||||
|
||||
await waitForTransition(page);
|
||||
const settledSurfaceHeight = await page
|
||||
.locator("[data-native-sheet]")
|
||||
.evaluate((element) => element.getBoundingClientRect().height);
|
||||
expect(opening!.surfaceHeight).toBeCloseTo(settledSurfaceHeight, 0);
|
||||
const openUnderlay = await page
|
||||
.locator('[data-native-role="underlay"]')
|
||||
.evaluate((element) => {
|
||||
const matrix = new DOMMatrix(getComputedStyle(element).transform);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { scale: matrix.a, top: rect.top };
|
||||
});
|
||||
expect(openUnderlay.scale).toBeCloseTo(0.96, 2);
|
||||
expect(openUnderlay.top).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("button", { name: "Cancel" }).dispatchEvent("click");
|
||||
const closing = await page.evaluate(async () => {
|
||||
for (let frame = 0; frame < 60; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const routerView = document.querySelector<HTMLElement>(
|
||||
'.nvr-router-view--interactive[data-native-direction="back"]',
|
||||
);
|
||||
const destination = document.querySelector<HTMLElement>(
|
||||
'[data-native-role="to"]',
|
||||
);
|
||||
const progress = Number(
|
||||
routerView?.style.getPropertyValue("--native-progress") ?? 0,
|
||||
);
|
||||
if (routerView && destination && progress >= 0.03) {
|
||||
const matrix = new DOMMatrix(getComputedStyle(destination).transform);
|
||||
return { progress, scale: matrix.a };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
expect(closing).toBeDefined();
|
||||
expect(closing!.scale).toBeGreaterThanOrEqual(0.96);
|
||||
expect(closing!.scale).toBeLessThan(1);
|
||||
await waitForTransition(page);
|
||||
await expect(page).toHaveURL(/\/inbox$/);
|
||||
const restoredScale = await page
|
||||
.locator('[data-native-role="active"]')
|
||||
.evaluate(
|
||||
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
|
||||
);
|
||||
expect(restoredScale).toBeCloseTo(1, 3);
|
||||
|
||||
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
|
||||
await page.waitForFunction(() => {
|
||||
const routerView = document.querySelector<HTMLElement>(
|
||||
'.nvr-router-view--interactive[data-native-presentation="sheet"]',
|
||||
);
|
||||
const surface = document.querySelector<HTMLElement>(
|
||||
'[data-native-role="to"] [data-native-sheet]',
|
||||
);
|
||||
return Boolean(
|
||||
routerView &&
|
||||
surface &&
|
||||
surface.getBoundingClientRect().top <
|
||||
routerView.getBoundingClientRect().bottom,
|
||||
);
|
||||
});
|
||||
await waitForTransition(page);
|
||||
const repeatedUnderlayScale = await page
|
||||
.locator('[data-native-role="underlay"]')
|
||||
.evaluate(
|
||||
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
|
||||
);
|
||||
expect(repeatedUnderlayScale).toBeCloseTo(0.96, 2);
|
||||
});
|
||||
|
||||
test("hands content overscroll to adjacent sheet breakpoints", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("button", { name: "Compose" }).click();
|
||||
await expect(page).toHaveURL(/\/compose$/);
|
||||
await waitForTransition(page);
|
||||
|
||||
const surface = page.locator("[data-native-sheet]");
|
||||
const body = page.locator(".nvr-sheet__body");
|
||||
const box = await body.boundingBox();
|
||||
if (!box) throw new Error("Sheet scroll body did not render");
|
||||
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = Math.min(
|
||||
40,
|
||||
Math.max(0, element.scrollHeight - element.clientHeight - 10),
|
||||
);
|
||||
});
|
||||
await page.mouse.move(box.x + 100, box.y + box.height * 0.5);
|
||||
await page.mouse.wheel(0, 1_000);
|
||||
await page.mouse.wheel(0, 80);
|
||||
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
|
||||
await page.waitForTimeout(280);
|
||||
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await page.mouse.move(box.x + 100, box.y + box.height * 0.7);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 100, box.y + box.height * 0.35, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "1");
|
||||
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
const expandedBox = await body.boundingBox();
|
||||
if (!expandedBox) throw new Error("Expanded sheet body did not render");
|
||||
await page.mouse.move(
|
||||
expandedBox.x + 100,
|
||||
expandedBox.y + expandedBox.height * 0.35,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
expandedBox.x + 100,
|
||||
expandedBox.y + expandedBox.height * 0.55,
|
||||
{ steps: 10 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
|
||||
});
|
||||
|
||||
test("keeps the target live during a held component drag", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
const row = page.locator(".conversation-row").first();
|
||||
@@ -528,13 +795,16 @@ test("always opens compose as a vertical sheet after prior navigation", async ({
|
||||
test("drags a sheet down to dismiss it", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
await page.getByRole("button", { name: "Compose" }).click();
|
||||
const sheet = await page.locator(".sheet-screen").boundingBox();
|
||||
if (!sheet) throw new Error("Sheet did not render");
|
||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
|
||||
await expect(page).toHaveURL(/\/compose$/);
|
||||
await waitForTransition(page);
|
||||
const sheet = await page.locator("[data-native-sheet]").boundingBox();
|
||||
const handle = await page.locator(".nvr-sheet__handle").boundingBox();
|
||||
if (!sheet || !handle) throw new Error("Sheet did not render");
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + 12);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
sheet.x + sheet.width / 2,
|
||||
sheet.y + sheet.height * 0.55,
|
||||
handle.x + handle.width / 2,
|
||||
handle.y + sheet.height * 0.75,
|
||||
{ steps: 14 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { reactive, type InjectionKey, type Ref } from "vue";
|
||||
|
||||
export interface CompatibilityLabEvent {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
hook: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface CompatibilityLabContext {
|
||||
source: string;
|
||||
routeLabel: Readonly<Ref<string>>;
|
||||
}
|
||||
|
||||
export const demoAppValueKey: InjectionKey<string> = Symbol("demo-app-value");
|
||||
export const compatibilityLabContextKey: InjectionKey<CompatibilityLabContext> =
|
||||
Symbol("compatibility-lab-context");
|
||||
|
||||
let eventSequence = 0;
|
||||
const startedAt = performance.now();
|
||||
|
||||
export const compatibilityLab = reactive({
|
||||
events: [] as CompatibilityLabEvent[],
|
||||
});
|
||||
|
||||
export function recordCompatibilityEvent(
|
||||
source: string,
|
||||
hook: string,
|
||||
detail?: string,
|
||||
) {
|
||||
compatibilityLab.events.unshift({
|
||||
id: ++eventSequence,
|
||||
timestamp: `${(performance.now() - startedAt).toFixed(0)} ms`,
|
||||
source,
|
||||
hook,
|
||||
detail,
|
||||
});
|
||||
if (compatibilityLab.events.length > 120) compatibilityLab.events.splice(120);
|
||||
}
|
||||
|
||||
export function clearCompatibilityEvents() {
|
||||
compatibilityLab.events.splice(0);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import {
|
||||
compatibilityLabContextKey,
|
||||
demoAppValueKey,
|
||||
recordCompatibilityEvent,
|
||||
} from "../compatibility-lab";
|
||||
|
||||
const props = defineProps<{ requestId: number }>();
|
||||
const route = useRoute();
|
||||
const appValue = inject(demoAppValueKey, "missing app injection");
|
||||
const labContext = inject(compatibilityLabContextKey);
|
||||
|
||||
recordCompatibilityEvent(
|
||||
"Suspense",
|
||||
"async setup started",
|
||||
`request ${props.requestId}`,
|
||||
);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 750));
|
||||
recordCompatibilityEvent(
|
||||
"Suspense",
|
||||
"async setup resolved",
|
||||
`request ${props.requestId}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="compat-probe compat-probe--resolved"
|
||||
data-testid="compat-suspense-ready"
|
||||
>
|
||||
<span class="compat-probe__badge">Suspense resolved</span>
|
||||
<strong>Async request {{ requestId }} complete</strong>
|
||||
<p>{{ appValue }}</p>
|
||||
<p>{{ labContext?.routeLabel.value }}</p>
|
||||
<p>{{ route.fullPath }}</p>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
} from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { recordCompatibilityEvent } from "../compatibility-lab";
|
||||
|
||||
const props = defineProps<{
|
||||
instanceName: string;
|
||||
revision: number;
|
||||
}>();
|
||||
const route = useRoute();
|
||||
const localCount = ref(0);
|
||||
const record = (hook: string) =>
|
||||
recordCompatibilityEvent(props.instanceName, hook, route.fullPath);
|
||||
|
||||
onBeforeMount(() => record("onBeforeMount"));
|
||||
onMounted(() => record("onMounted"));
|
||||
onBeforeUpdate(() => record("onBeforeUpdate"));
|
||||
onUpdated(() => record("onUpdated"));
|
||||
onBeforeUnmount(() => record("onBeforeUnmount"));
|
||||
onUnmounted(() => record("onUnmounted"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="compat-probe" data-testid="composition-lifecycle-probe">
|
||||
<span class="compat-probe__badge">Composition API</span>
|
||||
<strong>{{ instanceName }}</strong>
|
||||
<p><code>useRoute()</code>: {{ route.fullPath }}</p>
|
||||
<p>Revision {{ revision }} · local count {{ localCount }}</p>
|
||||
<button type="button" @click="localCount += 1">
|
||||
Increment local state
|
||||
</button>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import {
|
||||
compatibilityLabContextKey,
|
||||
demoAppValueKey,
|
||||
} from "../compatibility-lab";
|
||||
|
||||
defineProps<{ location: "route tree" | "teleport" }>();
|
||||
|
||||
const route = useRoute();
|
||||
const appValue = inject(demoAppValueKey, "missing app injection");
|
||||
const labContext = inject(compatibilityLabContextKey);
|
||||
const labValue = computed(
|
||||
() => labContext?.routeLabel.value ?? "missing page injection",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="compat-probe"
|
||||
:data-testid="`inject-probe-${location.replace(' ', '-')}`"
|
||||
>
|
||||
<span class="compat-probe__badge">provide / inject · {{ location }}</span>
|
||||
<strong>{{ appValue }}</strong>
|
||||
<p>Page injection: {{ labValue }}</p>
|
||||
<p>Scoped route: {{ route.fullPath }}</p>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
} from "vue";
|
||||
import { recordCompatibilityEvent } from "../compatibility-lab";
|
||||
|
||||
const props = defineProps<{ name: string }>();
|
||||
const count = ref(0);
|
||||
const record = (hook: string) =>
|
||||
recordCompatibilityEvent(`KeepAlive ${props.name}`, hook);
|
||||
|
||||
onBeforeMount(() => record("onBeforeMount"));
|
||||
onMounted(() => record("onMounted"));
|
||||
onActivated(() => record("onActivated"));
|
||||
onDeactivated(() => record("onDeactivated"));
|
||||
onBeforeUnmount(() => record("onBeforeUnmount"));
|
||||
onUnmounted(() => record("onUnmounted"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="compat-probe compat-probe--keepalive"
|
||||
:data-testid="`keep-alive-${name}`"
|
||||
>
|
||||
<span class="compat-probe__badge">Kept instance {{ name }}</span>
|
||||
<strong>Counter: {{ count }}</strong>
|
||||
<p>Increment, switch instances, then return to verify preserved state.</p>
|
||||
<button type="button" @click="count += 1">Increment {{ name }}</button>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import type { RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import { recordCompatibilityEvent } from "../compatibility-lab";
|
||||
|
||||
export default defineComponent({
|
||||
name: "CompatibilityOptionsProbe",
|
||||
props: {
|
||||
instanceName: { type: String, required: true },
|
||||
revision: { type: Number, required: true },
|
||||
},
|
||||
data: () => ({ localCount: 0 }),
|
||||
beforeCreate() {
|
||||
recordCompatibilityEvent(this.instanceName, "beforeCreate");
|
||||
},
|
||||
created() {
|
||||
recordCompatibilityEvent(this.instanceName, "created", this.routePath());
|
||||
},
|
||||
beforeMount() {
|
||||
recordCompatibilityEvent(this.instanceName, "beforeMount");
|
||||
},
|
||||
mounted() {
|
||||
recordCompatibilityEvent(this.instanceName, "mounted");
|
||||
},
|
||||
beforeUpdate() {
|
||||
recordCompatibilityEvent(this.instanceName, "beforeUpdate");
|
||||
},
|
||||
updated() {
|
||||
recordCompatibilityEvent(this.instanceName, "updated");
|
||||
},
|
||||
beforeUnmount() {
|
||||
recordCompatibilityEvent(this.instanceName, "beforeUnmount");
|
||||
},
|
||||
unmounted() {
|
||||
recordCompatibilityEvent(this.instanceName, "unmounted");
|
||||
},
|
||||
methods: {
|
||||
routePath() {
|
||||
return (this as unknown as { $route: RouteLocationNormalizedLoaded })
|
||||
.$route.fullPath;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="compat-probe" data-testid="options-lifecycle-probe">
|
||||
<span class="compat-probe__badge">Options API</span>
|
||||
<strong>{{ instanceName }}</strong>
|
||||
<p><code>$route</code>: {{ routePath() }}</p>
|
||||
<p>Revision {{ revision }} · local count {{ localCount }}</p>
|
||||
<button type="button" @click="localCount += 1">
|
||||
Increment local state
|
||||
</button>
|
||||
</article>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { createNativeRouter } from "@native-vue-router/core";
|
||||
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
|
||||
import { createElectronRendererAdapter } from "@native-vue-router/electron";
|
||||
import App from "./App.vue";
|
||||
import { demoAppValueKey } from "./compatibility-lab";
|
||||
import { createPwaAdapter } from "./pwa";
|
||||
import { router } from "./router";
|
||||
import "./style.css";
|
||||
@@ -27,6 +28,7 @@ const nativeRouter = createNativeRouter({
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(nativeRouter);
|
||||
app.provide(demoAppValueKey, "Injected from the demo application root");
|
||||
|
||||
await router.isReady();
|
||||
app.mount("#app");
|
||||
|
||||
@@ -104,6 +104,31 @@ const routes: RouteRecordRaw[] = [
|
||||
native: { presentation: "push", parent: "/profile", gesture: "edge" },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/profile/vue-lab/:sample",
|
||||
name: "vue-compatibility",
|
||||
component: () => import("./views/VueCompatibilityView.vue"),
|
||||
meta: {
|
||||
native: { presentation: "push", parent: "/profile", gesture: "edge" },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/profile/vue-lab/:sample/away",
|
||||
name: "vue-compatibility-away",
|
||||
component: () => import("./views/VueCompatibilityAwayView.vue"),
|
||||
meta: {
|
||||
native: {
|
||||
presentation: "push",
|
||||
parent: (route) => ({
|
||||
name: "vue-compatibility",
|
||||
params: { sample: route.params.sample },
|
||||
query: route.query,
|
||||
hash: route.hash,
|
||||
}),
|
||||
gesture: "edge",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
+259
-13
@@ -28,7 +28,8 @@ html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Commenting this out for now. Its causing a black bar at the bottom of the screen on iOS */
|
||||
/* height: 100%; */
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
@@ -65,7 +66,7 @@ html[data-pwa-edge-guard="active"] body {
|
||||
.app-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
max-width: 740px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
@@ -662,18 +663,19 @@ html[data-pwa-edge-guard="active"] body {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.sheet-screen {
|
||||
border-radius: 22px 22px 0 0;
|
||||
.compose-sheet {
|
||||
--nvr-sheet-background: #0b0d12;
|
||||
--nvr-sheet-backdrop: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
.sheet-handle {
|
||||
position: sticky;
|
||||
z-index: 12;
|
||||
top: 8px;
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
margin: 8px auto 0;
|
||||
border-radius: 4px;
|
||||
background: #4e5360;
|
||||
.compose-sheet-content {
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 88% 4%,
|
||||
rgba(124, 92, 255, 0.09),
|
||||
transparent 24%
|
||||
),
|
||||
#0b0d12;
|
||||
}
|
||||
.sheet-header {
|
||||
display: grid;
|
||||
@@ -685,12 +687,23 @@ html[data-pwa-edge-guard="active"] body {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sheet-header small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.sheet-header button {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
color: #9b88ff;
|
||||
background: transparent;
|
||||
}
|
||||
.sheet-header button:last-child {
|
||||
justify-self: end;
|
||||
font-size: 11px;
|
||||
}
|
||||
.compose-search {
|
||||
margin: 0 16px 14px;
|
||||
}
|
||||
@@ -884,6 +897,226 @@ html[data-pwa-edge-guard="active"] body {
|
||||
background: #ff6c76;
|
||||
box-shadow: 0 0 9px #ff6c76;
|
||||
}
|
||||
|
||||
.compatibility-screen {
|
||||
padding-bottom: calc(34px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.compat-header-state {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(255, 194, 92, 0.28);
|
||||
border-radius: 999px;
|
||||
color: #ffc86c;
|
||||
background: rgba(255, 194, 92, 0.08);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.compat-header-state.active {
|
||||
border-color: rgba(61, 217, 170, 0.3);
|
||||
color: #6ce5bf;
|
||||
background: rgba(61, 217, 170, 0.08);
|
||||
}
|
||||
.compat-intro > span {
|
||||
width: 64px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.compat-route-state code {
|
||||
max-width: 52%;
|
||||
overflow-wrap: anywhere;
|
||||
color: #b9adff;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
.compat-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0 16px 18px;
|
||||
}
|
||||
.compat-controls--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.compat-controls button,
|
||||
.compat-inline-actions button,
|
||||
.compat-probe button,
|
||||
.compat-event-heading button {
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(124, 92, 255, 0.25);
|
||||
border-radius: 12px;
|
||||
color: #c7beff;
|
||||
background: rgba(124, 92, 255, 0.1);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.settings-group > .compat-inline-actions {
|
||||
min-height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.compat-inline-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
.settings-group > .compat-probe-grid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.compat-probe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 10px 12px;
|
||||
padding: 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.compat-probe-grid .compat-probe {
|
||||
margin: 0;
|
||||
}
|
||||
.compat-probe__badge {
|
||||
color: #a998ff;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.compat-probe strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.compat-probe p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.compat-probe code {
|
||||
color: #b9adff;
|
||||
}
|
||||
.compat-probe button {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
.compat-probe--keepalive {
|
||||
border-color: rgba(61, 217, 170, 0.16);
|
||||
background: rgba(61, 217, 170, 0.045);
|
||||
}
|
||||
.compat-probe--resolved {
|
||||
border-color: rgba(61, 217, 170, 0.2);
|
||||
}
|
||||
.compat-probe--loading {
|
||||
min-height: 96px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.compat-transition-card {
|
||||
margin: 10px 12px;
|
||||
padding: 22px 14px;
|
||||
border: 1px solid rgba(255, 194, 92, 0.22);
|
||||
border-radius: 15px;
|
||||
color: #ffd28a;
|
||||
background: rgba(255, 194, 92, 0.07);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
}
|
||||
.compat-fade-enter-active,
|
||||
.compat-fade-leave-active {
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
.compat-fade-enter-from,
|
||||
.compat-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
.compat-teleport {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(2, 3, 6, 0.74);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.compat-teleport > section {
|
||||
position: relative;
|
||||
width: min(440px, 100%);
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(169, 152, 255, 0.32);
|
||||
border-radius: 22px;
|
||||
background: #141720;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
.compat-teleport > section > button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 20px;
|
||||
}
|
||||
.compat-teleport h2 {
|
||||
margin: 0 44px 15px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.compat-teleport .compat-probe {
|
||||
margin: 0;
|
||||
}
|
||||
.settings-group > .compat-event-heading {
|
||||
min-height: 66px;
|
||||
}
|
||||
.compat-event-heading button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.compat-event-log {
|
||||
max-height: 360px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
list-style: none;
|
||||
}
|
||||
.compat-event-log li {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 9px 13px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.compat-event-log time,
|
||||
.compat-event-log small {
|
||||
color: #686e7b;
|
||||
font-size: 9px;
|
||||
}
|
||||
.compat-event-log span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.compat-event-log strong {
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.compat-event-log code {
|
||||
color: #afa1ff;
|
||||
font-size: 10px;
|
||||
}
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -926,6 +1159,19 @@ html[data-pwa-edge-guard="active"] body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.compat-controls--three,
|
||||
.settings-group > .compat-probe-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.compat-event-log li {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
.compat-event-log li > small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -1,37 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
import { computed, ref } from "vue";
|
||||
import { NativeSheet, useNativeRouter } from "@native-vue-router/core";
|
||||
import AppAvatar from "../components/AppAvatar.vue";
|
||||
import { useDemoStore } from "../data";
|
||||
|
||||
const native = useNativeRouter()
|
||||
const store = useDemoStore()
|
||||
const query = ref('')
|
||||
const native = useNativeRouter();
|
||||
const store = useDemoStore();
|
||||
const query = ref("");
|
||||
const sheetBreakpoint = ref(0.62);
|
||||
const snapEnabled = ref(true);
|
||||
const sheetBreakpoints = computed(() =>
|
||||
snapEnabled.value ? [0.38, 0.62, 1] : [],
|
||||
);
|
||||
|
||||
async function choose(id: string) {
|
||||
await native.cancelInteractive()
|
||||
await native.replace(`/chat/${id}`, { presentation: 'push' })
|
||||
await native.cancelInteractive();
|
||||
await native.replace(`/chat/${id}`, { presentation: "push" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NativeDismissGesture as="main" class="screen sheet-screen">
|
||||
<div class="sheet-handle" aria-hidden="true" />
|
||||
<NativeSheet
|
||||
v-model="sheetBreakpoint"
|
||||
class="compose-sheet"
|
||||
aria-label="New message"
|
||||
:breakpoints="sheetBreakpoints"
|
||||
:initial-breakpoint="0.62"
|
||||
>
|
||||
<main class="compose-sheet-content">
|
||||
<header class="sheet-header">
|
||||
<button type="button" @click="native.dismiss()">Cancel</button>
|
||||
<div>
|
||||
<h1>New message</h1>
|
||||
<span />
|
||||
<small data-testid="sheet-size">
|
||||
{{ snapEnabled ? `${Math.round(sheetBreakpoint * 100)}%` : "Auto" }}
|
||||
</small>
|
||||
</div>
|
||||
<button type="button" @click="snapEnabled = !snapEnabled">
|
||||
{{ snapEnabled ? "Fit content" : "Use snap points" }}
|
||||
</button>
|
||||
</header>
|
||||
<label class="search-field compose-search">
|
||||
<span>To:</span>
|
||||
<input v-model="query" autofocus placeholder="Search people" />
|
||||
</label>
|
||||
<div class="conversation-list">
|
||||
<button v-for="person in store.people.value.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))" :key="person.id" class="conversation-row" @click="choose(person.id)">
|
||||
<button
|
||||
v-for="person in store.people.value.filter((item) =>
|
||||
item.name.toLowerCase().includes(query.toLowerCase()),
|
||||
)"
|
||||
:key="person.id"
|
||||
class="conversation-row"
|
||||
@click="choose(person.id)"
|
||||
>
|
||||
<AppAvatar :person="person" />
|
||||
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
|
||||
<div class="conversation-copy">
|
||||
<strong>{{ person.name }}</strong>
|
||||
<p>{{ person.handle }}</p>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</NativeDismissGesture>
|
||||
</main>
|
||||
</NativeSheet>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { setStoryEntryBlocked, storyEntryGuard } from '../guard-state'
|
||||
import { NativeLink, useNativeRouter } from "@native-vue-router/core";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
|
||||
|
||||
const native = useNativeRouter()
|
||||
const native = useNativeRouter();
|
||||
|
||||
function toggleStoryGuard() {
|
||||
setStoryEntryBlocked(!storyEntryGuard.blockEntry)
|
||||
setStoryEntryBlocked(!storyEntryGuard.blockEntry);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,13 +24,35 @@ function toggleStoryGuard() {
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-list">
|
||||
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a>
|
||||
<button type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard">
|
||||
<span>⌽</span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : 'Off' }}</i>
|
||||
<a
|
||||
href="/profile/runtime-lab"
|
||||
@click.prevent="native.sibling('/profile/runtime-lab')"
|
||||
><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Block Stories re-entry"
|
||||
:aria-pressed="storyEntryGuard.blockEntry"
|
||||
@click="toggleStoryGuard"
|
||||
>
|
||||
<span>⌽</span><strong>Block cached Stories re-entry</strong
|
||||
><i data-testid="story-guard-status">{{
|
||||
storyEntryGuard.blockEntry ? storyEntryGuard.status : "Off"
|
||||
}}</i>
|
||||
</button>
|
||||
<NativeLink to="/settings"
|
||||
><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink
|
||||
>
|
||||
<NativeLink to="/profile/vue-lab/alpha?mode=manual#route-state"
|
||||
><span>Vue</span><strong>Vue compatibility lab</strong
|
||||
><i>›</i></NativeLink
|
||||
>
|
||||
<a href="https://github.com" target="_blank" rel="noreferrer"
|
||||
><span>⌘</span><strong>Project source</strong><i>↗</i></a
|
||||
>
|
||||
<button type="button">
|
||||
<span>◐</span><strong>Appearance</strong><i>System</i>
|
||||
</button>
|
||||
<NativeLink to="/settings"><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink>
|
||||
<a href="https://github.com" target="_blank" rel="noreferrer"><span>⌘</span><strong>Project source</strong><i>↗</i></a>
|
||||
<button type="button"><span>◐</span><strong>Appearance</strong><i>System</i></button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useNativeRouter } from "@native-vue-router/core";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import {
|
||||
compatibilityLab,
|
||||
recordCompatibilityEvent,
|
||||
} from "../compatibility-lab";
|
||||
|
||||
const route = useRoute();
|
||||
const native = useNativeRouter();
|
||||
const unloadStatus = ref("Lab remains cached");
|
||||
const labLocation = computed(() => ({
|
||||
name: "vue-compatibility",
|
||||
params: { sample: route.params.sample },
|
||||
query: route.query,
|
||||
hash: route.hash,
|
||||
}));
|
||||
|
||||
function unloadLab() {
|
||||
const count = native.unload(labLocation.value);
|
||||
unloadStatus.value = count
|
||||
? "Lab view evicted; Back will create a new instance"
|
||||
: "No matching inactive lab view was mounted";
|
||||
recordCompatibilityEvent("Away screen", "native.unload", `${count} view(s)`);
|
||||
}
|
||||
|
||||
async function unloadAndReturn() {
|
||||
unloadLab();
|
||||
await native.pop();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="screen compatibility-screen"
|
||||
data-testid="vue-compatibility-away"
|
||||
>
|
||||
<AppHeader
|
||||
title="Cached-route checkpoint"
|
||||
subtitle="The compatibility lab is behind this view"
|
||||
back
|
||||
/>
|
||||
|
||||
<section class="lab-intro compat-intro">
|
||||
<span>↩</span>
|
||||
<div>
|
||||
<strong>Inspect native cache behavior</strong>
|
||||
<p>
|
||||
The previous lab instance is inactive but still mounted until you
|
||||
explicitly unload it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Inactive route controls</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>Compatibility lab</strong
|
||||
><small>{{ unloadStatus }}</small></span
|
||||
><b>behind</b>
|
||||
</div>
|
||||
<p>
|
||||
Use normal Back to observe native activate/show without Vue remount
|
||||
hooks. Use “Unload and return” to observe beforeUnmount/unmounted
|
||||
followed by a fresh component instance.
|
||||
</p>
|
||||
</section>
|
||||
<div class="compat-controls">
|
||||
<button type="button" data-testid="compat-unload-lab" @click="unloadLab">
|
||||
Unload cached lab
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-unload-return"
|
||||
@click="unloadAndReturn"
|
||||
>
|
||||
Unload and return
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="settings-group compat-event-section">
|
||||
<div class="compat-event-heading">
|
||||
<span
|
||||
><strong>Shared lifecycle journal</strong
|
||||
><small>Events survive route eviction</small></span
|
||||
>
|
||||
</div>
|
||||
<ol class="compat-event-log" data-testid="compat-away-event-log">
|
||||
<li v-for="event in compatibilityLab.events" :key="event.id">
|
||||
<time>{{ event.timestamp }}</time>
|
||||
<span
|
||||
><strong>{{ event.source }}</strong
|
||||
><code>{{ event.hook }}</code></span
|
||||
>
|
||||
<small>{{ event.detail }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,427 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
} from "vue";
|
||||
import {
|
||||
onNativeViewActivate,
|
||||
onNativeViewDeactivate,
|
||||
onNativeViewEvict,
|
||||
onNativeViewHide,
|
||||
onNativeViewShow,
|
||||
useNativeRouter,
|
||||
useNativeViewLifecycle,
|
||||
} from "@native-vue-router/core";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import CompatibilityAsyncProbe from "../components/CompatibilityAsyncProbe.vue";
|
||||
import CompatibilityCompositionProbe from "../components/CompatibilityCompositionProbe.vue";
|
||||
import CompatibilityInjectProbe from "../components/CompatibilityInjectProbe.vue";
|
||||
import CompatibilityKeepAliveProbe from "../components/CompatibilityKeepAliveProbe.vue";
|
||||
import CompatibilityOptionsProbe from "../components/CompatibilityOptionsProbe.vue";
|
||||
import {
|
||||
clearCompatibilityEvents,
|
||||
compatibilityLab,
|
||||
compatibilityLabContextKey,
|
||||
recordCompatibilityEvent,
|
||||
} from "../compatibility-lab";
|
||||
|
||||
const route = useRoute();
|
||||
const native = useNativeRouter();
|
||||
const nativeLifecycle = useNativeViewLifecycle();
|
||||
const instanceId = Math.random().toString(36).slice(2, 7);
|
||||
const revision = ref(0);
|
||||
const probesMounted = ref(true);
|
||||
const keepAliveVariant = ref<"A" | "B">("A");
|
||||
const transitionVisible = ref(true);
|
||||
const teleportOpen = ref(false);
|
||||
const suspenseRequest = ref(1);
|
||||
const routeLabel = computed(() => route.fullPath);
|
||||
const nativeVisible = nativeLifecycle.isVisible;
|
||||
const nativeActive = nativeLifecycle.isActive;
|
||||
const eventCount = computed(() => compatibilityLab.events.length);
|
||||
|
||||
provide(compatibilityLabContextKey, {
|
||||
source: `Vue compatibility lab ${instanceId}`,
|
||||
routeLabel,
|
||||
});
|
||||
|
||||
const recordPageEvent = (hook: string, detail?: string) =>
|
||||
recordCompatibilityEvent(`Lab page ${instanceId}`, hook, detail);
|
||||
|
||||
onMounted(() => recordPageEvent("onMounted", route.fullPath));
|
||||
onBeforeUnmount(() => recordPageEvent("onBeforeUnmount"));
|
||||
onUnmounted(() => recordPageEvent("onUnmounted"));
|
||||
onNativeViewActivate(() => recordPageEvent("native activate"));
|
||||
onNativeViewDeactivate(() => recordPageEvent("native deactivate"));
|
||||
onNativeViewShow(() => recordPageEvent("native show"));
|
||||
onNativeViewHide(() => recordPageEvent("native hide"));
|
||||
onNativeViewEvict((reason) => recordPageEvent("native evict", String(reason)));
|
||||
|
||||
function updateProbes() {
|
||||
revision.value += 1;
|
||||
recordPageEvent("revision changed", String(revision.value));
|
||||
}
|
||||
|
||||
function toggleProbeMount() {
|
||||
probesMounted.value = !probesMounted.value;
|
||||
recordPageEvent(probesMounted.value ? "probes inserted" : "probes removed");
|
||||
}
|
||||
|
||||
function switchKeptInstance() {
|
||||
keepAliveVariant.value = keepAliveVariant.value === "A" ? "B" : "A";
|
||||
}
|
||||
|
||||
function recordTransition(hook: string) {
|
||||
recordCompatibilityEvent("Transition", hook);
|
||||
}
|
||||
|
||||
function reloadSuspense() {
|
||||
suspenseRequest.value += 1;
|
||||
}
|
||||
|
||||
function pushAlternateRoute() {
|
||||
const sample = route.params.sample === "alpha" ? "beta" : "alpha";
|
||||
void native.push({
|
||||
name: "vue-compatibility",
|
||||
params: { sample },
|
||||
query: { mode: "parameter", revision: revision.value },
|
||||
hash: "#route-state",
|
||||
});
|
||||
}
|
||||
|
||||
function replaceQueryAndHash() {
|
||||
void native.replace(
|
||||
{
|
||||
name: "vue-compatibility",
|
||||
params: { sample: route.params.sample },
|
||||
query: { mode: "replaced", tick: Date.now().toString().slice(-5) },
|
||||
hash: "#event-log",
|
||||
},
|
||||
{ presentation: "fade" },
|
||||
);
|
||||
}
|
||||
|
||||
function openAwayRoute() {
|
||||
void native.push({
|
||||
name: "vue-compatibility-away",
|
||||
params: { sample: route.params.sample },
|
||||
query: route.query,
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="screen compatibility-screen"
|
||||
data-testid="vue-compatibility-view"
|
||||
:data-instance-id="instanceId"
|
||||
>
|
||||
<AppHeader
|
||||
title="Vue compatibility"
|
||||
subtitle="Native component laboratory"
|
||||
back
|
||||
>
|
||||
<span class="compat-header-state" :class="{ active: nativeActive }">{{
|
||||
nativeActive ? "active" : nativeVisible ? "transitioning" : "cached"
|
||||
}}</span>
|
||||
</AppHeader>
|
||||
|
||||
<section class="lab-intro compat-intro">
|
||||
<span>Vue</span>
|
||||
<div>
|
||||
<strong>Exercise real framework behavior</strong>
|
||||
<p>
|
||||
Every control below runs inside a routed, cached NativeRouterView
|
||||
entry.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="route-state"
|
||||
class="settings-group compat-route-state"
|
||||
data-testid="compat-route-state"
|
||||
>
|
||||
<h2>Scoped route state</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>Full path</strong
|
||||
><small>useRoute() and Options API $route</small></span
|
||||
><code data-testid="compat-full-path">{{ route.fullPath }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
><strong>Named route</strong
|
||||
><small>Matched record identity</small></span
|
||||
><code>{{ String(route.name) }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span><strong>Param</strong><small>route.params.sample</small></span
|
||||
><code data-testid="compat-param">{{ route.params.sample }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span><strong>Query</strong><small>route.query</small></span
|
||||
><code data-testid="compat-query">{{
|
||||
JSON.stringify(route.query)
|
||||
}}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span><strong>Hash</strong><small>route.hash</small></span
|
||||
><code data-testid="compat-hash">{{ route.hash || "(empty)" }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span><strong>Matched</strong><small>route.matched</small></span
|
||||
><code>{{
|
||||
route.matched.map((record) => String(record.name)).join(" → ")
|
||||
}}</code>
|
||||
</div>
|
||||
</section>
|
||||
<div class="compat-controls compat-controls--three">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-change-param"
|
||||
@click="pushAlternateRoute"
|
||||
>
|
||||
Push alternate param
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-change-query"
|
||||
@click="replaceQueryAndHash"
|
||||
>
|
||||
Replace query + hash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-open-away"
|
||||
@click="openAwayRoute"
|
||||
>
|
||||
Cache this view
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>provide() / inject()</h2>
|
||||
<CompatibilityInjectProbe location="route tree" />
|
||||
<p>
|
||||
The same probe is rendered inside the Teleport below to verify
|
||||
logical-tree injection and scoped routing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Options and Composition lifecycle</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>Shared revision</strong
|
||||
><small>Changing it triggers beforeUpdate / updated</small></span
|
||||
><b data-testid="compat-revision">{{ revision }}</b>
|
||||
</div>
|
||||
<div class="compat-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-update-probes"
|
||||
@click="updateProbes"
|
||||
>
|
||||
Update props
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-toggle-probes"
|
||||
@click="toggleProbeMount"
|
||||
>
|
||||
{{ probesMounted ? "Unmount probes" : "Mount probes" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="probesMounted" class="compat-probe-grid">
|
||||
<CompatibilityOptionsProbe
|
||||
:instance-name="`Options ${instanceId}`"
|
||||
:revision="revision"
|
||||
/>
|
||||
<CompatibilityCompositionProbe
|
||||
:instance-name="`Composition ${instanceId}`"
|
||||
:revision="revision"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>KeepAlive</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>Current cached child</strong
|
||||
><small>Switch away and back after incrementing</small></span
|
||||
><b>{{ keepAliveVariant }}</b>
|
||||
</div>
|
||||
<div class="compat-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-switch-keepalive"
|
||||
@click="switchKeptInstance"
|
||||
>
|
||||
Switch to {{ keepAliveVariant === "A" ? "B" : "A" }}
|
||||
</button>
|
||||
</div>
|
||||
<KeepAlive>
|
||||
<CompatibilityKeepAliveProbe
|
||||
:key="keepAliveVariant"
|
||||
:name="keepAliveVariant"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Transition</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>CSS transition target</strong
|
||||
><small>Hooks are recorded in the event journal</small></span
|
||||
><b>{{ transitionVisible ? "shown" : "removed" }}</b>
|
||||
</div>
|
||||
<div class="compat-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-toggle-transition"
|
||||
@click="transitionVisible = !transitionVisible"
|
||||
>
|
||||
Toggle transition
|
||||
</button>
|
||||
</div>
|
||||
<Transition
|
||||
name="compat-fade"
|
||||
@before-enter="recordTransition('before-enter')"
|
||||
@after-enter="recordTransition('after-enter')"
|
||||
@before-leave="recordTransition('before-leave')"
|
||||
@after-leave="recordTransition('after-leave')"
|
||||
>
|
||||
<article
|
||||
v-if="transitionVisible"
|
||||
class="compat-transition-card"
|
||||
data-testid="compat-transition-card"
|
||||
>
|
||||
Transition child is mounted
|
||||
</article>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Teleport</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>Body-level overlay</strong
|
||||
><small
|
||||
>Automatically hidden when this native view is inactive</small
|
||||
></span
|
||||
><b>{{ teleportOpen ? "armed" : "closed" }}</b>
|
||||
</div>
|
||||
<div class="compat-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-open-teleport"
|
||||
@click="teleportOpen = true"
|
||||
>
|
||||
Open teleported overlay
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="compat-fade">
|
||||
<div
|
||||
v-if="teleportOpen && nativeVisible"
|
||||
class="compat-teleport"
|
||||
data-testid="compat-teleport-overlay"
|
||||
@click.self="teleportOpen = false"
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="compat-teleport-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close teleported overlay"
|
||||
@click="teleportOpen = false"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="compat-teleport-title">Teleported route content</h2>
|
||||
<CompatibilityInjectProbe location="teleport" />
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Suspense</h2>
|
||||
<div>
|
||||
<span
|
||||
><strong>Async setup request</strong
|
||||
><small>Fallback remains for 750 ms</small></span
|
||||
><b>#{{ suspenseRequest }}</b>
|
||||
</div>
|
||||
<div class="compat-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-reload-suspense"
|
||||
@click="reloadSuspense"
|
||||
>
|
||||
Reload async child
|
||||
</button>
|
||||
</div>
|
||||
<Suspense
|
||||
:key="suspenseRequest"
|
||||
:timeout="0"
|
||||
@pending="recordCompatibilityEvent('Suspense', 'pending')"
|
||||
@fallback="recordCompatibilityEvent('Suspense', 'fallback')"
|
||||
@resolve="recordCompatibilityEvent('Suspense', 'resolve')"
|
||||
>
|
||||
<CompatibilityAsyncProbe :request-id="suspenseRequest" />
|
||||
<template #fallback>
|
||||
<article
|
||||
class="compat-probe compat-probe--loading"
|
||||
data-testid="compat-suspense-fallback"
|
||||
>
|
||||
<span class="lab-spinner" />
|
||||
<div>
|
||||
<strong>Suspense fallback</strong>
|
||||
<p>Waiting for async setup…</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
<section id="event-log" class="settings-group compat-event-section">
|
||||
<div class="compat-event-heading">
|
||||
<span
|
||||
><strong>Lifecycle event journal</strong
|
||||
><small>{{ eventCount }} retained events · newest first</small></span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="compat-clear-events"
|
||||
@click="clearCompatibilityEvents"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<ol class="compat-event-log" data-testid="compat-event-log">
|
||||
<li v-for="event in compatibilityLab.events" :key="event.id">
|
||||
<time>{{ event.timestamp }}</time>
|
||||
<span
|
||||
><strong>{{ event.source }}</strong
|
||||
><code>{{ event.hook }}</code></span
|
||||
>
|
||||
<small>{{ event.detail }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
dev-dist
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#080b12" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="Origins" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<title>Routeless Origins Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@native-vue-router/origins-demo",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --config vite.config.ts --host",
|
||||
"build": "vue-tsc -p tsconfig.json --noEmit && vite build --config vite.config.ts",
|
||||
"preview": "vite preview --config vite.config.ts --host 0.0.0.0 --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@native-vue-router/core-v2": "0.1.0-experimental.0",
|
||||
"vue": "^3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginScene,
|
||||
createOriginScene,
|
||||
originView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import { labEvents } from "./lab-state";
|
||||
import HubView from "./views/HubView.vue";
|
||||
|
||||
/*
|
||||
* This scene is the entire application state for the experiment. There is no
|
||||
* Vue Router installation, route table, URL, or hidden RouterView.
|
||||
*/
|
||||
const scene = createOriginScene({
|
||||
initial: originView(HubView, undefined, {
|
||||
key: "origins-lab",
|
||||
name: "Origins Lab",
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-shell">
|
||||
<OriginScene :scene="scene" />
|
||||
|
||||
<details class="scene-debug">
|
||||
<summary>
|
||||
<span
|
||||
class="debug-pulse"
|
||||
:class="{ live: scene.operations.value.length }"
|
||||
/>
|
||||
{{ scene.nodes.value.length }} node{{
|
||||
scene.nodes.value.length === 1 ? "" : "s"
|
||||
}}
|
||||
</summary>
|
||||
<div class="debug-content" aria-live="polite">
|
||||
<section>
|
||||
<strong>Scene graph</strong>
|
||||
<ol>
|
||||
<li v-for="node in scene.nodes.value" :key="node.key">
|
||||
<span>
|
||||
{{ node.view.name }}
|
||||
<em :class="`node-state node-state--${node.state}`">
|
||||
{{ node.state }}
|
||||
</em>
|
||||
</span>
|
||||
<code>{{ node.key.split("::").at(-1) }}</code>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section v-if="scene.operations.value.length">
|
||||
<strong>Animation edges</strong>
|
||||
<div
|
||||
v-for="operation in scene.operations.value"
|
||||
:key="operation.id"
|
||||
class="debug-operation"
|
||||
>
|
||||
<span>{{ operation.choreography.name }}</span>
|
||||
<progress :value="operation.progress" max="1" />
|
||||
<small>
|
||||
{{ operation.phase }} ·
|
||||
{{ Math.round(operation.progress * 100) }}%
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<strong>Recent Vue lifecycle</strong>
|
||||
<ul class="event-log">
|
||||
<li v-for="event in labEvents.slice(0, 6)" :key="event.id">
|
||||
<time>{{ event.time }}</time>
|
||||
{{ event.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
instance: string;
|
||||
status: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="instance-card">
|
||||
<span>{{ label }}</span>
|
||||
<strong>{{ instance }}</strong>
|
||||
<small>{{ status }}</small>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface DemoPhoto {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
gradient: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const demoPhotos: readonly DemoPhoto[] = [
|
||||
{
|
||||
id: "ember",
|
||||
title: "Afterglow",
|
||||
location: "Reykjanes · 23:14",
|
||||
gradient:
|
||||
"radial-gradient(circle at 72% 25%, #ffcc8a 0, #ff774d 18%, transparent 43%), linear-gradient(145deg, #622c54, #171526 72%)",
|
||||
description:
|
||||
"A warm focus portal demonstrates scale, blur, opacity, and rounded-corner effects.",
|
||||
},
|
||||
{
|
||||
id: "tide",
|
||||
title: "Low tide",
|
||||
location: "Lofoten · 05:42",
|
||||
gradient:
|
||||
"radial-gradient(circle at 25% 25%, #b8f2ff 0, #3f9eb8 24%, transparent 48%), linear-gradient(160deg, #123b50, #07131f 72%)",
|
||||
description:
|
||||
"Swipe left while this view is present to create the next photo as another origin.",
|
||||
},
|
||||
{
|
||||
id: "moss",
|
||||
title: "Quiet giant",
|
||||
location: "Yakushima · 16:08",
|
||||
gradient:
|
||||
"radial-gradient(circle at 64% 30%, #d9ff9f 0, #608b52 22%, transparent 45%), linear-gradient(145deg, #294235, #091812 74%)",
|
||||
description:
|
||||
"The like button is component-local state. Move forward, then return to see the same retained instance and state.",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
|
||||
export interface LabEvent {
|
||||
id: number;
|
||||
message: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
let instanceSequence = 0;
|
||||
let eventSequence = 0;
|
||||
|
||||
export const labEvents = reactive<LabEvent[]>([]);
|
||||
|
||||
export function recordLabEvent(message: string) {
|
||||
labEvents.unshift({
|
||||
id: ++eventSequence,
|
||||
message,
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
});
|
||||
if (labEvents.length > 18) labEvents.length = 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every Vue setup execution a visible identity. A scene-edge collapse
|
||||
* and a forward push both retain the ID. A committed back only unmounts the
|
||||
* entry being popped, then reveals the exact previous instance.
|
||||
*/
|
||||
export function useDemoInstance(label: string) {
|
||||
const instance = `${label.slice(0, 2).toUpperCase()}-${String(
|
||||
++instanceSequence,
|
||||
).padStart(2, "0")}`;
|
||||
const status = ref("created");
|
||||
recordLabEvent(`${instance} created`);
|
||||
|
||||
onMounted(() => {
|
||||
status.value = "mounted";
|
||||
recordLabEvent(`${instance} mounted`);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
status.value = "unmounting";
|
||||
recordLabEvent(`${instance} unmounted`);
|
||||
});
|
||||
|
||||
return { instance, status };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from "vue";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
/*
|
||||
* Registration is deliberately immediate so an installed app becomes
|
||||
* offline-capable during its first session. `autoUpdate` in the Vite plugin
|
||||
* activates a fresh build without requiring a custom update prompt yet.
|
||||
*/
|
||||
registerSW({ immediate: true });
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,376 @@
|
||||
import { defineOriginChoreography } from "@native-vue-router/core-v2";
|
||||
|
||||
const percent = (value: number) => `${(value * 100).toFixed(3)}%`;
|
||||
|
||||
/** A bottom-up cover similar to presenting a native media player. */
|
||||
export const coverUp = defineOriginChoreography({
|
||||
name: "cover-up",
|
||||
commitThreshold: 0.3,
|
||||
commitVelocity: 0.75,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.035)}, 0) scale(${1 - progress * 0.055})`,
|
||||
opacity: 1 - progress * 0.22,
|
||||
style: { filter: `saturate(${1 - progress * 0.3})` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(1 - progress)}, 0)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 30}px`,
|
||||
boxShadow: `0 -24px 80px rgb(0 0 0 / ${progress * 0.38})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse of coverUp. The retained origin instance is revealed underneath. */
|
||||
export const coverDown = defineOriginChoreography({
|
||||
name: "cover-down",
|
||||
commitThreshold: 0.28,
|
||||
commitVelocity: 0.72,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress)}, 0)`,
|
||||
style: { borderRadius: `${progress * 30}px` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(-0.035 + progress * 0.035)}, 0) scale(${0.945 + progress * 0.055})`,
|
||||
opacity: 0.78 + progress * 0.22,
|
||||
style: { filter: `saturate(${0.7 + progress * 0.3})` },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A scale-and-focus transition. Filters use local-last CSS composition while
|
||||
* geometry remains independently composable with any ancestor operation.
|
||||
*/
|
||||
export const focusPortal = defineOriginChoreography({
|
||||
name: "focus-portal",
|
||||
commitThreshold: 0.32,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.12})`,
|
||||
opacity: 1 - progress * 0.55,
|
||||
style: { filter: `blur(${progress * 8}px)` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent((1 - progress) * 0.08)}, 0) scale(${0.72 + progress * 0.28})`,
|
||||
opacity: progress,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 42}px`,
|
||||
filter: `blur(${(1 - progress) * 3}px)`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const unfocusPortal = defineOriginChoreography({
|
||||
name: "unfocus-portal",
|
||||
commitThreshold: 0.3,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * 0.08)}, 0) scale(${1 - progress * 0.28})`,
|
||||
opacity: 1 - progress,
|
||||
style: {
|
||||
borderRadius: `${progress * 42}px`,
|
||||
filter: `blur(${progress * 3}px)`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `scale(${0.88 + progress * 0.12})`,
|
||||
opacity: 0.45 + progress * 0.55,
|
||||
style: { filter: `blur(${(1 - progress) * 8}px)` },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** A deliberately expressive transition for the four-direction gesture lab. */
|
||||
export const cardFlip = defineOriginChoreography({
|
||||
name: "card-flip",
|
||||
commitThreshold: 0.42,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `perspective(900px) rotateY(${-progress * 86}deg) scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.68,
|
||||
style: { transformOrigin: "left center" },
|
||||
},
|
||||
target: {
|
||||
transform: `perspective(900px) rotateY(${(1 - progress) * 86}deg) scale(${0.92 + progress * 0.08})`,
|
||||
opacity: 0.3 + progress * 0.7,
|
||||
style: { transformOrigin: "right center" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The frame contribution affects both sides of the edge. If this operation is
|
||||
* chained, descendants inherit the same small vertical arc.
|
||||
*/
|
||||
export const arcSlide = defineOriginChoreography({
|
||||
name: "arc-slide",
|
||||
commitThreshold: 0.34,
|
||||
commitVelocity: 0.8,
|
||||
effects: ({ progress }) => ({
|
||||
frame: {
|
||||
transform: `translate3d(0, ${-Math.sin(progress * Math.PI) * 9}px, 0)`,
|
||||
},
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * -0.28)}, 0, 0) rotate(${-progress * 1.5}deg) scale(${1 - progress * 0.035})`,
|
||||
opacity: 1 - progress * 0.22,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(1 - progress)}, 0, 0) rotate(${(1 - progress) * 2.5}deg)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const rise = defineOriginChoreography({
|
||||
name: "rise",
|
||||
commitThreshold: 0.33,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.18)}, 0) scale(${1 - progress * 0.04})`,
|
||||
opacity: 1 - progress * 0.3,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(1 - progress)}, 0)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const fall = defineOriginChoreography({
|
||||
name: "fall",
|
||||
commitThreshold: 0.33,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress)}, 0)`,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(-0.18 + progress * 0.18)}, 0) scale(${0.96 + progress * 0.04})`,
|
||||
opacity: 0.7 + progress * 0.3,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected presentation rather than a full-screen route replacement.
|
||||
*
|
||||
* At rest the page has moved right by two thirds, leaving its left third
|
||||
* visible in the exposed rail. `persistAtRest` keeps these progress-1 effects
|
||||
* connected after commit instead of parking and hiding the page.
|
||||
*/
|
||||
export const partialDrawerOpen = defineOriginChoreography({
|
||||
name: "partial-drawer-open",
|
||||
persistAtRest: true,
|
||||
commitThreshold: 0.28,
|
||||
commitVelocity: 0.68,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * (2 / 3))}, 0, 0)`,
|
||||
style: {
|
||||
filter: `brightness(${1 - progress * 0.22})`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent((progress - 1) * (2 / 3))}, 0, 0)`,
|
||||
opacity: 0.35 + progress * 0.65,
|
||||
style: {
|
||||
boxShadow: `24px 0 70px rgb(0 0 0 / ${progress * 0.36})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reciprocal close motion for the connected two-thirds drawer. */
|
||||
export const partialDrawerClose = defineOriginChoreography({
|
||||
name: "partial-drawer-close",
|
||||
commitThreshold: 0.28,
|
||||
commitVelocity: 0.68,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * (-2 / 3))}, 0, 0)`,
|
||||
opacity: 1 - progress * 0.65,
|
||||
style: {
|
||||
boxShadow: `24px 0 70px rgb(0 0 0 / ${(1 - progress) * 0.36})`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent((1 - progress) * (2 / 3))}, 0, 0)`,
|
||||
style: {
|
||||
filter: `brightness(${0.78 + progress * 0.22})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Reveal a full-screen overlay from above while its actual dialog remains
|
||||
* anchored to the left. The gesture's custom start predicate lives in the
|
||||
* originating view; choreography remains concerned only with movement.
|
||||
*/
|
||||
export const dropEdgeDialog = defineOriginChoreography({
|
||||
name: "drop-edge-dialog",
|
||||
commitThreshold: 0.24,
|
||||
commitVelocity: 0.62,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.025})`,
|
||||
opacity: 1 - progress * 0.38,
|
||||
style: {
|
||||
filter: `saturate(${1 - progress * 0.35})`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent((1 - progress) * -0.018)}, 0)`,
|
||||
opacity: 1,
|
||||
style: {
|
||||
clipPath: `inset(0 0 ${percent(1 - progress)} 0)`,
|
||||
filter: `drop-shadow(0 28px 70px rgb(0 0 0 / ${progress * 0.55}))`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse the edge dialog upward and reveal its retained origin instance. */
|
||||
export const liftEdgeDialog = defineOriginChoreography({
|
||||
name: "lift-edge-dialog",
|
||||
commitThreshold: 0.24,
|
||||
commitVelocity: 0.62,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.018)}, 0)`,
|
||||
opacity: 1,
|
||||
style: {
|
||||
clipPath: `inset(0 0 ${percent(progress)} 0)`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `scale(${0.975 + progress * 0.025})`,
|
||||
opacity: 0.62 + progress * 0.38,
|
||||
style: {
|
||||
filter: `saturate(${0.65 + progress * 0.35})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Compact horizontal motion for a nested carousel. Its smaller source travel
|
||||
* keeps the retained slide visible behind the incoming card.
|
||||
*/
|
||||
export const nestedCarouselForward = defineOriginChoreography({
|
||||
name: "nested-carousel-forward",
|
||||
commitThreshold: 0.3,
|
||||
commitVelocity: 0.7,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * -0.18)}, 0, 0) scale(${1 - progress * 0.045})`,
|
||||
opacity: 1 - progress * 0.35,
|
||||
style: { filter: `saturate(${1 - progress * 0.25})` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 28}px`,
|
||||
boxShadow: `-24px 0 60px rgb(0 0 0 / ${progress * 0.3})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse reveal for the cooperative nested carousel. */
|
||||
export const nestedCarouselBack = defineOriginChoreography({
|
||||
name: "nested-carousel-back",
|
||||
commitThreshold: 0.3,
|
||||
commitVelocity: 0.7,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
||||
style: { borderRadius: `${progress * 28}px` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(-0.18 + progress * 0.18)}, 0, 0) scale(${0.955 + progress * 0.045})`,
|
||||
opacity: 0.65 + progress * 0.35,
|
||||
style: { filter: `saturate(${0.75 + progress * 0.25})` },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Vertical nested-deck motion with a shared sideways arc. The frame effect
|
||||
* demonstrates that nested scenes retain the same composition semantics.
|
||||
*/
|
||||
export const nestedDeckForward = defineOriginChoreography({
|
||||
name: "nested-deck-forward",
|
||||
commitThreshold: 0.32,
|
||||
effects: ({ progress }) => ({
|
||||
frame: {
|
||||
transform: `translate3d(${Math.sin(progress * Math.PI) * 10}px, 0, 0)`,
|
||||
},
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.12)}, 0) rotate(${-progress * 2}deg) scale(${1 - progress * 0.06})`,
|
||||
opacity: 1 - progress * 0.4,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(1 - progress)}, 0) rotate(${(1 - progress) * 3}deg)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Downward reverse of the nested vertical deck. */
|
||||
export const nestedDeckBack = defineOriginChoreography({
|
||||
name: "nested-deck-back",
|
||||
commitThreshold: 0.32,
|
||||
effects: ({ progress }) => ({
|
||||
frame: {
|
||||
transform: `translate3d(${-Math.sin(progress * Math.PI) * 10}px, 0, 0)`,
|
||||
},
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress)}, 0) rotate(${progress * 3}deg)`,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(-0.12 + progress * 0.12)}, 0) rotate(${(1 - progress) * -2}deg) scale(${0.94 + progress * 0.06})`,
|
||||
opacity: 0.6 + progress * 0.4,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Intentionally dramatic cube turn used by the conflict case so it is obvious
|
||||
* when the child scene, rather than the page scene, owns the gesture.
|
||||
*/
|
||||
export const nestedCubeForward = defineOriginChoreography({
|
||||
name: "nested-cube-forward",
|
||||
commitThreshold: 0.38,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `perspective(720px) translate3d(${percent(progress * -0.5)}, 0, 0) rotateY(${progress * 72}deg)`,
|
||||
opacity: 1 - progress * 0.55,
|
||||
style: { transformOrigin: "right center" },
|
||||
},
|
||||
target: {
|
||||
transform: `perspective(720px) translate3d(${percent((1 - progress) * 0.5)}, 0, 0) rotateY(${(progress - 1) * 72}deg)`,
|
||||
opacity: 0.45 + progress * 0.55,
|
||||
style: { transformOrigin: "left center" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse cube turn for retained slides in the conflict case. */
|
||||
export const nestedCubeBack = defineOriginChoreography({
|
||||
name: "nested-cube-back",
|
||||
commitThreshold: 0.38,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `perspective(720px) translate3d(${percent(progress * 0.5)}, 0, 0) rotateY(${-progress * 72}deg)`,
|
||||
opacity: 1 - progress * 0.55,
|
||||
style: { transformOrigin: "left center" },
|
||||
},
|
||||
target: {
|
||||
transform: `perspective(720px) translate3d(${percent((progress - 1) * 0.5)}, 0, 0) rotateY(${(1 - progress) * 72}deg)`,
|
||||
opacity: 0.45 + progress * 0.55,
|
||||
style: { transformOrigin: "right center" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from "vue";
|
||||
import { back, slideRight, useOrigin } from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Chat");
|
||||
const draft = ref("");
|
||||
const messages = ref([
|
||||
"This page deliberately owns no edge recognizer.",
|
||||
"Try swiping right. Nothing should happen.",
|
||||
]);
|
||||
|
||||
function goBack() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const message = draft.value.trim();
|
||||
if (!message) return;
|
||||
messages.value.push(message);
|
||||
draft.value = "";
|
||||
await nextTick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
No gesture binding is used here. Gesture absence is the policy, rather
|
||||
than a global back gesture followed by an exception.
|
||||
-->
|
||||
<div class="chat-view">
|
||||
<header class="chat-header">
|
||||
<button type="button" @click="goBack">←</button>
|
||||
<div class="chat-avatar">M</div>
|
||||
<div>
|
||||
<strong>Maya</strong>
|
||||
<span>online</span>
|
||||
</div>
|
||||
<span class="chat-lock">edge locked</span>
|
||||
</header>
|
||||
|
||||
<main class="messages">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="`${index}-${message}`"
|
||||
class="message"
|
||||
:class="{ 'message--mine': index > 1 }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Chat instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<input v-model="draft" placeholder="Write something…" />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { cardFlip, rise, unfocusPortal } from "../motions";
|
||||
import DirectionResultView from "./DirectionResultView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Direction");
|
||||
|
||||
const eastView = () =>
|
||||
originView(
|
||||
DirectionResultView,
|
||||
{
|
||||
direction: "east",
|
||||
heading: "Horizontal declarations can own a full-page flip.",
|
||||
accent: "#38d8ff",
|
||||
createdBy: identity.instance,
|
||||
},
|
||||
{ key: "direction-east", name: "East Result" },
|
||||
);
|
||||
const openEast = () => above(eastView(), cardFlip);
|
||||
|
||||
const northView = () =>
|
||||
originView(
|
||||
DirectionResultView,
|
||||
{
|
||||
direction: "north",
|
||||
heading: "The same origin can create a completely different view upward.",
|
||||
accent: "#b9ff66",
|
||||
createdBy: identity.instance,
|
||||
},
|
||||
{ key: "direction-north", name: "North Result" },
|
||||
);
|
||||
const openNorth = () => above(northView(), rise);
|
||||
|
||||
const flipGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(eastView()))
|
||||
.animate(cardFlip);
|
||||
const riseGesture = gesture.to
|
||||
.up()
|
||||
.navigate(() => above(northView()))
|
||||
.animate(rise);
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
const gestures = [flipGesture, riseGesture, backGesture] as const;
|
||||
|
||||
function perform(action: ReturnType<typeof openEast>) {
|
||||
void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page direction-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="eyebrow">Lab 02 · Direction matrix</div>
|
||||
<h1>One component. Three explicit choices.</h1>
|
||||
<p>
|
||||
Swipe left for a perspective flip, swipe up for a rising cover, or use the
|
||||
left edge to go back. None of these gestures exist outside this view.
|
||||
</p>
|
||||
|
||||
<div class="direction-compass">
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="compass-action compass-action--up"
|
||||
@click="perform(openNorth())"
|
||||
>
|
||||
<span>↑</span>
|
||||
<strong>Rise</strong>
|
||||
<small>target above</small>
|
||||
</button>
|
||||
<div class="compass-core">
|
||||
<span>origin</span>
|
||||
<strong>{{ identity.instance }}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="compass-action compass-action--left"
|
||||
@click="perform(openEast())"
|
||||
>
|
||||
<span>←</span>
|
||||
<strong>Flip left</strong>
|
||||
<small>custom perspective</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Direction view"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<div class="gesture-map">
|
||||
<span>↑ full-surface target</span>
|
||||
<span>← full-surface target</span>
|
||||
<span>left edge → history back</span>
|
||||
</div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { fall } from "../motions";
|
||||
|
||||
const props = defineProps<{
|
||||
direction: "east" | "north";
|
||||
heading: string;
|
||||
accent: string;
|
||||
createdBy?: string;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance(props.direction === "east" ? "East" : "North");
|
||||
const goBack = (context: OriginContext) => {
|
||||
if (!context.canGoBack) return null;
|
||||
return back(props.direction === "north" ? fall : slideRight);
|
||||
};
|
||||
|
||||
const returnGesture =
|
||||
props.direction === "east"
|
||||
? gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight)
|
||||
: gesture.to
|
||||
.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(fall);
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture
|
||||
class="lab-page direction-result"
|
||||
:class="`direction-result--${direction}`"
|
||||
:style="{ '--result-accent': accent }"
|
||||
:gesture="returnGesture"
|
||||
>
|
||||
<div class="result-orbit" />
|
||||
<div class="eyebrow">Resolved from {{ direction }}</div>
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>
|
||||
This component was created by <strong>{{ createdBy }}</strong
|
||||
>. Its own setup has now produced <strong>{{ identity.instance }}</strong
|
||||
>.
|
||||
</p>
|
||||
<InstanceCard
|
||||
label="Result instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
Return to the retained direction instance
|
||||
</button>
|
||||
<div
|
||||
class="gesture-hint"
|
||||
:class="{ 'gesture-hint--back': direction === 'east' }"
|
||||
>
|
||||
<template v-if="direction === 'north'"
|
||||
>Swipe down to return <span>↓</span></template
|
||||
>
|
||||
<template v-else><span>←</span> Swipe from the left edge</template>
|
||||
</div>
|
||||
<div v-if="direction === 'east'" class="edge-marker edge-marker--left">
|
||||
back edge
|
||||
</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { liftEdgeDialog } from "../motions";
|
||||
|
||||
defineProps<{
|
||||
openedBy?: string;
|
||||
predicate?: string;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Dialog");
|
||||
const selected = ref("Motion");
|
||||
|
||||
const dismiss = (context: OriginContext) =>
|
||||
context.canGoBack ? back(liftEdgeDialog) : null;
|
||||
|
||||
const dismissGesture = gesture.to
|
||||
.up()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(liftEdgeDialog);
|
||||
|
||||
function close() {
|
||||
const action = dismiss(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="edge-dialog-overlay" :gesture="dismissGesture">
|
||||
<button
|
||||
class="edge-dialog-backdrop"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Close edge dialog"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="edge-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edge-dialog-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">Custom-origin dialog</div>
|
||||
<h1 id="edge-dialog-title">Dropped from the top.</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Close dialog"
|
||||
@click="close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
The target is a normal scene component. Its content is anchored left,
|
||||
while the operation moves the target frame from top to bottom.
|
||||
</p>
|
||||
|
||||
<dl class="dialog-facts">
|
||||
<div>
|
||||
<dt>Admitted by</dt>
|
||||
<dd>{{ predicate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Origin instance</dt>
|
||||
<dd>{{ openedBy }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Placement</dt>
|
||||
<dd>above</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<fieldset>
|
||||
<legend>Dialog-local state</legend>
|
||||
<button
|
||||
v-for="option in ['Motion', 'Gesture', 'Scene']"
|
||||
:key="option"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:class="{ selected: selected === option }"
|
||||
@click="selected = option"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<InstanceCard
|
||||
label="Dialog instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<strong>Swipe up anywhere on the panel to dismiss.</strong>
|
||||
<small
|
||||
>The reverse operation reveals the retained origin instance.</small
|
||||
>
|
||||
</footer>
|
||||
</section>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { dropEdgeDialog, unfocusPortal } from "../motions";
|
||||
import EdgeDialogView from "./EdgeDialogView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Predicate");
|
||||
|
||||
/*
|
||||
* This is deliberately not the built-in `edge` option. A down gesture's
|
||||
* built-in edge would be the top edge, while this lab wants down movement to
|
||||
* begin at the left edge. The predicate can describe that cross-axis rule—or
|
||||
* any safe area, shape, percentage, or exclusion zone the application needs.
|
||||
*/
|
||||
function dialogEdgeWidth(host: HTMLElement) {
|
||||
return Math.min(72, Math.max(36, host.clientWidth * 0.08));
|
||||
}
|
||||
|
||||
const dialogView = () =>
|
||||
originView(
|
||||
EdgeDialogView,
|
||||
{
|
||||
openedBy: identity.instance,
|
||||
predicate: "x ≤ clamp(36px, 8%, 72px)",
|
||||
},
|
||||
{ key: "edge-dialog", name: "Edge Dialog" },
|
||||
);
|
||||
|
||||
const openDialog = () => above(dialogView(), dropEdgeDialog);
|
||||
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(unfocusPortal) : null;
|
||||
|
||||
const dialogGesture = gesture.from
|
||||
.when(({ point, host }) => point.localX <= dialogEdgeWidth(host))
|
||||
.to.down()
|
||||
.navigate(() => above(dialogView()))
|
||||
.animate(dropEdgeDialog);
|
||||
|
||||
/*
|
||||
* The same physical left edge also supports a conventional rightward back
|
||||
* gesture. Both recognizers see pointer-down; their axis checks decide which
|
||||
* one captures once movement becomes unambiguous.
|
||||
*/
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(36px, 8%, 72px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
|
||||
const gestures = [dialogGesture, backGesture] as const;
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page predicate-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
|
||||
<div class="predicate-copy">
|
||||
<div class="eyebrow">Lab 06 · Predicate edge</div>
|
||||
<h1>Direction and starting edge are independent.</h1>
|
||||
<p>
|
||||
Begin inside the illuminated left rail and drag <strong>down</strong>. A
|
||||
custom predicate admits the pointer; the normal direction recognizer
|
||||
still handles axis capture, progress, velocity, and release.
|
||||
</p>
|
||||
|
||||
<div class="predicate-code">
|
||||
<span>start predicate</span>
|
||||
<code>x ≤ clamp(36px, 8%, 72px)</code>
|
||||
<small>movement: down · built-in edge: intentionally omitted</small>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Predicate origin"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="origin.perform(openDialog())"
|
||||
>
|
||||
Open accessibly without dragging
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="predicate-edge" aria-hidden="true">
|
||||
<span>custom left-edge predicate</span>
|
||||
<div class="predicate-arrow">↓</div>
|
||||
<small>start here · pull down</small>
|
||||
</div>
|
||||
|
||||
<div class="predicate-axis-note">
|
||||
A rightward drag on this same rail still performs edge-back.
|
||||
</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import SecondView from "./SecondView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("X");
|
||||
|
||||
const secondView = () =>
|
||||
originView(
|
||||
SecondView,
|
||||
{
|
||||
openedAt: new Date().toLocaleTimeString(),
|
||||
createdBy: identity.instance,
|
||||
},
|
||||
{ key: "second", name: "Second" },
|
||||
);
|
||||
const createSecond = () => above(secondView(), slideLeft);
|
||||
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
|
||||
const forwardGesture = gesture
|
||||
.to.left()
|
||||
.navigate(() => above(secondView()))
|
||||
.animate(slideLeft);
|
||||
const backGesture = gesture
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page chain-view view-one"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
<div class="eyebrow">Chain stress test · Origin X</div>
|
||||
<h1>Every component starts its own movement.</h1>
|
||||
<p>
|
||||
Drag this screen left. Release it, then immediately drag the blue screen
|
||||
while this spring is still settling. Keep going to build a four-node
|
||||
scene.
|
||||
</p>
|
||||
<InstanceCard
|
||||
label="X Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
data-origin-gesture="ignore"
|
||||
type="button"
|
||||
@click="origin.perform(createSecond())"
|
||||
>
|
||||
Open Y without a gesture
|
||||
</button>
|
||||
<div class="gesture-hint">Swipe left <span>→</span></div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
|
||||
defineProps<{ chain?: string }>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Omega");
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page chain-view view-four" :gesture="backGesture">
|
||||
<div class="eyebrow">Chain stress test · Origin Ω</div>
|
||||
<h1>Four independently mounted components. Three live edges.</h1>
|
||||
<p>
|
||||
If you moved quickly enough, the inspector showed X, Y, Z, and Ω at once.
|
||||
Each component stayed a flat sibling while inherited transforms composed
|
||||
mathematically.
|
||||
</p>
|
||||
<div class="proof">
|
||||
Reported ancestry <strong>{{ chain }}</strong>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Ω Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
Return to the retained Z instance
|
||||
</button>
|
||||
<div class="gesture-hint gesture-hint--back">
|
||||
<span>←</span> Or swipe from the left edge
|
||||
</div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
originView,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { demoPhotos, type DemoPhoto } from "../gallery-data";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { focusPortal, unfocusPortal } from "../motions";
|
||||
import PhotoDetailView from "./PhotoDetailView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Gallery");
|
||||
|
||||
const photoAction = (photo: DemoPhoto, index: number) =>
|
||||
above(
|
||||
originView(
|
||||
PhotoDetailView,
|
||||
{ photo, index },
|
||||
{ key: `photo-${photo.id}`, name: photo.title },
|
||||
),
|
||||
focusPortal,
|
||||
);
|
||||
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(unfocusPortal) : null;
|
||||
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page gallery-view" :gesture="backGesture">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
<div class="eyebrow">Lab 03 · Focus gallery</div>
|
||||
<h1>Events can originate motion without being gestures.</h1>
|
||||
<p>
|
||||
Select a card to run the same scene primitive programmatically. Once
|
||||
inside, the destination declares its own traversal gestures.
|
||||
</p>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Gallery instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="photo-grid">
|
||||
<button
|
||||
v-for="(photo, index) in demoPhotos"
|
||||
:key="photo.id"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="photo-card"
|
||||
:style="{ background: photo.gradient }"
|
||||
@click="origin.perform(photoAction(photo, index))"
|
||||
>
|
||||
<span>{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<strong>{{ photo.title }}</strong>
|
||||
<small>{{ photo.location }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
above,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
useOrigin,
|
||||
type OriginChoreography,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { coverUp, focusPortal } from "../motions";
|
||||
import ChatView from "./ChatView.vue";
|
||||
import DirectionLabView from "./DirectionLabView.vue";
|
||||
import EdgePredicateView from "./EdgePredicateView.vue";
|
||||
import FirstView from "./FirstView.vue";
|
||||
import GalleryView from "./GalleryView.vue";
|
||||
import NestedScenesView from "./NestedScenesView.vue";
|
||||
import PaymentDetailsView from "./PaymentDetailsView.vue";
|
||||
import PartialDrawerPageView from "./PartialDrawerPageView.vue";
|
||||
import PlayerView from "./PlayerView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Hub");
|
||||
|
||||
function viewFor(component: Parameters<typeof originView>[0], name: string) {
|
||||
return originView(component, undefined, {
|
||||
key: name.toLowerCase().replaceAll(" ", "-"),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
function actionFor(
|
||||
component: Parameters<typeof originView>[0],
|
||||
name: string,
|
||||
choreography: OriginChoreography,
|
||||
) {
|
||||
return above(viewFor(component, name), choreography);
|
||||
}
|
||||
|
||||
/*
|
||||
* No `.from` is declared: every eligible pointer-down on the Hub can become
|
||||
* this leftward gesture once movement passes the direction lock.
|
||||
*/
|
||||
const enterChainGesture = gesture.to
|
||||
.left()
|
||||
// .complete(
|
||||
// ({ progress, velocity }) =>
|
||||
// progress >= 0.3 || (progress >= 0.05 && velocity >= 0.8),
|
||||
// )
|
||||
.navigate(() => above(viewFor(FirstView, "Chain X")))
|
||||
.animate(slideLeft);
|
||||
|
||||
const openChain = () => actionFor(FirstView, "Chain X", slideLeft);
|
||||
const openDirections = () =>
|
||||
actionFor(DirectionLabView, "Direction Lab", focusPortal);
|
||||
const openGallery = () => actionFor(GalleryView, "Gallery", focusPortal);
|
||||
const openPlayer = () => actionFor(PlayerView, "Player", coverUp);
|
||||
const openChat = () => actionFor(ChatView, "Locked Chat", slideLeft);
|
||||
const openPredicate = () =>
|
||||
actionFor(EdgePredicateView, "Predicate Edge", focusPortal);
|
||||
const openNestedScenes = () =>
|
||||
actionFor(NestedScenesView, "Nested Scenes", focusPortal);
|
||||
const openCheckout = () =>
|
||||
actionFor(PaymentDetailsView, "Payment Details", slideLeft);
|
||||
const openPartialDrawer = () =>
|
||||
actionFor(PartialDrawerPageView, "Partial Drawer", focusPortal);
|
||||
|
||||
const labs = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Concurrent chain",
|
||||
description: "Swipe through X→Y→Z→Ω before the preceding springs settle.",
|
||||
tags: ["4 live nodes", "frame composition"],
|
||||
action: openChain,
|
||||
accent: "violet",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Direction matrix",
|
||||
description:
|
||||
"One component declares horizontal, vertical, and edge-only behavior.",
|
||||
tags: ["multi-axis", "custom flip"],
|
||||
action: openDirections,
|
||||
accent: "cyan",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Focus gallery",
|
||||
description:
|
||||
"Programmatic card selection and gesture-driven photo traversal.",
|
||||
tags: ["portal", "local state"],
|
||||
action: openGallery,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Native player",
|
||||
description:
|
||||
"A vertical cover with media state and intentionally no back edge.",
|
||||
tags: ["vertical dismiss", "gesture policy"],
|
||||
action: openPlayer,
|
||||
accent: "green",
|
||||
},
|
||||
{
|
||||
number: "05",
|
||||
title: "Locked chat",
|
||||
description:
|
||||
"A Snapchat-style page that exposes only an explicit back button.",
|
||||
tags: ["blocked edge", "form state"],
|
||||
action: openChat,
|
||||
accent: "rose",
|
||||
},
|
||||
{
|
||||
number: "06",
|
||||
title: "Predicate edge",
|
||||
description: "Swipe down from a dynamic left-edge region to drop a dialog.",
|
||||
tags: ["custom hit test", "axis arbitration"],
|
||||
action: openPredicate,
|
||||
accent: "lime",
|
||||
},
|
||||
{
|
||||
number: "07",
|
||||
title: "Nested scenes",
|
||||
description:
|
||||
"Carousels and decks with local history—including deliberate gesture conflicts.",
|
||||
tags: ["carousel", "nested ownership"],
|
||||
action: openNestedScenes,
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
number: "08",
|
||||
title: "Replace checkout",
|
||||
description:
|
||||
"Confirm a mock payment, replace its mounted entry, then test where back returns.",
|
||||
tags: ["atomic replace", "history rewrite"],
|
||||
action: openCheckout,
|
||||
accent: "teal",
|
||||
},
|
||||
{
|
||||
number: "09",
|
||||
title: "Partial drawer",
|
||||
description:
|
||||
"Push a connected drawer two-thirds across while the live source occupies the final third.",
|
||||
tags: ["persistent effects", "partial view"],
|
||||
action: openPartialDrawer,
|
||||
accent: "orange",
|
||||
},
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page hub-view" :gesture="enterChainGesture">
|
||||
<keep-alive>
|
||||
<header class="hub-header">
|
||||
<div>
|
||||
<div class="eyebrow">Routeless origins · interactive field guide</div>
|
||||
<h1>A navigation engine made of component-owned movement.</h1>
|
||||
<p>
|
||||
No route table decides what this screen can do. Every lab below
|
||||
creates its target, chooses its choreography, and declares its own
|
||||
gesture policy.
|
||||
</p>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Hub instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main class="lab-grid">
|
||||
<button
|
||||
v-for="lab in labs"
|
||||
:key="lab.number"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="lab-card"
|
||||
:class="`lab-card--${lab.accent}`"
|
||||
@click="origin.perform(lab.action())"
|
||||
>
|
||||
<span class="lab-number">{{ lab.number }}</span>
|
||||
<span class="lab-copy">
|
||||
<strong>{{ lab.title }}</strong>
|
||||
<small>{{ lab.description }}</small>
|
||||
</span>
|
||||
<span class="tag-row">
|
||||
<em v-for="tag in lab.tags" :key="tag">{{ tag }}</em>
|
||||
</span>
|
||||
<span class="lab-arrow">↗</span>
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<footer class="hub-footer">
|
||||
<span>Tip: swipe anywhere left to enter the chain stress test.</span>
|
||||
<span>The inspector in the corner exposes live nodes and edges.</span>
|
||||
</footer>
|
||||
</keep-alive>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
back,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
useOrigin,
|
||||
type OriginChoreography,
|
||||
type OriginGestureDirectedBuilder,
|
||||
type OriginView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import {
|
||||
nestedCarouselBack,
|
||||
nestedCarouselForward,
|
||||
nestedCubeBack,
|
||||
nestedCubeForward,
|
||||
nestedDeckBack,
|
||||
nestedDeckForward,
|
||||
} from "../motions";
|
||||
|
||||
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
|
||||
|
||||
const props = defineProps<{
|
||||
kind: NestedSceneKind;
|
||||
index: number;
|
||||
total: number;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
next?: () => OriginView;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance(`Nested ${props.kind} ${props.index + 1}`);
|
||||
const localLikes = ref(0);
|
||||
|
||||
const vertical = props.kind === "vertical";
|
||||
const forwardMotion: OriginChoreography = vertical
|
||||
? nestedDeckForward
|
||||
: props.kind === "conflict"
|
||||
? nestedCubeForward
|
||||
: nestedCarouselForward;
|
||||
const backMotion: OriginChoreography = vertical
|
||||
? nestedDeckBack
|
||||
: props.kind === "conflict"
|
||||
? nestedCubeBack
|
||||
: nestedCarouselBack;
|
||||
|
||||
/*
|
||||
* Cooperative cases deliberately leave the outer page's first 44 pixels
|
||||
* untouched. The conflict case starts anywhere, so its pointer-down handler
|
||||
* stops propagation before it knows whether `back()` is available.
|
||||
*/
|
||||
function movementBuilder(
|
||||
direction: "forward" | "back",
|
||||
): OriginGestureDirectedBuilder {
|
||||
const to = vertical
|
||||
? direction === "forward"
|
||||
? "up"
|
||||
: "down"
|
||||
: direction === "forward"
|
||||
? "left"
|
||||
: "right";
|
||||
const start =
|
||||
props.kind === "conflict"
|
||||
? gesture.to
|
||||
: gesture.from.when(({ point, bounds }) => {
|
||||
const parentRail = Math.min(52, Math.max(22, bounds.width * 0.12));
|
||||
return point.localX > parentRail;
|
||||
}).to;
|
||||
return start[to]();
|
||||
}
|
||||
|
||||
const forwardGesture = movementBuilder("forward")
|
||||
.navigate(() => {
|
||||
const target = props.next?.();
|
||||
return target ? forward(target) : null;
|
||||
})
|
||||
.animate(forwardMotion);
|
||||
|
||||
const backGesture = movementBuilder("back")
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(backMotion);
|
||||
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
|
||||
function nextWithButton() {
|
||||
const target = props.next?.();
|
||||
if (target) void origin.perform(forward(target, forwardMotion));
|
||||
}
|
||||
|
||||
function backWithButton() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(backMotion));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="article"
|
||||
class="nested-slide"
|
||||
:class="`nested-slide--${kind}`"
|
||||
:data-nested-kind="kind"
|
||||
:data-nested-index="index"
|
||||
:style="{ '--nested-accent': accent }"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="nested-slide__orb" aria-hidden="true" />
|
||||
|
||||
<header>
|
||||
<span>{{ eyebrow }}</span>
|
||||
<strong>{{ index + 1 }} / {{ total }}</strong>
|
||||
</header>
|
||||
|
||||
<div class="nested-slide__copy">
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="nested-slide__state">
|
||||
<InstanceCard
|
||||
label="Nested instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="localLikes += 1"
|
||||
>
|
||||
Local state · {{ localLikes }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:disabled="!origin.canGoBack.value"
|
||||
@click="backWithButton"
|
||||
>
|
||||
{{ vertical ? "↓ Previous" : "← Previous" }}
|
||||
</button>
|
||||
|
||||
<div class="nested-slide__dots" aria-label="Slide position">
|
||||
<span
|
||||
v-for="dot in total"
|
||||
:key="dot"
|
||||
:class="{ active: dot === index + 1 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:disabled="!next"
|
||||
@click="nextWithButton"
|
||||
>
|
||||
{{ vertical ? "Next ↑" : "Next →" }}
|
||||
</button>
|
||||
</footer>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
OriginScene,
|
||||
back,
|
||||
createOriginScene,
|
||||
gesture,
|
||||
originView,
|
||||
useOrigin,
|
||||
type OriginView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { unfocusPortal } from "../motions";
|
||||
import NestedSceneSlide from "./NestedSceneSlide.vue";
|
||||
|
||||
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
|
||||
|
||||
interface SlideCopy {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
|
||||
cooperative: [
|
||||
{
|
||||
eyebrow: "Cooperative carousel",
|
||||
title: "A scene inside a page",
|
||||
description:
|
||||
"Swipe left away from the glowing pass-through rail. The target mounts only inside this clipped carousel.",
|
||||
accent: "#76dcff",
|
||||
},
|
||||
{
|
||||
eyebrow: "Retained nested history",
|
||||
title: "Slide one is still mounted",
|
||||
description:
|
||||
"Increment local state, move again, then return. The same nested Vue instances are revealed.",
|
||||
accent: "#a78bfa",
|
||||
},
|
||||
{
|
||||
eyebrow: "Independent operation graph",
|
||||
title: "Three carousel instances",
|
||||
description:
|
||||
"This history belongs to the carousel scene. The outer page remains one mounted application entry.",
|
||||
accent: "#65f2b1",
|
||||
},
|
||||
],
|
||||
vertical: [
|
||||
{
|
||||
eyebrow: "Vertical nested deck",
|
||||
title: "Pull upward inside this card",
|
||||
description:
|
||||
"The deck uses vertical progress, rotation, and a shared sideways frame arc.",
|
||||
accent: "#ffbd70",
|
||||
},
|
||||
{
|
||||
eyebrow: "Orthogonal choreography",
|
||||
title: "The page still owns its left rail",
|
||||
description:
|
||||
"Pull down to reveal the retained card, or begin on its pass-through rail and swipe right to leave the lab.",
|
||||
accent: "#ff7f9f",
|
||||
},
|
||||
{
|
||||
eyebrow: "Nested composition",
|
||||
title: "Frames remain local",
|
||||
description:
|
||||
"Transforms compose exactly like the full-screen scene, but measurements come from this smaller viewport.",
|
||||
accent: "#ffe079",
|
||||
},
|
||||
],
|
||||
conflict: [
|
||||
{
|
||||
eyebrow: "Intentional conflict",
|
||||
title: "This child claims every pointer-down",
|
||||
description:
|
||||
"Try the page's rightward back gesture over this card. The child sees it first, even though nested back is unavailable.",
|
||||
accent: "#ff5f78",
|
||||
},
|
||||
{
|
||||
eyebrow: "Child-owned back",
|
||||
title: "Now rightward swipe works here",
|
||||
description:
|
||||
"After moving forward, the exact same gesture can resolve nested back, producing the cube reversal.",
|
||||
accent: "#ff8d4d",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function nestedView(kind: NestedSceneKind, index: number): OriginView {
|
||||
const slides = slideSets[kind];
|
||||
const copy = slides[index]!;
|
||||
return originView(
|
||||
NestedSceneSlide,
|
||||
{
|
||||
kind,
|
||||
index,
|
||||
total: slides.length,
|
||||
...copy,
|
||||
next:
|
||||
index + 1 < slides.length
|
||||
? (): OriginView => nestedView(kind, index + 1)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: `${kind}-slide-${index + 1}`,
|
||||
name: `${kind} slide ${index + 1}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function nestedScene(kind: NestedSceneKind) {
|
||||
return createOriginScene({ initial: nestedView(kind, 0) });
|
||||
}
|
||||
|
||||
interface NestedCase {
|
||||
kind: NestedSceneKind;
|
||||
status: "supported" | "supported-with-policy" | "known-conflict";
|
||||
title: string;
|
||||
summary: string;
|
||||
expected: string;
|
||||
issue: string;
|
||||
scene: ReturnType<typeof createOriginScene>;
|
||||
}
|
||||
|
||||
const cases: readonly NestedCase[] = [
|
||||
{
|
||||
kind: "cooperative",
|
||||
status: "supported-with-policy",
|
||||
title: "Reserved-edge carousel",
|
||||
summary:
|
||||
"Horizontal nested history with a start predicate that leaves a clamp(22px, 12%, 52px) rail for its parent.",
|
||||
expected:
|
||||
"Swipe left or right beyond the rail; swipe right from the card's rail to trigger parent back.",
|
||||
issue:
|
||||
"The parent/child agreement is spatial policy written by the developer, not automatic arbitration.",
|
||||
scene: nestedScene("cooperative"),
|
||||
},
|
||||
{
|
||||
kind: "vertical",
|
||||
status: "supported",
|
||||
title: "Vertical component deck",
|
||||
summary:
|
||||
"A second nested scene uses upward/downward gestures and an entirely different frame animation.",
|
||||
expected:
|
||||
"Swipe up for the next card, down for retained back, and swipe right from its rail for parent back.",
|
||||
issue:
|
||||
"Its touch-action prevents page scrolling while a touch begins over the deck, which is appropriate here but must be intentional.",
|
||||
scene: nestedScene("vertical"),
|
||||
},
|
||||
{
|
||||
kind: "conflict",
|
||||
status: "known-conflict",
|
||||
title: "Greedy child recognizer",
|
||||
summary:
|
||||
"The cube carousel deliberately uses `.to.left()` and `.to.right()` without a `.from` constraint.",
|
||||
expected: "Nested forward/back works and remains clipped to the card.",
|
||||
issue:
|
||||
"On slide one, parent back fails everywhere over this card: the child stops pointer-down propagation before `.navigate()` returns null.",
|
||||
scene: nestedScene("conflict"),
|
||||
},
|
||||
];
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Nested scenes");
|
||||
|
||||
const pageBackGesture = gesture.to
|
||||
.right()
|
||||
.complete(
|
||||
({ distance, bounds, progress, velocity }) =>
|
||||
distance >= bounds.width * 0.08 || progress >= 0.22 || velocity >= 0.65,
|
||||
)
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
|
||||
function backWithButton() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(unfocusPortal));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page nested-scenes-view" :gesture="pageBackGesture">
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
|
||||
<header class="nested-scenes-header">
|
||||
<div>
|
||||
<div class="eyebrow">Lab 07 · Nested origin scenes</div>
|
||||
<h1>Components can own smaller worlds.</h1>
|
||||
<p>
|
||||
Each card below contains a completely independent
|
||||
<code>OriginScene</code>. Its views, history, gestures, measurements,
|
||||
and animations stay inside that card while this page remains the outer
|
||||
origin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="nested-page-instance">
|
||||
<span>Outer page instance</span>
|
||||
<strong>{{ identity.instance }}</strong>
|
||||
<small>rightward 8% → page back</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="nested-case-grid">
|
||||
<section
|
||||
v-for="item in cases"
|
||||
:key="item.kind"
|
||||
class="nested-case"
|
||||
:class="`nested-case--${item.kind}`"
|
||||
>
|
||||
<header>
|
||||
<span class="nested-case__status" :class="item.status">
|
||||
{{ item.status.replaceAll("-", " ") }}
|
||||
</span>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.summary }}</p>
|
||||
</header>
|
||||
|
||||
<div class="nested-stage-shell">
|
||||
<OriginScene class="nested-stage" :scene="item.scene" />
|
||||
<div class="nested-stage-edge" aria-hidden="true">
|
||||
{{
|
||||
item.kind === "conflict"
|
||||
? "parent blocked"
|
||||
: "parent pass-through"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nested-case__diagnostics">
|
||||
<span>
|
||||
{{ item.scene.nodes.value.length }} nested node{{
|
||||
item.scene.nodes.value.length === 1 ? "" : "s"
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ item.scene.operations.value.length }} live edge{{
|
||||
item.scene.operations.value.length === 1 ? "" : "s"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Expected</dt>
|
||||
<dd>{{ item.expected }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{{ item.kind === "conflict" ? "Predicted failure" : "Caveat" }}
|
||||
</dt>
|
||||
<dd>{{ item.issue }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="nested-scenes-footer">
|
||||
<strong>Ownership today is decided at pointer-down.</strong>
|
||||
<span>
|
||||
A future gesture arena could delay that decision and let a parent claim
|
||||
the sequence when a child declines navigation.
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<div class="nested-page-gesture">
|
||||
Parent gesture: swipe right outside a child, or from a cooperative rail
|
||||
</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
|
||||
defineProps<{
|
||||
orderReference: string;
|
||||
replacedInstance: string;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Confirmation");
|
||||
|
||||
const returnGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
function returnToLabs() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture
|
||||
class="checkout-view confirmation-view"
|
||||
:gesture="returnGesture"
|
||||
>
|
||||
<section class="confirmation-card">
|
||||
<div class="confirmation-mark" aria-hidden="true">✓</div>
|
||||
<div class="eyebrow">Payment complete</div>
|
||||
<h1>Order confirmed.</h1>
|
||||
<p>
|
||||
The Payment Details node <code>{{ replacedInstance }}</code> has been
|
||||
removed. Back now resolves the retained Hub instance directly.
|
||||
</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Order</dt>
|
||||
<dd>{{ orderReference }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>History depth</dt>
|
||||
<dd>{{ origin.context.value.history.length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Previous view</dt>
|
||||
<dd>{{ origin.context.value.previous?.name ?? "None" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<InstanceCard
|
||||
label="Confirmation instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="confirmation-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="returnToLabs"
|
||||
>
|
||||
Back to labs
|
||||
</button>
|
||||
<small>Try the left-edge back gesture too.</small>
|
||||
</section>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGestureSurface,
|
||||
back,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { partialDrawerOpen } from "../motions";
|
||||
import PartialDrawerView from "./PartialDrawerView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Drawer page");
|
||||
|
||||
const drawerView = () =>
|
||||
originView(
|
||||
PartialDrawerView,
|
||||
{ openedFrom: identity.instance },
|
||||
{ key: "partial-drawer", name: "Two-thirds Drawer" },
|
||||
);
|
||||
|
||||
const openDrawerGesture = gesture.from
|
||||
.left("clamp(32px, 10%, 88px)")
|
||||
.to.right()
|
||||
.navigate(() => forward(drawerView()))
|
||||
.animate(partialDrawerOpen);
|
||||
|
||||
const gestures = [openDrawerGesture] as const;
|
||||
|
||||
function openDrawer() {
|
||||
void origin.perform(forward(drawerView(), partialDrawerOpen));
|
||||
}
|
||||
|
||||
function returnToLabs() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="partial-origin-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<!--
|
||||
This rail is the left third of the full page. Once the whole page moves
|
||||
right by 2/3, this exact live DOM remains visible in the final third.
|
||||
-->
|
||||
<aside class="partial-origin-rail">
|
||||
<div class="partial-brand">OV</div>
|
||||
<nav aria-label="Example navigation">
|
||||
<button type="button" data-origin-gesture="ignore">Inbox</button>
|
||||
<button type="button" data-origin-gesture="ignore">Projects</button>
|
||||
<button type="button" data-origin-gesture="ignore">Archive</button>
|
||||
</nav>
|
||||
<InstanceCard
|
||||
label="Page instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section class="partial-origin-content">
|
||||
<header>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Return to labs"
|
||||
@click="returnToLabs"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
<span>Connected presentation</span>
|
||||
</header>
|
||||
|
||||
<div class="partial-origin-copy">
|
||||
<div class="eyebrow">Partial route lab</div>
|
||||
<h1>Keep one third of this page on screen.</h1>
|
||||
<p>
|
||||
Drag right from the left edge. The drawer becomes a real history
|
||||
entry, while this same mounted page remains translated and visible.
|
||||
</p>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="openDrawer"
|
||||
>
|
||||
Open two-thirds drawer
|
||||
</button>
|
||||
<div class="gesture-hint"><span>→</span> Pull from the left edge</div>
|
||||
</div>
|
||||
</section>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { partialDrawerClose } from "../motions";
|
||||
|
||||
defineProps<{ openedFrom: string }>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Drawer");
|
||||
|
||||
const closeGesture = gesture.to
|
||||
.left()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(partialDrawerClose);
|
||||
|
||||
function closeDrawer() {
|
||||
if (origin.context.value.canGoBack)
|
||||
void origin.perform(back(partialDrawerClose));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="partial-drawer-layer" :gesture="closeGesture">
|
||||
<aside class="partial-drawer-panel">
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">Two-thirds destination</div>
|
||||
<h1>Workspace</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Close drawer"
|
||||
@click="closeDrawer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
This drawer is its own Vue view and history entry. It did not copy,
|
||||
teleport, or remount the page visible beside it.
|
||||
</p>
|
||||
|
||||
<div class="partial-drawer-list">
|
||||
<button type="button" data-origin-gesture="ignore">
|
||||
<span>01</span>
|
||||
Recent activity
|
||||
</button>
|
||||
<button type="button" data-origin-gesture="ignore">
|
||||
<span>02</span>
|
||||
Shared with me
|
||||
</button>
|
||||
<button type="button" data-origin-gesture="ignore">
|
||||
<span>03</span>
|
||||
Offline files
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Drawer instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<small>
|
||||
Opened over {{ openedFrom }} · swipe left or tap the exposed third to
|
||||
close.
|
||||
</small>
|
||||
</aside>
|
||||
|
||||
<button
|
||||
class="partial-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="Close drawer from exposed page"
|
||||
@click="closeDrawer"
|
||||
/>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
OriginGestureSurface,
|
||||
back,
|
||||
gesture,
|
||||
originView,
|
||||
replace,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import OrderConfirmationView from "./OrderConfirmationView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Payment");
|
||||
const cardholder = ref("Ada Lovelace");
|
||||
const lastFour = ref("4242");
|
||||
const processing = ref(false);
|
||||
|
||||
function confirmationView() {
|
||||
return originView(
|
||||
OrderConfirmationView,
|
||||
{
|
||||
orderReference: `NVO-${Math.floor(Date.now() / 1000)
|
||||
.toString()
|
||||
.slice(-6)}`,
|
||||
replacedInstance: identity.instance,
|
||||
},
|
||||
{ key: "order-confirmation", name: "Order Confirmation" },
|
||||
);
|
||||
}
|
||||
|
||||
const confirmGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => replace(confirmationView()))
|
||||
.animate(slideLeft);
|
||||
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
const gestures = [confirmGesture, backGesture] as const;
|
||||
|
||||
function returnToLabs() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (processing.value) return;
|
||||
processing.value = true;
|
||||
|
||||
// `replace` is one atomic scene operation. Payment Details stays mounted
|
||||
// throughout the animation and is removed only after the commit completes.
|
||||
await origin.perform(replace(confirmationView(), slideLeft));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="checkout-view payment-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<header class="checkout-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Return to labs"
|
||||
@click="returnToLabs"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span>Secure checkout</span>
|
||||
<span class="checkout-step">Step 2 of 2</span>
|
||||
</header>
|
||||
|
||||
<section class="checkout-layout">
|
||||
<div class="checkout-copy">
|
||||
<div class="eyebrow">Replace-history lab</div>
|
||||
<h1>Payment details</h1>
|
||||
<p>
|
||||
Completing this mock payment replaces this mounted entry. The
|
||||
confirmation page will point straight back to the Hub.
|
||||
</p>
|
||||
<InstanceCard
|
||||
label="Payment instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form class="payment-card" @submit.prevent="pay">
|
||||
<div class="order-line">
|
||||
<span>Origin Pro</span>
|
||||
<strong>$48.00</strong>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Cardholder
|
||||
<input
|
||||
v-model="cardholder"
|
||||
data-origin-gesture="ignore"
|
||||
autocomplete="cc-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Card number
|
||||
<span class="card-number">
|
||||
<b>•••• •••• ••••</b>
|
||||
<input
|
||||
v-model="lastFour"
|
||||
data-origin-gesture="ignore"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
autocomplete="cc-number"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<div class="payment-row">
|
||||
<label>
|
||||
Expiry
|
||||
<input
|
||||
data-origin-gesture="ignore"
|
||||
value="08 / 29"
|
||||
autocomplete="cc-exp"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CVC
|
||||
<input
|
||||
data-origin-gesture="ignore"
|
||||
value="123"
|
||||
maxlength="4"
|
||||
autocomplete="cc-csc"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="pay-button"
|
||||
type="submit"
|
||||
data-origin-gesture="ignore"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ processing ? "Confirming…" : "Pay $48.00" }}
|
||||
</button>
|
||||
<small>Or swipe left anywhere outside the form controls.</small>
|
||||
</form>
|
||||
</section>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, ref, type Component } from "vue";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { demoPhotos, type DemoPhoto } from "../gallery-data";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { arcSlide, coverDown } from "../motions";
|
||||
|
||||
const props = defineProps<{ photo: DemoPhoto; index: number }>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance(`Photo ${props.index + 1}`);
|
||||
const liked = ref(false);
|
||||
const ownComponent = getCurrentInstance()?.type as Component;
|
||||
const nextPhoto = computed(
|
||||
() => demoPhotos[(props.index + 1) % demoPhotos.length]!,
|
||||
);
|
||||
|
||||
/*
|
||||
* The target uses the same SFC definition with different props. It is still a
|
||||
* fresh scene node and Vue instance, which makes recipe identity visible.
|
||||
*/
|
||||
const nextPhotoView = () =>
|
||||
originView(
|
||||
ownComponent,
|
||||
{
|
||||
photo: nextPhoto.value,
|
||||
index: (props.index + 1) % demoPhotos.length,
|
||||
},
|
||||
{ key: `photo-${nextPhoto.value.id}`, name: nextPhoto.value.title },
|
||||
);
|
||||
const goNext = () => above(nextPhotoView(), arcSlide);
|
||||
|
||||
const goBack = (context: OriginContext, vertical = false) =>
|
||||
context.canGoBack ? back(vertical ? coverDown : slideRight) : null;
|
||||
|
||||
const nextGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(nextPhotoView()))
|
||||
.animate(arcSlide);
|
||||
const dismissGesture = gesture.to
|
||||
.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(coverDown);
|
||||
const edgeBackGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [nextGesture, dismissGesture, edgeBackGesture] as const;
|
||||
|
||||
function close() {
|
||||
const action = goBack(origin.context.value, true);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="article"
|
||||
class="photo-detail"
|
||||
:style="{ background: photo.gradient }"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="photo-vignette" />
|
||||
<header class="photo-toolbar">
|
||||
<button type="button" data-origin-gesture="ignore" @click="close">
|
||||
↓ Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:aria-pressed="liked"
|
||||
@click="liked = !liked"
|
||||
>
|
||||
{{ liked ? "♥ Liked" : "♡ Like" }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="photo-copy">
|
||||
<div class="eyebrow">{{ photo.location }}</div>
|
||||
<h1>{{ photo.title }}</h1>
|
||||
<p>{{ photo.description }}</p>
|
||||
<InstanceCard
|
||||
label="Photo instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="origin.perform(goNext())"
|
||||
>
|
||||
Next: {{ nextPhoto.title }}
|
||||
</button>
|
||||
<div class="gesture-map">
|
||||
<span>← next photo</span>
|
||||
<span>↓ retained previous instance</span>
|
||||
<span>left edge → retained previous instance</span>
|
||||
</div>
|
||||
</main>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { coverDown } from "../motions";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Player");
|
||||
const playing = ref(false);
|
||||
const position = ref(38);
|
||||
const elapsed = computed(() => `1:${String(position.value).padStart(2, "0")}`);
|
||||
const timer = window.setInterval(() => {
|
||||
if (playing.value) position.value = (position.value + 1) % 60;
|
||||
}, 1000);
|
||||
onBeforeUnmount(() => window.clearInterval(timer));
|
||||
|
||||
const dismiss = (context: OriginContext) =>
|
||||
context.canGoBack ? back(coverDown) : null;
|
||||
|
||||
const dismissGesture = gesture.to
|
||||
.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(coverDown);
|
||||
|
||||
function close() {
|
||||
const action = dismiss(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="player-view" :gesture="dismissGesture">
|
||||
<header class="player-header">
|
||||
<button type="button" data-origin-gesture="ignore" @click="close">
|
||||
⌄
|
||||
</button>
|
||||
<span>Now playing</span>
|
||||
<button type="button" data-origin-gesture="ignore">•••</button>
|
||||
</header>
|
||||
|
||||
<div class="album-art">
|
||||
<span class="album-ring album-ring--one" />
|
||||
<span class="album-ring album-ring--two" />
|
||||
<span class="album-center">O</span>
|
||||
</div>
|
||||
|
||||
<section class="track-copy">
|
||||
<div>
|
||||
<h1>Independent Frames</h1>
|
||||
<p>The Scene Graph</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:aria-pressed="playing"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
{{ playing ? "♥" : "♡" }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<label class="timeline">
|
||||
<input
|
||||
v-model="position"
|
||||
data-origin-gesture="ignore"
|
||||
type="range"
|
||||
min="0"
|
||||
max="59"
|
||||
/>
|
||||
<span>{{ elapsed }}</span>
|
||||
<span>3:42</span>
|
||||
</label>
|
||||
|
||||
<div class="player-controls">
|
||||
<button type="button" data-origin-gesture="ignore">↶</button>
|
||||
<button
|
||||
class="play-button"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
{{ playing ? "Ⅱ" : "▶" }}
|
||||
</button>
|
||||
<button type="button" data-origin-gesture="ignore">↷</button>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Player instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<p class="policy-note">
|
||||
Swipe down anywhere to dismiss. Horizontal back is intentionally absent,
|
||||
like a native full-screen player.
|
||||
</p>
|
||||
<div class="drag-handle" />
|
||||
</OriginGesture>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import ThirdView from "./ThirdView.vue";
|
||||
|
||||
defineProps<{ openedAt?: string; createdBy?: string }>();
|
||||
|
||||
const identity = useDemoInstance("Y");
|
||||
const thirdView = () =>
|
||||
originView(
|
||||
ThirdView,
|
||||
{ createdBy: identity.instance },
|
||||
{ key: "third", name: "Third" },
|
||||
);
|
||||
const forwardGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(thirdView()))
|
||||
.animate(slideLeft);
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page chain-view view-two"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="eyebrow">Chain stress test · Origin Y</div>
|
||||
<h1>Y can move while X is still moving it.</h1>
|
||||
<p>
|
||||
X→Y supplies Y's inherited coordinate frame. This Y→Z gesture adds a
|
||||
second transform layer inside that frame.
|
||||
</p>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<span>Created by X</span>
|
||||
<strong>{{ createdBy }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Created at</span>
|
||||
<strong>{{ openedAt ?? "unknown" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Y Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<div class="gesture-hint">Swipe left again <span>→</span></div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideRight,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { arcSlide } from "../motions";
|
||||
import FourthView from "./FourthView.vue";
|
||||
|
||||
defineProps<{ createdBy?: string }>();
|
||||
|
||||
const identity = useDemoInstance("Z");
|
||||
const fourthView = () =>
|
||||
originView(
|
||||
FourthView,
|
||||
{ chain: `X → Y → ${identity.instance}` },
|
||||
{ key: "fourth", name: "Omega" },
|
||||
);
|
||||
const forwardGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(fourthView()))
|
||||
.animate(arcSlide);
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page chain-view view-three"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="eyebrow">Chain stress test · Origin Z</div>
|
||||
<h1>Now add an arc while two frames may still be moving.</h1>
|
||||
<p>
|
||||
Z inherits X→Y and Y→Z motion, then originates its own frame contribution.
|
||||
The shared frame lifts both Z and Ω through a small arc.
|
||||
</p>
|
||||
<div class="proof">
|
||||
Created by Y instance <strong>{{ createdBy }}</strong>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Z Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<div class="gesture-hint">Swipe left for Ω <span>→</span></div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/origins-demo.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@native-vue-router/core-v2": ["../../packages/core-v2/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import path from "node:path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
/**
|
||||
* Recover browsers that visited this hostname while Docker still served Vite's
|
||||
* development mode. Its development worker can retain HTML that imports
|
||||
* `/@vite/client` and `/src/main.ts`; a normal static fallback would return
|
||||
* HTML for those module URLs and leave the visitor on a blank screen.
|
||||
*
|
||||
* This preview-only compatibility endpoint is requested only by that obsolete
|
||||
* shell. It clears the old origin-local worker/cache state and writes the
|
||||
* current production HTML into the document. The production application then
|
||||
* registers the real `/sw.js` worker normally.
|
||||
*/
|
||||
function legacyDevelopmentWorkerMigration(): Plugin {
|
||||
const migrationSource = `
|
||||
void (async () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()));
|
||||
}
|
||||
if ("caches" in window) {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
}
|
||||
|
||||
const productionDocument = await fetch(
|
||||
"/index.html?origin-pwa-migration=" + Date.now(),
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const html = await productionDocument.text();
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
})();
|
||||
`;
|
||||
|
||||
return {
|
||||
name: "origins-legacy-development-worker-migration",
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const pathname = new URL(request.url ?? "/", "http://origins.local")
|
||||
.pathname;
|
||||
|
||||
if (pathname === "/@vite/client") {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(
|
||||
"// Compatibility stub for the retired Vite dev client.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/src/main.ts") {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(migrationSource);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
base: "./",
|
||||
// Reuse the workspace's existing install icons rather than maintaining a
|
||||
// second set that can silently drift from the main demo.
|
||||
publicDir: path.resolve(__dirname, "../../public"),
|
||||
plugins: [
|
||||
vue(),
|
||||
legacyDevelopmentWorkerMigration(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
// The hosted Docker service runs Vite's development server, so its
|
||||
// public HTTPS URL also needs a real manifest and service worker.
|
||||
enabled: true,
|
||||
navigateFallback: "index.html",
|
||||
suppressWarnings: true,
|
||||
},
|
||||
includeAssets: [
|
||||
"favicon.svg",
|
||||
"app-icon.svg",
|
||||
"apple-touch-icon.png",
|
||||
"pwa-192.png",
|
||||
"pwa-512.png",
|
||||
],
|
||||
manifest: {
|
||||
id: "/",
|
||||
name: "Routeless Origins Lab",
|
||||
short_name: "Origins Lab",
|
||||
description:
|
||||
"Seven interactive labs for a routeless Vue animation and gesture engine.",
|
||||
theme_color: "#080b12",
|
||||
background_color: "#080b12",
|
||||
display: "standalone",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
orientation: "any",
|
||||
icons: [
|
||||
{
|
||||
src: "/pwa-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/pwa-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/pwa-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/app-icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
navigateFallback: "/index.html",
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
// The development container is reached through this Traefik hostname.
|
||||
// Keeping the allow-list explicit preserves Vite's DNS-rebinding defense.
|
||||
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@native-vue-router\/core-v2\/style\.css$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../../packages/core-v2/src/style.css",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: /^@native-vue-router\/core-v2$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../../packages/core-v2/src/index.ts",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, "dist"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
+23
-1
@@ -20,6 +20,28 @@ Pointer movement changes only a CSS progress variable. Built-in presentations re
|
||||
|
||||
Settling animations are interruptible. A new button navigation or recognized gesture waits only for any in-flight Vue Router guard/history commit, immediately finalizes the old visual transaction, and begins from the newly authoritative route. It never waits for the previous spring to finish. Leading-edge back recognition runs in the navigator capture phase so partially visible component layers cannot steal the physical back edge.
|
||||
|
||||
### Runtime domains
|
||||
|
||||
`runtime.ts` is the public transaction coordinator. Its supporting domains live
|
||||
under `packages/core/src/runtime`:
|
||||
|
||||
- `vue-router-bridge.ts` contains all Vue Router-specific integration: route
|
||||
resolution and loading, commit operations, `afterEach` reconciliation,
|
||||
browser-history completion, and the scoped Options API `$route` bridge.
|
||||
- `history-ledger.ts` owns native push, replace, and pop history semantics.
|
||||
- `view-store.ts` owns mounted route entries, active state, preview reuse,
|
||||
eviction, underlay protection, and cache statistics.
|
||||
- `animation.ts` owns gesture commit policy, view preparation, and spring
|
||||
settling.
|
||||
- `presentations.ts` owns built-in and application-defined presentations.
|
||||
- `diagnostics.ts` owns runtime diagnostic subscriptions and event emission.
|
||||
- `route-entry.ts` owns route-entry construction, labels, timestamps, and
|
||||
sibling direction.
|
||||
|
||||
Keeping the Vue Router adapter separate makes the compatibility work visible
|
||||
and prevents history, cache, rendering, and animation policy from accumulating
|
||||
inside the integration layer.
|
||||
|
||||
Component gesture owners stop propagation before a containing navigator can claim the same pointer. Navigator gestures wait for horizontal intent, use pointer capture, respect form controls and `data-native-gesture="ignore"`, and preserve normal vertical scrolling through `touch-action: pan-y`.
|
||||
|
||||
## Route metadata
|
||||
@@ -87,7 +109,7 @@ useNativeViewActiveEffect(() => {
|
||||
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
|
||||
```
|
||||
|
||||
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation. Application data that must survive eviction belongs in an application store.
|
||||
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition and the visible, inert route beneath a partial sheet. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation or while painted beneath a sheet. Application data that must survive eviction belongs in an application store.
|
||||
|
||||
## Optional performance profiler
|
||||
|
||||
|
||||
@@ -70,9 +70,9 @@ Transactions are interruptible. Starting a new navigation while a spring is sett
|
||||
|
||||
## Rendering and presentation
|
||||
|
||||
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is visible and interactive. During a transaction, exactly the `from` and `to` entries receive active roles.
|
||||
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is interactive. A partial sheet also keeps its prior route visible but inert as an underlay. During a transaction, exactly the `from` and `to` entries receive interactive roles.
|
||||
|
||||
Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface.
|
||||
Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sheets retain their presentation after commit so they remain below the device safe top and can use content height or developer-defined snap points while the inert route beneath stays visible. Partial sheets animate their measured surface instead of a transparent viewport-sized wrapper, preserving motion from the first frame and keeping the underlay scale continuous across commit. Their scroll body hands top/down and bottom/up overscroll to sheet resizing while retaining ordinary content scrolling at interior positions. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface.
|
||||
|
||||
The runtime publishes progress as a CSS custom property. Built-in motion is mostly expressed through transforms and opacity, keeping per-frame JavaScript work constant. Applications can register presentations whose layer styles are functions of progress, role, direction, and optional source geometry.
|
||||
|
||||
@@ -88,6 +88,8 @@ Ownership is explicit:
|
||||
- A component gesture link owns drags that begin on that component away from the back edge.
|
||||
- A sheet dismissal surface owns downward vertical drags.
|
||||
|
||||
Scrollable sheets choose content or sheet ownership from the gesture's initial directional intent and keep that owner until release. A content-owned gesture does not become a sheet gesture merely because it later reaches an edge or reverses; the next gesture can begin at that edge and resize the sheet. This prevents concurrent scrolling and avoids applying distance accumulated by content to sheet geometry.
|
||||
|
||||
Pointer capture keeps delivery stable after recognition. Recognizer state is detached synchronously at pointer release, before route loading or animation promises are awaited. This is essential: a delayed callback from one gesture must never erase the state of a newer gesture.
|
||||
|
||||
## History and cache are intentionally separate
|
||||
|
||||
+1
-1
@@ -26,4 +26,4 @@ The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics
|
||||
|
||||
## Accessibility
|
||||
|
||||
Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants.
|
||||
Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. A partial sheet's visible underlay is also inert and `aria-hidden`; it remains painted only to provide visual context beneath the sheet backdrop. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Routeless origins architecture
|
||||
|
||||
The v2 experiment lives in `packages/core-v2` and its physical test application
|
||||
lives in `apps/origins-demo`.
|
||||
|
||||
## State model
|
||||
|
||||
The scene contains:
|
||||
|
||||
- stable, flat Vue component nodes;
|
||||
- linked mounted-instance history entries;
|
||||
- temporary directed operation edges.
|
||||
|
||||
It does not contain an active route or current view.
|
||||
|
||||
For overlapping operations:
|
||||
|
||||
```text
|
||||
Nodes: X, Y, Z
|
||||
Edges: X → Y
|
||||
Y → Z
|
||||
```
|
||||
|
||||
Each edge records its own progress, velocity, outcome, choreography, and
|
||||
source/target keys.
|
||||
|
||||
## Coordinate composition
|
||||
|
||||
Suppose A is X→Y and B is Y→Z:
|
||||
|
||||
```text
|
||||
visual(X) = A.source
|
||||
visual(Y) = A.target × B.source
|
||||
visual(Z) = A.target × B.target
|
||||
```
|
||||
|
||||
An optional frame effect is included on both sides of an edge:
|
||||
|
||||
```text
|
||||
visual(Y) = A.frame × A.target × B.frame × B.source
|
||||
visual(Z) = A.frame × A.target × B.frame × B.target
|
||||
```
|
||||
|
||||
The implementation emits these operations as one combined transform on each
|
||||
flat host. This has the visual semantics of nested coordinate frames without
|
||||
reparenting Vue component VNodes.
|
||||
|
||||
## Completion
|
||||
|
||||
Committing a forward X→Y:
|
||||
|
||||
1. Keeps X mounted but marks it parked, inert, and visually hidden.
|
||||
2. Places Y in X's former visual graph position.
|
||||
3. Removes the X→Y edge.
|
||||
4. Leaves any Y→Z edge and all mounted descendants intact.
|
||||
|
||||
Committing Y→Z before X→Y is also valid. Z replaces Y as the target of the
|
||||
still-running X edge, after which X→Y effectively becomes X→Z. The unit suite
|
||||
covers both completion orders.
|
||||
|
||||
Committing back from Y to X reveals the existing X node and removes Y. It does
|
||||
not construct X again from its recipe. Cancelling a forward edge restores its
|
||||
source and removes the newly created target branch; cancelling a back edge
|
||||
re-parks its retained target.
|
||||
|
||||
Committing a replace from Y to Z creates Z but links it directly to Y's
|
||||
previous entry:
|
||||
|
||||
```text
|
||||
Before: X (parked) ← Y (visible)
|
||||
After: X (parked) ← Z (visible)
|
||||
```
|
||||
|
||||
Y remains mounted while the operation is interactive and is removed only on
|
||||
commit. Cancelling removes Z and restores Y, making replacement one atomic
|
||||
operation rather than a visible back followed by a forward push.
|
||||
|
||||
### Connected partial presentations
|
||||
|
||||
Most committed pushes collapse their temporary operation edge and park the
|
||||
retained source. A choreography with `persistAtRest: true` instead keeps its
|
||||
progress-`1` effects as a settled coordinate relationship:
|
||||
|
||||
```text
|
||||
Page (exposed, inert) → Drawer (active)
|
||||
```
|
||||
|
||||
This supports partial destinations without copying or remounting either Vue
|
||||
component. For a two-thirds drawer, the settled source effect translates the
|
||||
page right by two thirds, while the target finishes at its identity position.
|
||||
The page's left third therefore remains physically visible in the final third
|
||||
of the viewport.
|
||||
|
||||
Settled relationships are not live operations and are omitted from the public
|
||||
operation diagnostics. Beginning back temporarily suspends the relationship
|
||||
and lets a reciprocal close choreography start at the same endpoints. A
|
||||
cancelled close restores the settled edge; a committed close removes the drawer
|
||||
and returns the page to its normal root position.
|
||||
|
||||
## Interaction
|
||||
|
||||
Gesture recognition is declared within each component through
|
||||
`OriginGesture` or `useOriginGesture()`. The injected scene-node key determines
|
||||
the origin. Recognition never asks a coordinator which view is active.
|
||||
|
||||
The immutable `gesture` builder separates optional pointer-down policy
|
||||
(`.from`), movement recognition (`.to`), release policy (`.complete`),
|
||||
navigation intent (`.navigate`), and visual choreography (`.animate`). A chain
|
||||
that begins at `.to` is valid and admits pointer-down anywhere on its host.
|
||||
|
||||
At pointer release, the operation decides synchronously whether it will commit
|
||||
or cancel. Its spring may continue afterward. A retained target can therefore
|
||||
originate another routine while the preceding spring is still visible.
|
||||
|
||||
### Nested scenes
|
||||
|
||||
An `OriginScene` may be rendered inside a view owned by another scene. The
|
||||
nearest injected node scope makes carousel or deck gestures operate on the
|
||||
nested scene, with local measurements and retained history.
|
||||
|
||||
Gesture ownership between nested scenes is currently selected at pointer-down.
|
||||
An eligible child stops propagation even if its later navigation factory
|
||||
declines. Parent fallback therefore requires the child to reserve a
|
||||
non-matching `.from` region; automatic delayed arbitration remains future
|
||||
gesture-arena work.
|
||||
|
||||
## Retained history
|
||||
|
||||
Each pushed node stores the key of its mounted previous entry. The chain is
|
||||
local to that origin context rather than a URL:
|
||||
|
||||
```text
|
||||
X (parked) ← Y (parked) ← Z (visible)
|
||||
```
|
||||
|
||||
Back targets the previous node key directly. A node is unmounted only when a
|
||||
committed back operation pops it, a forward operation is cancelled, or the
|
||||
current entry is successfully replaced, or the whole scene is destroyed.
|
||||
Because the same DOM survives parking, nested scroll positions and
|
||||
component-local state survive without `<KeepAlive>`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Vue Router guard/history experiment
|
||||
|
||||
This small app compares the browser's current URL and history position with
|
||||
Vue Router's `currentRoute` while a real `beforeResolve` guard is pending.
|
||||
|
||||
## Run it
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm --prefix experiments/router-guard-history run dev
|
||||
```
|
||||
|
||||
Open the URL printed by Vite.
|
||||
|
||||
## Suggested tests
|
||||
|
||||
### Forward navigation
|
||||
|
||||
1. Enable **Pause the next `beforeResolve`**.
|
||||
2. Click **Push Alpha**.
|
||||
3. While paused, compare **Browser URL** with **router.currentRoute**.
|
||||
4. Allow or reject the navigation.
|
||||
|
||||
For `router.push()`, both values remain on the outgoing route until the guard
|
||||
allows confirmation.
|
||||
|
||||
### Back navigation
|
||||
|
||||
1. Push **Alpha**, then push **Beta**.
|
||||
2. Enable **Pause the next `beforeResolve`**.
|
||||
3. Click **Router Back**.
|
||||
4. While paused, compare **Browser URL** with **router.currentRoute**.
|
||||
5. Reject the navigation and watch the browser restore its previous history
|
||||
entry, or repeat the test and allow it.
|
||||
|
||||
The event timeline separately records the raw browser `popstate`, Vue Router
|
||||
guards, the URL, `currentRoute`, and the navigation result.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vue Router Guard History Experiment</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "router-guard-history-experiment",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.0.6",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { RouterView } from "vue-router";
|
||||
import { experiment, record } from "./experiment";
|
||||
import { router } from "./router";
|
||||
|
||||
const renderTick = ref(0);
|
||||
const browserUrl = computed(() => {
|
||||
renderTick.value;
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
});
|
||||
const historyPosition = computed(() => {
|
||||
renderTick.value;
|
||||
return window.history.state?.position ?? "not available";
|
||||
});
|
||||
|
||||
function refreshBrowserState() {
|
||||
renderTick.value += 1;
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", refreshBrowserState);
|
||||
router.afterEach(refreshBrowserState);
|
||||
|
||||
function navigate(kind: "push" | "replace", target: string) {
|
||||
record(`action: ${kind}(${target})`, router.currentRoute.value.fullPath, {
|
||||
target,
|
||||
});
|
||||
void router[kind](target).then(() => refreshBrowserState());
|
||||
}
|
||||
|
||||
function back() {
|
||||
record("action: router.back()", router.currentRoute.value.fullPath);
|
||||
router.back();
|
||||
}
|
||||
|
||||
function settle(allow: boolean) {
|
||||
experiment.pendingGuard?.settle(allow);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
experiment.logs.splice(0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header>
|
||||
<p class="eyebrow">Vue Router experiment</p>
|
||||
<h1>What changes while <code>beforeResolve</code> is pending?</h1>
|
||||
<p class="intro">
|
||||
Pause the next resolve guard, navigate, and compare the browser URL with
|
||||
Vue Router's authoritative route.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="status-grid">
|
||||
<div>
|
||||
<span>Browser URL</span>
|
||||
<strong>{{ browserUrl }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>router.currentRoute</span>
|
||||
<strong>{{ router.currentRoute.value.fullPath }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>history.state.position</span>
|
||||
<strong>{{ historyPosition }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<label class="hold-toggle">
|
||||
<input v-model="experiment.holdNextResolve" type="checkbox" />
|
||||
Pause the next <code>beforeResolve</code>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button @click="navigate('push', '/home')">Push Home</button>
|
||||
<button @click="navigate('push', '/alpha')">Push Alpha</button>
|
||||
<button @click="navigate('push', '/beta')">Push Beta</button>
|
||||
<button @click="navigate('replace', '/alpha')">Replace Alpha</button>
|
||||
<button class="back" @click="back">Router Back</button>
|
||||
</div>
|
||||
|
||||
<div v-if="experiment.pendingGuard" class="gate">
|
||||
<div>
|
||||
<span>Navigation paused</span>
|
||||
<strong>
|
||||
{{ experiment.pendingGuard.from }} →
|
||||
{{ experiment.pendingGuard.to }}
|
||||
</strong>
|
||||
</div>
|
||||
<button class="allow" @click="settle(true)">Allow navigation</button>
|
||||
<button class="reject" @click="settle(false)">Reject navigation</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RouterView />
|
||||
|
||||
<section class="log-panel">
|
||||
<div class="log-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Event timeline</p>
|
||||
<h2>Newest event first</h2>
|
||||
</div>
|
||||
<button class="quiet" @click="clearLog">Clear log</button>
|
||||
</div>
|
||||
|
||||
<div class="log-table">
|
||||
<div class="log-row log-labels">
|
||||
<span>Time / event</span>
|
||||
<span>Browser URL</span>
|
||||
<span>Current route</span>
|
||||
<span>Target / result</span>
|
||||
</div>
|
||||
<div v-for="entry in experiment.logs" :key="entry.id" class="log-row">
|
||||
<span
|
||||
><small>{{ entry.elapsed }}</small
|
||||
>{{ entry.event }}</span
|
||||
>
|
||||
<code>{{ entry.browserUrl }}</code>
|
||||
<code>{{ entry.currentRoute }}</code>
|
||||
<span>
|
||||
<code v-if="entry.target">{{ entry.target }}</code>
|
||||
<small v-if="entry.detail">{{ entry.detail }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside>
|
||||
<strong>Suggested test</strong>
|
||||
Push Alpha, then Beta. Enable the pause checkbox and click Router Back.
|
||||
While the guard is paused, inspect the two route values and then try both
|
||||
rejection and approval.
|
||||
</aside>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { reactive } from "vue";
|
||||
import type {
|
||||
NavigationFailure,
|
||||
RouteLocationNormalizedLoaded,
|
||||
} from "vue-router";
|
||||
|
||||
export interface ExperimentLog {
|
||||
id: number;
|
||||
elapsed: string;
|
||||
event: string;
|
||||
browserUrl: string;
|
||||
currentRoute: string;
|
||||
target?: string;
|
||||
historyPosition?: unknown;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface PendingGuard {
|
||||
to: string;
|
||||
from: string;
|
||||
settle(allow: boolean): void;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let sequence = 0;
|
||||
|
||||
export const experiment = reactive<{
|
||||
holdNextResolve: boolean;
|
||||
pendingGuard: PendingGuard | null;
|
||||
logs: ExperimentLog[];
|
||||
}>({
|
||||
holdNextResolve: false,
|
||||
pendingGuard: null,
|
||||
logs: [],
|
||||
});
|
||||
|
||||
function browserUrl() {
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export function record(
|
||||
event: string,
|
||||
currentRoute: string,
|
||||
options: { target?: string; detail?: string } = {},
|
||||
) {
|
||||
experiment.logs.unshift({
|
||||
id: ++sequence,
|
||||
elapsed: `${(performance.now() - startedAt).toFixed(1)} ms`,
|
||||
event,
|
||||
browserUrl: browserUrl(),
|
||||
currentRoute,
|
||||
target: options.target,
|
||||
historyPosition: window.history.state?.position,
|
||||
detail: options.detail,
|
||||
});
|
||||
}
|
||||
|
||||
export function waitForDecision(
|
||||
to: RouteLocationNormalizedLoaded,
|
||||
from: RouteLocationNormalizedLoaded,
|
||||
) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
experiment.pendingGuard = {
|
||||
to: to.fullPath,
|
||||
from: from.fullPath,
|
||||
settle(allow) {
|
||||
experiment.pendingGuard = null;
|
||||
resolve(allow);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function describeFailure(failure?: NavigationFailure | void) {
|
||||
return failure
|
||||
? `navigation failure type ${String(failure.type)}`
|
||||
: "navigation confirmed";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { record } from "./experiment";
|
||||
import { router } from "./router";
|
||||
import "./style.css";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
|
||||
void router.isReady().then(() => {
|
||||
record("router: ready", router.currentRoute.value.fullPath);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import ExperimentPage from "./views/ExperimentPage.vue";
|
||||
import {
|
||||
describeFailure,
|
||||
experiment,
|
||||
record,
|
||||
waitForDecision,
|
||||
} from "./experiment";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/", redirect: "/home" },
|
||||
{
|
||||
path: "/home",
|
||||
component: ExperimentPage,
|
||||
props: { title: "Home", color: "#6d5efc" },
|
||||
},
|
||||
{
|
||||
path: "/alpha",
|
||||
component: ExperimentPage,
|
||||
props: { title: "Alpha", color: "#ef5da8" },
|
||||
},
|
||||
{
|
||||
path: "/beta",
|
||||
component: ExperimentPage,
|
||||
props: { title: "Beta", color: "#2bbf8a" },
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// This listener sees the raw browser traversal. For a back/forward traversal,
|
||||
// compare its URL with router.currentRoute in the event log.
|
||||
window.addEventListener("popstate", () => {
|
||||
record("window: popstate", router.currentRoute.value.fullPath);
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
record("router: beforeEach", router.currentRoute.value.fullPath, {
|
||||
target: to.fullPath,
|
||||
});
|
||||
});
|
||||
|
||||
router.beforeResolve(async (to, from) => {
|
||||
record("router: beforeResolve entered", router.currentRoute.value.fullPath, {
|
||||
target: to.fullPath,
|
||||
});
|
||||
if (!experiment.holdNextResolve) return;
|
||||
|
||||
experiment.holdNextResolve = false;
|
||||
const allow = await waitForDecision(to, from);
|
||||
record(
|
||||
allow ? "beforeResolve: allowed" : "beforeResolve: rejected",
|
||||
router.currentRoute.value.fullPath,
|
||||
{ target: to.fullPath },
|
||||
);
|
||||
return allow || false;
|
||||
});
|
||||
|
||||
router.afterEach((to, _from, failure) => {
|
||||
record("router: afterEach", router.currentRoute.value.fullPath, {
|
||||
target: to.fullPath,
|
||||
detail: describeFailure(failure),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
:root {
|
||||
color: #e8e9f3;
|
||||
background: #11131a;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #3b3e4c;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
color: #f5f5fa;
|
||||
background: #252834;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #303442;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0 5rem;
|
||||
}
|
||||
|
||||
header {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: clamp(2rem, 5vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.status-grid span,
|
||||
.gate span,
|
||||
.route-page span,
|
||||
small {
|
||||
color: #979baa;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
color: #aeb1be;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.status-grid div,
|
||||
.controls,
|
||||
.log-panel,
|
||||
aside {
|
||||
border: 1px solid #292c38;
|
||||
border-radius: 1rem;
|
||||
background: #181a22;
|
||||
}
|
||||
|
||||
.status-grid div {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.status-grid strong,
|
||||
.gate strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hold-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.button-row,
|
||||
.gate {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gate {
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #d6a53a;
|
||||
border-radius: 0.75rem;
|
||||
background: #2a2418;
|
||||
}
|
||||
|
||||
.gate div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.allow {
|
||||
background: #176b4f;
|
||||
}
|
||||
|
||||
.reject {
|
||||
background: #7d2d46;
|
||||
}
|
||||
|
||||
.route-page {
|
||||
min-height: 170px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 0.4rem;
|
||||
margin: 1rem 0;
|
||||
padding: 1.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at 90% 20%, var(--route-color), transparent 42%),
|
||||
#1b1d27;
|
||||
}
|
||||
|
||||
.route-page strong {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #292c38;
|
||||
}
|
||||
|
||||
.log-heading .eyebrow {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
min-width: 850px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr 1fr 1fr 1.4fr;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-top: 1px solid #232630;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.log-row > span {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.log-labels {
|
||||
color: #7f8390;
|
||||
border-top: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
code {
|
||||
color: #d6cffd;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
aside {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
color: #aeb1be;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
aside strong {
|
||||
color: #f5f5fa;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
main {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.back {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
color: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="route-page" :style="{ '--route-color': color }">
|
||||
<span>Rendered route component</span>
|
||||
<strong>{{ title }}</strong>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/router-guard-history.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
Generated
+393
-42
File diff suppressed because it is too large
Load Diff
+47
-16
@@ -1,8 +1,36 @@
|
||||
{
|
||||
"name": "native-vue-router-workspace",
|
||||
"name": "@native-vue-router/core-v2",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.0-experimental.0",
|
||||
"description": "Routeless, component-owned scene transitions and gesture recognition for Vue 3.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git"
|
||||
},
|
||||
"files": [
|
||||
"packages/core-v2/dist",
|
||||
"packages/core-v2/API.md",
|
||||
"packages/core-v2/README.md",
|
||||
"skills/build-with-native-vue-router-v2"
|
||||
],
|
||||
"main": "./packages/core-v2/dist/index.js",
|
||||
"types": "./packages/core-v2/dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./packages/core-v2/dist/index.d.ts",
|
||||
"import": "./packages/core-v2/dist/index.js"
|
||||
},
|
||||
"./style.css": "./packages/core-v2/dist/style.css",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"sideEffects": [
|
||||
"./packages/core-v2/dist/style.css"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
@@ -10,19 +38,27 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "npm run build:packages && vue-tsc -b && vite build",
|
||||
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
|
||||
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/core-v2 --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
|
||||
"build:v2-demo": "npm run build --workspace @native-vue-router/origins-demo",
|
||||
"dev:v2": "npm run dev --workspace @native-vue-router/origins-demo",
|
||||
"serve:v2": "npm run build:v2-demo && npm run preview --workspace @native-vue-router/origins-demo",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"preview": "vite preview",
|
||||
"electron": "npm run build && electron apps/electron/main.mjs",
|
||||
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\""
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
||||
"prepare": "npm run build --workspace packages/core-v2",
|
||||
"test:feel": "NODE_ENV=development vite build --mode development --config packages/core-v2/verify/browser/app/vite.config.mjs && node packages/core-v2/verify/browser/run.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"devDependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/haptics": "^8.0.0",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@capacitor/splash-screen": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@lucide/vue": "^1.24.0",
|
||||
@@ -30,27 +66,22 @@
|
||||
"@native-vue-router/core": "0.1.0",
|
||||
"@native-vue-router/electron": "0.1.0",
|
||||
"@native-vue-router/preset-native": "0.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"electron": "^43.1.1",
|
||||
"happy-dom": "^20.10.6",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.0.6",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
# Core v2: routeless origins
|
||||
|
||||
`@native-vue-router/core-v2` is an experimental, Vue-only scene compositor. It
|
||||
does not install Vue Router, resolve URLs, select a globally active route, or
|
||||
render through `RouterView`.
|
||||
|
||||
The complete function, component, option, type, gesture-edge, and choreography
|
||||
reference is in [API.md](./API.md).
|
||||
|
||||
## Install from Git
|
||||
|
||||
The repository root exposes this workspace package, runs its build during npm's
|
||||
Git-dependency preparation, and includes the generated JavaScript, declarations,
|
||||
and stylesheet:
|
||||
|
||||
```bash
|
||||
npm install git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git
|
||||
```
|
||||
|
||||
Pin applications to a commit or tag when reproducibility matters:
|
||||
|
||||
```bash
|
||||
npm install "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git#<commit-or-tag>"
|
||||
```
|
||||
|
||||
Vue 3.5 or newer is the only peer dependency.
|
||||
|
||||
The Git-installed repository package includes a Codex integration skill at
|
||||
`skills/build-with-native-vue-router-v2`. Copy that folder into
|
||||
`${CODEX_HOME:-$HOME/.codex}/skills` and invoke it as
|
||||
`$build-with-native-vue-router-v2`.
|
||||
|
||||
The primitive is:
|
||||
|
||||
> A mounted component can originate a routine that creates another component,
|
||||
> moves both components relative to the origin's coordinate frame, and retains
|
||||
> the previous instance until a committed back operation pops the newer entry.
|
||||
|
||||
## Run the experiment
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```sh
|
||||
npm run dev:v2
|
||||
```
|
||||
|
||||
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;
|
||||
- three nested scenes demonstrating cooperative carousels, vertical decks, and
|
||||
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,
|
||||
swipe rapidly through X → Y → Z → Ω to see all four components mounted while
|
||||
their independent frames are still moving.
|
||||
|
||||
An installable, offline-capable PWA build of the same experiment is hosted at
|
||||
<https://v2.demo.native-router.harvmaster.com/>.
|
||||
|
||||
## Basic usage
|
||||
|
||||
Create a scene with a component recipe:
|
||||
|
||||
```ts
|
||||
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||
import "@native-vue-router/core-v2/style.css";
|
||||
import HomeView from "./HomeView.vue";
|
||||
|
||||
export const scene = createOriginScene({
|
||||
initial: originView(HomeView, { accountId: "42" }, { key: "home" }),
|
||||
});
|
||||
```
|
||||
|
||||
Render it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginScene } from "@native-vue-router/core-v2";
|
||||
import { scene } from "./scene";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginScene :scene="scene" />
|
||||
</template>
|
||||
```
|
||||
|
||||
Declare an interaction inside the component that should originate it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import ProfileView from "./ProfileView.vue";
|
||||
|
||||
const openProfile = gesture.to
|
||||
.left()
|
||||
.navigate(() =>
|
||||
forward(originView(ProfileView, { userId: "7" }, { key: "profile-7" })),
|
||||
)
|
||||
.animate(slideLeft);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture :gesture="openProfile">
|
||||
<main>Swipe this component left</main>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
```
|
||||
|
||||
Starting directly at `.to.left()` means pointer-down may occur anywhere on the
|
||||
host. Add `.from.left("clamp(24px, 8%, 64px)")` before `.to.right()` for a
|
||||
conventional proportional back edge, or `.from.when(context => ...)` for
|
||||
arbitrary start policy. `.complete()` can override the choreography's release
|
||||
thresholds.
|
||||
|
||||
There is no global navigation declaration. If this component should not
|
||||
support that gesture, it simply does not render `OriginGesture`.
|
||||
|
||||
For an existing element where an additional wrapper is undesirable, use
|
||||
`useOriginGesture()` and attach its four pointer handlers directly.
|
||||
|
||||
For several gestures on one page surface, keep the definitions in the page and
|
||||
pass them to the policy-neutral host:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginGestureSurface } from "@native-vue-router/core-v2";
|
||||
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface as="main" :gestures="gestures">
|
||||
...
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Going backward
|
||||
|
||||
Every pushed history entry remains mounted. Back resolves the already-mounted
|
||||
previous node and pops only the current entry after the operation commits:
|
||||
|
||||
```ts
|
||||
import {
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOriginGesture,
|
||||
} from "@native-vue-router/core-v2";
|
||||
|
||||
const goBack = useOriginGesture(
|
||||
gesture.from
|
||||
.left("max(24px, 6%)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
);
|
||||
```
|
||||
|
||||
Parked entries are visually hidden, inert, and removed from pointer and
|
||||
accessibility interaction. Their Vue instances and DOM remain mounted, so
|
||||
component-local state and nested element scroll positions are preserved
|
||||
naturally. A cancelled back re-parks the previous target; a committed back
|
||||
unmounts the entry being left.
|
||||
|
||||
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
|
||||
shared frame:
|
||||
|
||||
```ts
|
||||
import { defineOriginChoreography } from "@native-vue-router/core-v2";
|
||||
|
||||
export const zoomFromCard = defineOriginChoreography({
|
||||
name: "zoom-from-card",
|
||||
commitThreshold: 0.42,
|
||||
effects: ({ progress, originRect, viewport }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.4,
|
||||
},
|
||||
target: {
|
||||
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 24}px`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
`originRect`, `targetRect`, and the scene viewport are measured after the
|
||||
target mounts. The gesture may update progress interactively or a normal click
|
||||
can call `useOrigin().perform(action)`.
|
||||
|
||||
Transforms are concatenated from the oldest origin frame to the newest local
|
||||
effect. Opacity is multiplied. Other properties in `style` use local-last
|
||||
precedence. Consequently, if X→Y and Y→Z overlap:
|
||||
|
||||
```text
|
||||
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
|
||||
component has one stable, keyed host directly under `OriginScene`.
|
||||
|
||||
If Y were physically moved from an X→Y wrapper to the scene root when an edge
|
||||
collapsed, Vue would unmount and recreate Y. Instead, v2 rewrites graph edges
|
||||
and recalculates Y's effect layers while every retained VNode stays in the same
|
||||
flat host.
|
||||
|
||||
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
|
||||
component ancestry.
|
||||
|
||||
## Source layout
|
||||
|
||||
Each module depends only on the ones listed above it.
|
||||
|
||||
| File | Responsibility |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `shared.ts` | Geometry and environment helpers |
|
||||
| `choreography.ts` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
|
||||
| `navigation.ts` | `originView()` recipes and `forward()`, `back()`, `replace()`, `above()`, `under()` actions |
|
||||
| `scene/types.ts` | Public scene types |
|
||||
| `scene/graph.ts` | `SceneGraph`: node and edge storage plus the begin/commit/cancel history rules (all synchronous) |
|
||||
| `scene/scene.ts` | `Scene` and `OperationHandle`: mounting, measuring, settling spring, compositing, and `useOrigin` |
|
||||
| `gesture/definition.ts` | Gesture types, component props, and the immutable `gesture` builder |
|
||||
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
|
||||
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
|
||||
|
||||
Tests live in `tests/`, outside `src/`. `tests/property.test.ts` checks the
|
||||
scene's structural invariants after every step of random operation sequences
|
||||
and pointer streams; set `PROPERTY_SEEDS` to run more than the default 60.
|
||||
|
||||
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
|
||||
`composeNodeStyle`) are module-level `export const`s that the class calls, so
|
||||
they can be tested directly without widening the class's interface.
|
||||
|
||||
## Current experimental boundaries
|
||||
|
||||
- One operation creates one target. Chaining operations already permits any
|
||||
number of simultaneous scene nodes; multi-target routines are not yet
|
||||
exposed as a public builder.
|
||||
- A node can originate one outgoing operation at a time. Its created target
|
||||
may immediately originate the next operation.
|
||||
- The included pointer recognizer handles one primary pointer and one axis.
|
||||
Choreographies and scene operations are independent of it.
|
||||
- Multiple recognizers can share a host and arbitrate by start policy and
|
||||
directional intent. A dedicated multi-pointer gesture arena is not exposed.
|
||||
- Nested `OriginScene` components have independent history and measurements.
|
||||
Child recognizers currently claim propagation at pointer-down, so yielding a
|
||||
region to a parent requires an explicit `.from` policy.
|
||||
- Every pushed history entry remains mounted until back pops it. There is not
|
||||
yet an eviction policy, so applications should deliberately reset long-lived
|
||||
navigation contexts when that API is introduced.
|
||||
- Parked instances remain mounted and ordinary Vue timers/watchers continue to
|
||||
run. Engine-specific park/resume lifecycle hooks are not exposed yet.
|
||||
- Arbitrary CSS properties can be used, but only transforms and opacity have
|
||||
defined multi-operation composition rules at present.
|
||||
|
||||
These boundaries are explicit so the experiment can validate the origin
|
||||
primitive before compatibility conveniences become permanent architecture.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@native-vue-router/core-v2",
|
||||
"version": "0.1.0-experimental.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git",
|
||||
"directory": "packages/core-v2"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"API.md",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"./dist/style.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { CSSProperties } from "vue";
|
||||
import { clamp01, type OriginRect } from "./shared";
|
||||
|
||||
/*
|
||||
* Choreographies: the visual side of an operation. A choreography computes
|
||||
* effects from operation progress; `composeEffects` collapses the effects
|
||||
* applied to one node into its inline style. Nothing here knows about history.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The lifecycle phase of an operation edge.
|
||||
*
|
||||
* - `preparing`: the target has been added and Vue is mounting it.
|
||||
* - `interactive`: progress may be controlled by a gesture or application.
|
||||
* - `settling`: the commit/cancel decision is fixed and the spring is running.
|
||||
* - `finished`: the graph rewrite or cancellation cleanup has completed.
|
||||
*/
|
||||
export type OriginOperationPhase =
|
||||
"preparing" | "interactive" | "settling" | "finished";
|
||||
|
||||
/**
|
||||
* The outcome selected for an operation.
|
||||
*
|
||||
* Intent remains `undecided` until `finish()` is called. It becomes final
|
||||
* before the settling animation completes, allowing the retained target to
|
||||
* originate its own operation immediately.
|
||||
*/
|
||||
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
|
||||
|
||||
/**
|
||||
* One composable contribution to a scene node's final visual style.
|
||||
*
|
||||
* Transforms are concatenated in origin-to-descendant order and opacity values
|
||||
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
|
||||
* routine replacing the other routine's CSS transform.
|
||||
*/
|
||||
export interface OriginEffect {
|
||||
/**
|
||||
* A CSS transform contribution. Transform strings from inherited and local
|
||||
* origin frames are concatenated rather than replacing one another.
|
||||
*/
|
||||
transform?: string;
|
||||
/**
|
||||
* An opacity contribution between `0` and `1`. Contributions from multiple
|
||||
* frames are multiplied and the final value is clamped.
|
||||
*/
|
||||
opacity?: number;
|
||||
/**
|
||||
* Relative stacking contribution. `above()` defaults the target to +1 and
|
||||
* `under()` defaults it to -1.
|
||||
*/
|
||||
layer?: number;
|
||||
/**
|
||||
* Escape hatch for non-geometric effects such as filter, clipPath, or
|
||||
* borderRadius. Later/local effects override inherited properties.
|
||||
*/
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent visual contributions calculated for one operation edge.
|
||||
*
|
||||
* Any omitted contribution is treated as an identity effect.
|
||||
*/
|
||||
export interface OriginEffectSet {
|
||||
/**
|
||||
* Applied to both source and target. This is useful for moving an entire
|
||||
* origin-relative coordinate frame.
|
||||
*/
|
||||
frame?: OriginEffect;
|
||||
/** Applied to the component that initiated this operation. */
|
||||
source?: OriginEffect;
|
||||
/** Applied to the component created by this operation and its descendants. */
|
||||
target?: OriginEffect;
|
||||
}
|
||||
|
||||
/** Values supplied whenever a choreography calculates its visual effects. */
|
||||
export interface OriginChoreographyContext {
|
||||
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
|
||||
progress: number;
|
||||
/**
|
||||
* How far a drag has gone past the ends of `progress`: negative when pulled
|
||||
* back beyond the start, positive beyond full progress, otherwise `0`. Use
|
||||
* it for rubber-banding or resistance; most choreographies ignore it.
|
||||
*/
|
||||
overshoot: number;
|
||||
/**
|
||||
* Normalized progress units per second. Positive velocity moves toward
|
||||
* commit; negative velocity moves back toward cancellation.
|
||||
*/
|
||||
velocity: number;
|
||||
/** Current lifecycle phase of the operation. */
|
||||
phase: OriginOperationPhase;
|
||||
/** Commit/cancel outcome, if release has already selected one. */
|
||||
intent: OriginOperationIntent;
|
||||
/** Origin host bounds captured immediately before the target mounts. */
|
||||
originRect?: OriginRect;
|
||||
/** Target host bounds measured after Vue mounts the target. */
|
||||
targetRect?: OriginRect;
|
||||
/** Scene-container bounds, or the browser viewport when no container exists. */
|
||||
viewport: OriginRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* A choreography describes visual relationships only. Component creation,
|
||||
* history, and cleanup are performed by the scene operation that uses it.
|
||||
*/
|
||||
export interface OriginChoreography {
|
||||
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
|
||||
readonly name?: string;
|
||||
/**
|
||||
* Calculate source, target, and shared-frame contributions for the current
|
||||
* operation state. This function should be deterministic and side-effect
|
||||
* free because it can run many times per animation frame.
|
||||
*/
|
||||
effects(context: OriginChoreographyContext): OriginEffectSet;
|
||||
/**
|
||||
* Gesture progress required to retain the target after release.
|
||||
*
|
||||
* @defaultValue `0.25`
|
||||
*/
|
||||
readonly commitThreshold?: number;
|
||||
/**
|
||||
* Release velocity, in progress units per second, that commits a flick
|
||||
* regardless of progress. Velocity is normalized by the gesture host's
|
||||
* size, so `1` means one full host width (or height) per second.
|
||||
*
|
||||
* @defaultValue `0.2`
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* Settling spring after release. A drawer, a sheet, and a full-page push
|
||||
* usually want different springs.
|
||||
*/
|
||||
readonly spring?: OriginSpring;
|
||||
}
|
||||
|
||||
/** Damped-spring settings for settling a released operation. */
|
||||
export interface OriginSpring {
|
||||
/**
|
||||
* Pull toward the resting position. Higher settles faster.
|
||||
*
|
||||
* @defaultValue `280`
|
||||
*/
|
||||
readonly stiffness?: number;
|
||||
/**
|
||||
* Resistance to motion. Lower overshoots and bounces.
|
||||
*
|
||||
* @defaultValue `30`
|
||||
*/
|
||||
readonly damping?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve type inference while declaring a custom choreography.
|
||||
*
|
||||
* The helper performs no runtime transformation. It gives custom routines a
|
||||
* named, documented construction point and validates their shape in TypeScript.
|
||||
*
|
||||
* @param choreography - Side-effect-free visual effect calculator and optional
|
||||
* release thresholds.
|
||||
* @returns The same choreography object.
|
||||
*/
|
||||
export function defineOriginChoreography(
|
||||
choreography: OriginChoreography,
|
||||
): OriginChoreography {
|
||||
return choreography;
|
||||
}
|
||||
|
||||
/** Distinguish a choreography from a plain options object. */
|
||||
export const isChoreography = (value: unknown): value is OriginChoreography => {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"effects" in value &&
|
||||
typeof value.effects === "function"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize one contribution before the compositor combines it with effects
|
||||
* inherited from earlier origin frames.
|
||||
*
|
||||
* Most applications should return plain effects from a choreography and let
|
||||
* the scene call this function. It is exported for custom compositors and
|
||||
* diagnostics.
|
||||
*
|
||||
* @param effect - Optional effect to normalize.
|
||||
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
|
||||
* @returns A defined effect with a numeric layer.
|
||||
*/
|
||||
export function normalizedEffect(
|
||||
effect: OriginEffect | undefined,
|
||||
fallbackLayer = 0,
|
||||
): OriginEffect {
|
||||
return {
|
||||
...effect,
|
||||
layer: (effect?.layer ?? 0) + fallbackLayer,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a transform contributes anything to a composed transform list. */
|
||||
function isComposable(transform: unknown): transform is string {
|
||||
return (
|
||||
typeof transform === "string" && transform !== "" && transform !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
/** Result of collapsing an ordered list of effects into one style. */
|
||||
export interface ComposedEffects {
|
||||
/** Merged style with the composed `transform` and `opacity` applied. */
|
||||
readonly style: CSSProperties;
|
||||
/** Sum of every effect's relative layer contribution. */
|
||||
readonly layer: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse independent effect layers, ordered from the outermost inherited
|
||||
* frame to the most local one, into a single style.
|
||||
*
|
||||
* Transforms are concatenated and opacities multiplied, including `transform`
|
||||
* and numeric `opacity` supplied through `effect.style`. Every other `style`
|
||||
* property uses local-last precedence over `base` and earlier effects.
|
||||
*/
|
||||
export const composeEffects = (
|
||||
effects: readonly OriginEffect[],
|
||||
base: CSSProperties = {},
|
||||
): ComposedEffects => {
|
||||
const style: CSSProperties = { ...base };
|
||||
const transforms: string[] = [];
|
||||
let opacity = 1;
|
||||
let layer = 0;
|
||||
|
||||
for (const effect of effects) {
|
||||
if (isComposable(effect.transform)) transforms.push(effect.transform);
|
||||
if (effect.opacity !== undefined) opacity *= effect.opacity;
|
||||
layer += effect.layer ?? 0;
|
||||
|
||||
if (effect.style) {
|
||||
Object.assign(style, effect.style);
|
||||
if (isComposable(effect.style.transform))
|
||||
transforms.push(effect.style.transform);
|
||||
if (effect.style.opacity !== undefined) {
|
||||
const styleOpacity = Number(effect.style.opacity);
|
||||
if (Number.isFinite(styleOpacity)) opacity *= styleOpacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign the composed properties last so no single effect's `style` can
|
||||
// replace transform work inherited from other frames.
|
||||
style.transform = transforms.length ? transforms.join(" ") : "none";
|
||||
style.opacity = String(clamp01(opacity));
|
||||
return { style, layer };
|
||||
};
|
||||
|
||||
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
|
||||
|
||||
/**
|
||||
* Native-style forward motion: the target enters above the source while the
|
||||
* source recedes slightly. These presets are examples; applications can
|
||||
* replace them with arbitrary `defineOriginChoreography()` callbacks.
|
||||
*/
|
||||
export const slideLeft = defineOriginChoreography({
|
||||
name: "slide-left",
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
|
||||
opacity: 1 - progress * 0.16,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Back motion reveals the target underneath the source. This is merely a
|
||||
* visual routine; the `back()` action selects retained-history behavior.
|
||||
*/
|
||||
export const slideRight = defineOriginChoreography({
|
||||
name: "slide-right",
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
|
||||
opacity: 0.84 + progress * 0.16,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Cross-fade preset that fades the source out while fading the target in.
|
||||
*
|
||||
* The default release thresholds from the scene are used.
|
||||
*/
|
||||
export const fade = defineOriginChoreography({
|
||||
name: "fade",
|
||||
effects: ({ progress }) => ({
|
||||
source: { opacity: 1 - progress },
|
||||
target: { opacity: progress },
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { OriginGestureProps } from "../gesture/definition";
|
||||
import { useOriginGesture } from "../gesture/recognizer";
|
||||
|
||||
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<OriginGestureProps>(), {
|
||||
as: "div",
|
||||
});
|
||||
|
||||
/*
|
||||
* This convenience component makes the declaration live exactly where the
|
||||
* developer writes it. `useOriginGesture()` is also public for components that
|
||||
* cannot accept an extra wrapper element. A new `gesture` prop applies from
|
||||
* the next pointer-down.
|
||||
*/
|
||||
const gesture = useOriginGesture(() => props.gesture);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
:ref="gesture.ref"
|
||||
v-bind="{ ...$attrs, ...gesture.listeners }"
|
||||
class="nvo-gesture"
|
||||
:style="gesture.style"
|
||||
@pointerdown="gesture.onPointerdown"
|
||||
@pointermove="gesture.onPointermove"
|
||||
@pointerup="gesture.onPointerup"
|
||||
@pointercancel="gesture.onPointercancel"
|
||||
@lostpointercapture="gesture.onLostpointercapture"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { OriginGestureSurfaceProps } from "../gesture/definition";
|
||||
import { useOriginGestures } from "../gesture/recognizer";
|
||||
|
||||
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
|
||||
as: "div",
|
||||
});
|
||||
|
||||
/*
|
||||
* All interaction policy belongs to the component that built the definitions.
|
||||
* This convenience host only installs them, combines their browser scrolling
|
||||
* requirements, and forwards a pointer sequence to every recognizer.
|
||||
*/
|
||||
const surface = useOriginGestures(() => props.gestures);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
:ref="surface.ref"
|
||||
v-bind="{ ...$attrs, ...surface.listeners }"
|
||||
class="nvo-gesture"
|
||||
:style="[$attrs.style, surface.style]"
|
||||
@pointerdown="surface.onPointerdown"
|
||||
@pointermove="surface.onPointermove"
|
||||
@pointerup="surface.onPointerup"
|
||||
@pointercancel="surface.onPointercancel"
|
||||
@lostpointercapture="surface.onLostpointercapture"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
|
||||
import { originNodeScopeKey } from "../scene/scene";
|
||||
import type { OriginScene, OriginSceneNode } from "../scene/types";
|
||||
|
||||
defineOptions({ name: "OriginNodeHost" });
|
||||
|
||||
const props = defineProps<{
|
||||
scene: OriginScene;
|
||||
node: OriginSceneNode;
|
||||
}>();
|
||||
|
||||
/*
|
||||
* This host is the stable physical home of the view component. It is keyed by
|
||||
* the scene node in OriginScene and never nested under another view. Only its
|
||||
* composed CSS style changes while operation edges are created and collapsed.
|
||||
*/
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
provide(originNodeScopeKey, {
|
||||
scene: props.scene,
|
||||
nodeKey: props.node.key,
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
props.scene.registerElement(props.node.key, host.value);
|
||||
});
|
||||
onBeforeUnmount(() => props.scene.registerElement(props.node.key, null));
|
||||
|
||||
const style = computed(() => props.scene.styleForNode(props.node.key));
|
||||
const interactive = computed(() =>
|
||||
props.scene.isNodeInteractive(props.node.key),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="host"
|
||||
class="nvo-node"
|
||||
:style="style"
|
||||
:data-origin-node="node.key"
|
||||
:data-origin-view="node.view.name ?? node.view.key"
|
||||
:data-origin-state="node.state"
|
||||
:aria-hidden="interactive ? undefined : 'true'"
|
||||
:inert="interactive ? undefined : true"
|
||||
>
|
||||
<!--
|
||||
Vue owns the component lifecycle normally. Adding another origin merely
|
||||
adds effect layers to this host; it does not replace this component VNode.
|
||||
-->
|
||||
<component :is="node.view.component" v-bind="node.view.props" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import type { OriginSceneProps } from "../scene/types";
|
||||
import OriginNodeHost from "./OriginNodeHost.vue";
|
||||
|
||||
defineOptions({ name: "OriginScene" });
|
||||
|
||||
const props = defineProps<OriginSceneProps>();
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
watchEffect(() => props.scene.registerContainer(root.value));
|
||||
onBeforeUnmount(() => props.scene.registerContainer(null));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
All component hosts are siblings. The operation graph is intentionally not
|
||||
mirrored as DOM ancestry because promoting Y after X→Y must not remount Y.
|
||||
-->
|
||||
<main
|
||||
ref="root"
|
||||
class="nvo-scene"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
isolation: 'isolate',
|
||||
}"
|
||||
>
|
||||
<OriginNodeHost
|
||||
v-for="node in scene.nodes.value"
|
||||
:key="node.key"
|
||||
:scene="scene"
|
||||
:node="node"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,628 @@
|
||||
import type { CSSProperties, MaybeRefOrGetter } from "vue";
|
||||
import type { OriginChoreography } from "../choreography";
|
||||
import type { OriginNavigationIntent } from "../navigation";
|
||||
import type { OriginContext } from "../scene/types";
|
||||
import type { OriginRect } from "../shared";
|
||||
import { isAtScrollEdge, withinEdge } from "./recognizer";
|
||||
|
||||
/*
|
||||
* What a gesture is: the public gesture types, component props, and the
|
||||
* immutable builder that produces executable definitions. Running them is
|
||||
* the recognizer's job.
|
||||
*/
|
||||
|
||||
/** Direction in which pointer movement advances operation progress. */
|
||||
export type OriginGestureDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
/** Physical side of the gesture host used to admit pointer-down. */
|
||||
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
|
||||
|
||||
/**
|
||||
* CSS length accepted by an edge-based gesture start rule.
|
||||
*
|
||||
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
|
||||
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
|
||||
* host when pointer-down occurs.
|
||||
*/
|
||||
export type OriginGestureDistance = number | string;
|
||||
|
||||
/** A pointer position measured in viewport and gesture-host coordinates. */
|
||||
export interface OriginGesturePoint {
|
||||
/** Viewport-relative horizontal position. */
|
||||
readonly clientX: number;
|
||||
/** Viewport-relative vertical position. */
|
||||
readonly clientY: number;
|
||||
/** Horizontal position relative to the gesture host's left edge. */
|
||||
readonly localX: number;
|
||||
/** Vertical position relative to the gesture host's top edge. */
|
||||
readonly localY: number;
|
||||
}
|
||||
|
||||
/** Context supplied to a custom `.from.when()` start predicate. */
|
||||
export interface OriginGestureStartContext {
|
||||
/** Native pointer-down event being considered. */
|
||||
readonly event: PointerEvent;
|
||||
/** Component instance from which the gesture would originate. */
|
||||
readonly origin: OriginContext;
|
||||
/** Element carrying the gesture's pointer handlers. */
|
||||
readonly host: HTMLElement;
|
||||
/** Gesture-host bounds captured at pointer-down. */
|
||||
readonly bounds: OriginRect;
|
||||
/** Pointer position at pointer-down. */
|
||||
readonly point: OriginGesturePoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous predicate deciding whether a pointer-down may become a gesture.
|
||||
*
|
||||
* Directional recognition still occurs later, after movement passes the
|
||||
* configured intent threshold.
|
||||
*/
|
||||
export type OriginGestureStartPredicate = (
|
||||
context: OriginGestureStartContext,
|
||||
) => boolean;
|
||||
|
||||
/** Start policy stored in a completed gesture definition. */
|
||||
export type OriginGestureStart =
|
||||
| {
|
||||
/** Recognize pointer-down anywhere on the host. */
|
||||
readonly kind: "anywhere";
|
||||
}
|
||||
| {
|
||||
/** Recognize pointer-down within a CSS distance of one host side. */
|
||||
readonly kind: "edge";
|
||||
readonly edge: OriginGestureEdge;
|
||||
readonly distance: OriginGestureDistance;
|
||||
}
|
||||
| {
|
||||
/** Recognize pointer-down when application policy returns `true`. */
|
||||
readonly kind: "when";
|
||||
readonly predicate: OriginGestureStartPredicate;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Recognize pointer-down while a scroller is at one edge. The gesture
|
||||
* shares its axis with that scroller's native scrolling.
|
||||
*/
|
||||
readonly kind: "scroll-edge";
|
||||
readonly scroller: OriginGestureScroller;
|
||||
readonly edge: OriginGestureEdge;
|
||||
};
|
||||
|
||||
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
|
||||
export interface OriginGestureDirectionOptions {
|
||||
/**
|
||||
* Minimum directed movement in CSS pixels before the gesture captures.
|
||||
*
|
||||
* @defaultValue `8`
|
||||
*/
|
||||
readonly threshold?: number;
|
||||
/**
|
||||
* Ratio by which directed movement must exceed cross-axis movement.
|
||||
*
|
||||
* @defaultValue `1.15`
|
||||
*/
|
||||
readonly axisDominance?: number;
|
||||
/**
|
||||
* Movement that counts as full progress (`1`), resolved at pointer-down.
|
||||
* A CSS length (numbers are pixels, `%` is of the host), or a function
|
||||
* returning pixels, for example to measure a drawer narrower than the host.
|
||||
*
|
||||
* @defaultValue the host's width (horizontal) or height (vertical)
|
||||
*/
|
||||
readonly distance?: OriginGestureProgressDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Movement that counts as full progress: a CSS length, or a function that
|
||||
* returns pixels, measured at pointer-down.
|
||||
*/
|
||||
export type OriginGestureProgressDistance =
|
||||
| OriginGestureDistance
|
||||
| ((context: {
|
||||
readonly host: HTMLElement;
|
||||
readonly bounds: OriginRect;
|
||||
}) => number);
|
||||
|
||||
/** Values available when a custom `.complete()` policy runs on pointer-up. */
|
||||
export interface OriginGestureCompletionContext {
|
||||
/** Component instance that originated the gesture. */
|
||||
readonly origin: OriginContext;
|
||||
/** Recognized movement direction. */
|
||||
readonly direction: OriginGestureDirection;
|
||||
/** Normalized directed distance, clamped to `0..1`. */
|
||||
readonly progress: number;
|
||||
/** Latest normalized progress units per second. */
|
||||
readonly velocity: number;
|
||||
/** Directed movement from pointer-down in CSS pixels. */
|
||||
readonly distance: number;
|
||||
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
|
||||
readonly crossDistance: number;
|
||||
/** Elapsed time since pointer-down in milliseconds. */
|
||||
readonly duration: number;
|
||||
/** Native pointer-up event that ended the interaction. */
|
||||
readonly event: PointerEvent;
|
||||
/** Element carrying the gesture's pointer handlers. */
|
||||
readonly host: HTMLElement;
|
||||
/** Gesture-host bounds captured at pointer-down. */
|
||||
readonly bounds: OriginRect;
|
||||
/** Pointer position captured at pointer-down. */
|
||||
readonly start: OriginGesturePoint;
|
||||
/** Pointer position at release. */
|
||||
readonly current: OriginGesturePoint;
|
||||
}
|
||||
|
||||
/** Synchronous commit/cancel policy installed by `.complete()`. */
|
||||
export type OriginGestureCompletionPredicate = (
|
||||
context: OriginGestureCompletionContext,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Values accepted from a gesture builder's `.navigate()` factory.
|
||||
*
|
||||
* A promise supports lazy view selection. Stale resolutions are discarded if
|
||||
* the pointer has already ended or been cancelled.
|
||||
*/
|
||||
export type MaybeOriginNavigationIntent =
|
||||
| OriginNavigationIntent
|
||||
| null
|
||||
| undefined
|
||||
| Promise<OriginNavigationIntent | null | undefined>;
|
||||
|
||||
/** Factory that resolves navigation after directional recognition succeeds. */
|
||||
export type OriginGestureNavigationFactory = (
|
||||
context: OriginContext,
|
||||
) => MaybeOriginNavigationIntent;
|
||||
|
||||
/**
|
||||
* Immutable, executable result of a complete gesture builder chain.
|
||||
*
|
||||
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
|
||||
* represented as an `anywhere` start rule.
|
||||
*/
|
||||
export interface OriginGestureDefinition {
|
||||
/** Identifies a definition produced by the {@link gesture} builder. */
|
||||
readonly kind: "origin-gesture-definition";
|
||||
/** Pointer-down eligibility policy. */
|
||||
readonly start: OriginGestureStart;
|
||||
/** Direction in which movement advances operation progress. */
|
||||
readonly direction: OriginGestureDirection;
|
||||
/** Directional intent recognition tuning. */
|
||||
readonly recognition: Readonly<OriginGestureDirectionOptions>;
|
||||
/**
|
||||
* Optional release decision. When omitted, choreography thresholds decide.
|
||||
*/
|
||||
readonly completion?: OriginGestureCompletionPredicate;
|
||||
/** Node-local destination/history resolver. */
|
||||
readonly navigation: OriginGestureNavigationFactory;
|
||||
/** Visual routine paired with the navigation intent. */
|
||||
readonly choreography: OriginChoreography;
|
||||
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
|
||||
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
|
||||
/** Handlers run when a drag starts to qualify to commit. See `.onArm()`. */
|
||||
readonly armHandlers?: readonly OriginGestureCommitHandler[];
|
||||
/** Handlers run when a drag stops qualifying to commit. */
|
||||
readonly disarmHandlers?: readonly OriginGestureCommitHandler[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Side effect run when a released gesture commits, such as haptic feedback.
|
||||
*
|
||||
* It runs once, synchronously at release, after the commit decision and
|
||||
* before the settling animation. It receives the same context as a
|
||||
* `.complete()` predicate. Errors are reported without interrupting the
|
||||
* operation.
|
||||
*/
|
||||
export type OriginGestureCommitHandler = (
|
||||
context: OriginGestureCompletionContext,
|
||||
) => void;
|
||||
|
||||
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
|
||||
export interface OriginGestureToBuilder {
|
||||
/** Recognize leftward pointer movement. */
|
||||
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||
/** Recognize rightward pointer movement. */
|
||||
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||
/** Recognize upward pointer movement. */
|
||||
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||
/** Recognize downward pointer movement. */
|
||||
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||
}
|
||||
|
||||
/** Stage produced after a `.from` policy has been selected. */
|
||||
export interface OriginGestureFromSelection {
|
||||
/** Select the direction that advances this gesture. */
|
||||
readonly to: OriginGestureToBuilder;
|
||||
}
|
||||
|
||||
/** Optional pointer-down policy exposed at the start of a gesture chain. */
|
||||
export interface OriginGestureFromBuilder {
|
||||
/** Admit pointer-down within `distance` of the host's left edge. */
|
||||
left(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||
/** Admit pointer-down within `distance` of the host's right edge. */
|
||||
right(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||
/** Admit pointer-down within `distance` of the host's top edge. */
|
||||
top(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||
/** Admit pointer-down within `distance` of the host's bottom edge. */
|
||||
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||
/** Admit pointer-down anywhere on the gesture host. */
|
||||
anywhere(): OriginGestureFromSelection;
|
||||
/** Admit pointer-down when a synchronous application predicate succeeds. */
|
||||
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
|
||||
/**
|
||||
* Admit pointer-down only while `scroller` is scrolled to `edge`, for
|
||||
* example drag-down-to-dismiss only at the top of a list. Movement the
|
||||
* other way is left to the scroller. See {@link atScrollEdge}.
|
||||
*/
|
||||
scrollEdge(
|
||||
scroller: OriginGestureScroller,
|
||||
edge: OriginGestureEdge,
|
||||
): OriginGestureFromSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scroll container: an element, a ref or getter returning one, or a CSS
|
||||
* selector resolved inside the gesture host.
|
||||
*/
|
||||
export type OriginGestureScroller =
|
||||
MaybeRefOrGetter<Element | null | undefined> | string;
|
||||
|
||||
/** Builder stage after direction is known and completion remains optional. */
|
||||
export interface OriginGestureDirectedBuilder {
|
||||
/** Override the choreography's default release decision. */
|
||||
complete(
|
||||
predicate: OriginGestureCompletionPredicate,
|
||||
): OriginGestureCompletedBuilder;
|
||||
/** Select the target/history mutation while retaining default completion. */
|
||||
navigate(
|
||||
factory: OriginGestureNavigationFactory,
|
||||
): OriginGestureNavigationBuilder;
|
||||
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
|
||||
/**
|
||||
* Run `handler` during a drag when releasing would start to commit, for
|
||||
* example for a "release now" haptic. See `.onArm()` in the API reference.
|
||||
*/
|
||||
onArm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
|
||||
/** Run `handler` during a drag when releasing would no longer commit. */
|
||||
onDisarm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
|
||||
}
|
||||
|
||||
/** Builder stage after a custom completion policy has been selected. */
|
||||
export interface OriginGestureCompletedBuilder {
|
||||
/** Select the target/history mutation performed on commit. */
|
||||
navigate(
|
||||
factory: OriginGestureNavigationFactory,
|
||||
): OriginGestureNavigationBuilder;
|
||||
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
|
||||
/**
|
||||
* Run `handler` during a drag when releasing would start to commit, for
|
||||
* example for a "release now" haptic. See `.onArm()` in the API reference.
|
||||
*/
|
||||
onArm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
|
||||
/** Run `handler` during a drag when releasing would no longer commit. */
|
||||
onDisarm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
|
||||
}
|
||||
|
||||
/** Final builder stage waiting for visual choreography. */
|
||||
export interface OriginGestureNavigationBuilder {
|
||||
/** Attach visual choreography and produce an executable definition. */
|
||||
animate(choreography: OriginChoreography): OriginGestureDefinition;
|
||||
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
|
||||
/**
|
||||
* Run `handler` during a drag when releasing would start to commit, for
|
||||
* example for a "release now" haptic. See `.onArm()` in the API reference.
|
||||
*/
|
||||
onArm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
|
||||
/** Run `handler` during a drag when releasing would no longer commit. */
|
||||
onDisarm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
|
||||
}
|
||||
|
||||
/** Root of the immutable fluent gesture-definition API. */
|
||||
export interface OriginGestureBuilder {
|
||||
/** Optionally constrain where pointer-down may begin. */
|
||||
readonly from: OriginGestureFromBuilder;
|
||||
/**
|
||||
* Select movement direction with an implicit `from.anywhere()` start.
|
||||
*/
|
||||
readonly to: OriginGestureToBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM bindings returned by {@link useOriginGesture}.
|
||||
*
|
||||
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
|
||||
* when {@link needsTouchmove} is true), and apply
|
||||
* {@link style}: its `touch-action` keeps native scrolling on every axis no
|
||||
* gesture claims.
|
||||
*/
|
||||
export interface OriginGestureBinding {
|
||||
/**
|
||||
* Required size and `touch-action` styles for the gesture host. Reading it
|
||||
* inside a render function tracks changes to the gesture definitions.
|
||||
*/
|
||||
readonly style: Readonly<CSSProperties>;
|
||||
/** Pointer-down handler that records a potentially eligible gesture. */
|
||||
readonly onPointerdown: (event: PointerEvent) => void;
|
||||
/** Pointer-move handler that recognizes and updates the operation. */
|
||||
readonly onPointermove: (event: PointerEvent) => void;
|
||||
/** Pointer-up handler that commits or cancels using progress and velocity. */
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
/** Pointer-cancel handler that abandons any captured operation. */
|
||||
readonly onPointercancel: () => void;
|
||||
/**
|
||||
* Lost-capture handler. Cancels a captured gesture whose pointer capture
|
||||
* ends without a pointer-up, so the operation never stays stuck mid-way.
|
||||
*/
|
||||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||
/**
|
||||
* Touch-move handler for gestures that share an axis with native scrolling
|
||||
* (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
|
||||
* claims, and leaves every other touch to the browser. Attach it as a
|
||||
* non-passive listener (Vue's `@touchmove` is), and only when
|
||||
* {@link needsTouchmove} is true.
|
||||
*/
|
||||
readonly onTouchmove: (event: TouchEvent) => void;
|
||||
/**
|
||||
* Whether any gesture shares an axis with scrolling, so `onTouchmove` must
|
||||
* be attached. Leave it off otherwise: a non-passive touch listener makes
|
||||
* the browser wait for JavaScript on every touch move, which costs frames.
|
||||
*/
|
||||
readonly needsTouchmove: boolean;
|
||||
/**
|
||||
* Extra listeners the host needs, as Vue props: `v-bind` them on the host.
|
||||
* Scroll-edge gestures track their scroller here (a passive scroll
|
||||
* listener), plus `onTouchmove` where the browser lacks directional
|
||||
* `touch-action`. Empty for ordinary gestures.
|
||||
*/
|
||||
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
|
||||
/** Function ref for the host element: `:ref="binding.ref"`. */
|
||||
readonly ref: (element: unknown) => void;
|
||||
}
|
||||
|
||||
// Component props ----------------------------------------------------------
|
||||
|
||||
/** Public props accepted by the `OriginGesture` convenience component. */
|
||||
export interface OriginGestureProps {
|
||||
/**
|
||||
* Native HTML tag rendered as the gesture host.
|
||||
*
|
||||
* @defaultValue `"div"`
|
||||
*/
|
||||
as?: string;
|
||||
/**
|
||||
* Immutable definition produced by the {@link gesture} builder. Replacing
|
||||
* it takes effect from the next pointer-down; a gesture in progress keeps
|
||||
* the definition it started with.
|
||||
*/
|
||||
gesture: OriginGestureDefinition;
|
||||
}
|
||||
|
||||
/** Public props accepted by the multi-definition gesture surface component. */
|
||||
export interface OriginGestureSurfaceProps {
|
||||
/**
|
||||
* Native HTML tag rendered as the shared gesture host.
|
||||
*
|
||||
* @defaultValue `"div"`
|
||||
*/
|
||||
as?: string;
|
||||
/**
|
||||
* Complete immutable definitions installed on the shared host.
|
||||
*
|
||||
* The surface adds no start, direction, completion, navigation, or animation
|
||||
* policy. Definitions should be created by the owning page component. The
|
||||
* list may change; a gesture in progress keeps the definition it started
|
||||
* with.
|
||||
*/
|
||||
gestures: readonly OriginGestureDefinition[];
|
||||
}
|
||||
|
||||
// Builder ------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* Immutable fluent builder. Each stage is a frozen object holding the choices
|
||||
* made so far in a frozen draft; the final `.animate()` produces a frozen
|
||||
* definition. `.onCommit()` is available at every stage after the direction
|
||||
* and returns the same stage with the handler appended.
|
||||
*
|
||||
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
|
||||
*/
|
||||
|
||||
/** Choices accumulated by the builder before `.animate()`. */
|
||||
interface GestureDraft {
|
||||
readonly start: OriginGestureStart;
|
||||
readonly direction: OriginGestureDirection;
|
||||
readonly recognition: Readonly<OriginGestureDirectionOptions>;
|
||||
readonly completion?: OriginGestureCompletionPredicate;
|
||||
readonly commitHandlers: readonly OriginGestureCommitHandler[];
|
||||
readonly armHandlers: readonly OriginGestureCommitHandler[];
|
||||
readonly disarmHandlers: readonly OriginGestureCommitHandler[];
|
||||
}
|
||||
|
||||
type HandlerList = "commitHandlers" | "armHandlers" | "disarmHandlers";
|
||||
|
||||
/**
|
||||
* `.onCommit()`, `.onArm()`, and `.onDisarm()` for a stage: each rebuilds the
|
||||
* same stage from the draft with the handler appended.
|
||||
*/
|
||||
function hookMethods<Draft extends GestureDraft, Stage>(
|
||||
draft: Draft,
|
||||
stage: (draft: Draft) => Stage,
|
||||
) {
|
||||
const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) =>
|
||||
stage(
|
||||
Object.freeze({
|
||||
...draft,
|
||||
[list]: Object.freeze([...draft[list], handler]),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
onCommit: append("commitHandlers"),
|
||||
onArm: append("armHandlers"),
|
||||
onDisarm: append("disarmHandlers"),
|
||||
};
|
||||
}
|
||||
|
||||
function navigationStage(
|
||||
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
|
||||
): OriginGestureNavigationBuilder {
|
||||
return Object.freeze({
|
||||
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
|
||||
Object.freeze({
|
||||
kind: "origin-gesture-definition",
|
||||
start: draft.start,
|
||||
direction: draft.direction,
|
||||
recognition: draft.recognition,
|
||||
completion: draft.completion,
|
||||
navigation: draft.navigation,
|
||||
choreography,
|
||||
commitHandlers: draft.commitHandlers,
|
||||
armHandlers: draft.armHandlers,
|
||||
disarmHandlers: draft.disarmHandlers,
|
||||
}),
|
||||
...hookMethods(draft, navigationStage),
|
||||
});
|
||||
}
|
||||
|
||||
const navigateFrom =
|
||||
(draft: GestureDraft) => (navigation: OriginGestureNavigationFactory) =>
|
||||
navigationStage(Object.freeze({ ...draft, navigation }));
|
||||
|
||||
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
|
||||
return Object.freeze({
|
||||
navigate: navigateFrom(draft),
|
||||
...hookMethods(draft, completedStage),
|
||||
});
|
||||
}
|
||||
|
||||
function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
|
||||
return Object.freeze({
|
||||
complete: (completion: OriginGestureCompletionPredicate) =>
|
||||
completedStage(Object.freeze({ ...draft, completion })),
|
||||
navigate: navigateFrom(draft),
|
||||
...hookMethods(draft, directedStage),
|
||||
});
|
||||
}
|
||||
|
||||
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
|
||||
const direct =
|
||||
(direction: OriginGestureDirection) =>
|
||||
(options: OriginGestureDirectionOptions = {}) =>
|
||||
directedStage(
|
||||
Object.freeze({
|
||||
start,
|
||||
direction,
|
||||
recognition: Object.freeze({ ...options }),
|
||||
commitHandlers: Object.freeze([]),
|
||||
armHandlers: Object.freeze([]),
|
||||
disarmHandlers: Object.freeze([]),
|
||||
}),
|
||||
);
|
||||
return Object.freeze({
|
||||
left: direct("left"),
|
||||
right: direct("right"),
|
||||
up: direct("up"),
|
||||
down: direct("down"),
|
||||
});
|
||||
}
|
||||
|
||||
function fromStage(start: OriginGestureStart): OriginGestureFromSelection {
|
||||
return Object.freeze({ to: toStage(Object.freeze(start)) });
|
||||
}
|
||||
|
||||
const fromEdge =
|
||||
(edge: OriginGestureEdge) => (distance: OriginGestureDistance) =>
|
||||
fromStage({ kind: "edge", edge, distance });
|
||||
|
||||
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
||||
left: fromEdge("left"),
|
||||
right: fromEdge("right"),
|
||||
top: fromEdge("top"),
|
||||
bottom: fromEdge("bottom"),
|
||||
anywhere: () => fromStage({ kind: "anywhere" }),
|
||||
when: (predicate: OriginGestureStartPredicate) =>
|
||||
fromStage({ kind: "when", predicate }),
|
||||
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
|
||||
fromStage({ kind: "scroll-edge", scroller, edge }),
|
||||
});
|
||||
|
||||
// Start rules ---------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* Composable `.from.when()` predicates. Each returns a start predicate, so
|
||||
* they combine with anyOf() and allOf():
|
||||
*
|
||||
* gesture.from
|
||||
* .when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
|
||||
* .to.down()
|
||||
*/
|
||||
|
||||
/** Admit pointer-down on an element matching `selector`, or inside one. */
|
||||
export const startedIn =
|
||||
(selector: string): OriginGestureStartPredicate =>
|
||||
({ event }) =>
|
||||
event.target instanceof Element && event.target.closest(selector) !== null;
|
||||
|
||||
/** Admit pointer-down within `distance` of one side of the gesture host. */
|
||||
export const nearEdge =
|
||||
(
|
||||
edge: OriginGestureEdge,
|
||||
distance: OriginGestureDistance,
|
||||
): OriginGestureStartPredicate =>
|
||||
({ host, bounds, point }) =>
|
||||
withinEdge(edge, distance, host, bounds, point);
|
||||
|
||||
/**
|
||||
* Admit pointer-down while `scroller` is scrolled all the way to `edge`.
|
||||
* A scroller that is missing (not yet mounted) counts as at the edge.
|
||||
*/
|
||||
export const atScrollEdge =
|
||||
(
|
||||
scroller: OriginGestureScroller,
|
||||
edge: OriginGestureEdge,
|
||||
): OriginGestureStartPredicate =>
|
||||
({ host }) =>
|
||||
isAtScrollEdge(scroller, edge, host);
|
||||
|
||||
/** Admit pointer-down when any of `predicates` does. */
|
||||
export const anyOf =
|
||||
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
|
||||
(context) =>
|
||||
predicates.some((predicate) => predicate(context));
|
||||
|
||||
/** Admit pointer-down only when all of `predicates` do. */
|
||||
export const allOf =
|
||||
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
|
||||
(context) =>
|
||||
predicates.every((predicate) => predicate(context));
|
||||
|
||||
/**
|
||||
* Root of the immutable gesture builder.
|
||||
*
|
||||
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
|
||||
* bound host, exactly like `.from.anywhere().to`.
|
||||
*
|
||||
* @example Anywhere-to-right back gesture
|
||||
* ```ts
|
||||
* const swipeBack = gesture
|
||||
* .to.right()
|
||||
* .navigate((context) => context.canGoBack ? back() : null)
|
||||
* .animate(slideRight);
|
||||
* ```
|
||||
*
|
||||
* @example Predicate-gated gesture with custom completion
|
||||
* ```ts
|
||||
* const openPanel = gesture
|
||||
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
|
||||
* .to.down()
|
||||
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
|
||||
* .navigate(() => above(originView(PanelView)))
|
||||
* .animate(dropPanel);
|
||||
* ```
|
||||
*/
|
||||
export const gesture: OriginGestureBuilder = Object.freeze({
|
||||
from: fromBuilder,
|
||||
to: toStage(Object.freeze({ kind: "anywhere" })),
|
||||
});
|
||||
@@ -0,0 +1,919 @@
|
||||
import {
|
||||
computed,
|
||||
onScopeDispose,
|
||||
shallowRef,
|
||||
toValue,
|
||||
type MaybeRefOrGetter,
|
||||
} from "vue";
|
||||
import type { OriginAction } from "../navigation";
|
||||
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
|
||||
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
OriginGestureDefinition,
|
||||
OriginGestureCompletionContext,
|
||||
OriginGestureDirection,
|
||||
OriginGestureDistance,
|
||||
OriginGestureEdge,
|
||||
OriginGesturePoint,
|
||||
OriginGestureProgressDistance,
|
||||
OriginGestureScroller,
|
||||
OriginGestureStart,
|
||||
} from "./definition";
|
||||
|
||||
/*
|
||||
* Running gestures: pointer-down admission, movement tracking, and the
|
||||
* recognizer that turns one host's pointer events into a scene operation.
|
||||
*/
|
||||
|
||||
// Direction ----------------------------------------------------------------
|
||||
|
||||
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
|
||||
return direction === "left" || direction === "right";
|
||||
};
|
||||
|
||||
/** Component of a movement `(dx, dy)` along `direction`. */
|
||||
export const directedDistance = (
|
||||
direction: OriginGestureDirection,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): number => {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
return -dx;
|
||||
case "right":
|
||||
return dx;
|
||||
case "up":
|
||||
return -dy;
|
||||
case "down":
|
||||
return dy;
|
||||
}
|
||||
};
|
||||
|
||||
/** Absolute movement perpendicular to `direction`. */
|
||||
export const crossDistance = (
|
||||
direction: OriginGestureDirection,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): number => {
|
||||
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
|
||||
};
|
||||
|
||||
/** Whether a gesture shares its axis with native scrolling. */
|
||||
export const sharesScroll = (definition: OriginGestureDefinition): boolean =>
|
||||
definition.start.kind === "scroll-edge";
|
||||
|
||||
/**
|
||||
* Whether the browser supports direction-specific `touch-action` values
|
||||
* (`pan-up`, `pan-down`, …). Chromium does; where it is missing (Safari),
|
||||
* scroll-edge gestures fall back to claiming touches from JavaScript.
|
||||
*/
|
||||
export const supportsDirectionalPan = (): boolean =>
|
||||
typeof CSS !== "undefined" &&
|
||||
typeof CSS.supports === "function" &&
|
||||
CSS.supports("touch-action", "pan-down");
|
||||
|
||||
type PanDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
/**
|
||||
* The pan a finger moving `direction` would start. A finger moving down pans
|
||||
* the content up (toward its top), which is what pull-to-refresh reserves.
|
||||
*/
|
||||
const PAN_OF: Record<OriginGestureDirection, PanDirection> = {
|
||||
down: "up",
|
||||
up: "down",
|
||||
left: "right",
|
||||
right: "left",
|
||||
};
|
||||
|
||||
/**
|
||||
* CSS `touch-action` for a gesture host. The browser keeps every pan that no
|
||||
* gesture needs, so native scrolling never waits for JavaScript:
|
||||
*
|
||||
* - a gesture that owns its axis removes that axis (`pan-y` remains for a
|
||||
* horizontal gesture);
|
||||
* - a gesture sharing its axis with a scroller (`.from.scrollEdge()`) removes
|
||||
* only the one pan its finger direction would start, and only while the
|
||||
* scroller is at its edge (`pan-x pan-down` for drag-down at the top), the
|
||||
* pull-to-refresh pattern from the Pointer Events specification.
|
||||
*
|
||||
* @param atEdge - Whether a scroll-edge definition's scroller is at its edge.
|
||||
* @param directional - Whether directional pan values are supported; without
|
||||
* them, shared axes stay fully pannable and `onTouchmove` claims touches.
|
||||
*/
|
||||
export const touchActionFor = (
|
||||
definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
|
||||
atEdge: (
|
||||
definition: Pick<OriginGestureDefinition, "start">,
|
||||
) => boolean = () => false,
|
||||
directional = supportsDirectionalPan(),
|
||||
): string => {
|
||||
const pans = new Set<PanDirection>(["left", "right", "up", "down"]);
|
||||
let any = false;
|
||||
for (const definition of definitions) {
|
||||
any = true;
|
||||
if (definition.start.kind === "scroll-edge") {
|
||||
if (directional && atEdge(definition))
|
||||
pans.delete(PAN_OF[definition.direction]);
|
||||
continue;
|
||||
}
|
||||
const pair: PanDirection[] = isHorizontal(definition.direction)
|
||||
? ["left", "right"]
|
||||
: ["up", "down"];
|
||||
for (const pan of pair) pans.delete(pan);
|
||||
}
|
||||
if (!any) return "auto";
|
||||
if (pans.size === 4) return "manipulation";
|
||||
if (pans.size === 0) return "none";
|
||||
const axis = (a: PanDirection, b: PanDirection, both: string) =>
|
||||
pans.has(a) && pans.has(b)
|
||||
? [both]
|
||||
: pans.has(a)
|
||||
? [`pan-${a}`]
|
||||
: pans.has(b)
|
||||
? [`pan-${b}`]
|
||||
: [];
|
||||
return [
|
||||
...axis("left", "right", "pan-x"),
|
||||
...axis("up", "down", "pan-y"),
|
||||
].join(" ");
|
||||
};
|
||||
|
||||
// Pointer-down admission ----------------------------------------------------
|
||||
|
||||
/*
|
||||
* Pointer-down admission: which events may begin a gesture at all.
|
||||
*/
|
||||
|
||||
const IGNORED_TARGETS =
|
||||
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
|
||||
|
||||
/** Form controls and opted-out subtrees never start gestures. */
|
||||
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
|
||||
return (
|
||||
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
|
||||
);
|
||||
};
|
||||
|
||||
export const pointOf = (
|
||||
event: Pick<PointerEvent, "clientX" | "clientY">,
|
||||
bounds: OriginRect,
|
||||
): OriginGesturePoint => {
|
||||
return {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
localX: event.clientX - bounds.left,
|
||||
localY: event.clientY - bounds.top,
|
||||
};
|
||||
};
|
||||
|
||||
const nonNegative = (value: number) =>
|
||||
Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
|
||||
const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/;
|
||||
const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
|
||||
|
||||
/**
|
||||
* Resolve a CSS length against a box with the gesture host's size.
|
||||
*
|
||||
* Numbers, pixel lengths, and percentages are computed directly. Anything
|
||||
* else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a
|
||||
* short-lived off-screen box so the browser resolves it exactly. This runs
|
||||
* only during pointer-down for edge-constrained definitions.
|
||||
*/
|
||||
export const resolveCssDistance = (
|
||||
distance: OriginGestureDistance,
|
||||
axis: "horizontal" | "vertical",
|
||||
host: HTMLElement,
|
||||
bounds: OriginRect,
|
||||
): number => {
|
||||
if (typeof distance === "number") return nonNegative(distance);
|
||||
const pixels = PIXELS.exec(distance);
|
||||
if (pixels) return nonNegative(Number(pixels[1]));
|
||||
const percent = PERCENT.exec(distance);
|
||||
if (percent) {
|
||||
const size = axis === "horizontal" ? bounds.width : bounds.height;
|
||||
return nonNegative((Number(percent[1]) / 100) * size);
|
||||
}
|
||||
|
||||
const document = host.ownerDocument;
|
||||
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
||||
|
||||
const container = document.createElement("div");
|
||||
const probe = document.createElement("div");
|
||||
Object.assign(container.style, {
|
||||
position: "fixed",
|
||||
left: "-100000px",
|
||||
top: "-100000px",
|
||||
width: `${bounds.width}px`,
|
||||
height: `${bounds.height}px`,
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
contain: "strict",
|
||||
});
|
||||
Object.assign(probe.style, {
|
||||
position: "absolute",
|
||||
width: axis === "horizontal" ? distance : "0",
|
||||
height: axis === "vertical" ? distance : "0",
|
||||
});
|
||||
container.append(probe);
|
||||
document.body.append(container);
|
||||
const rect = probe.getBoundingClientRect();
|
||||
container.remove();
|
||||
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
|
||||
};
|
||||
|
||||
/** Whether a pointer-down satisfies a definition's start policy. */
|
||||
export const matchesStart = (
|
||||
start: OriginGestureStart,
|
||||
event: PointerEvent,
|
||||
host: HTMLElement,
|
||||
origin: OriginContext,
|
||||
bounds: OriginRect,
|
||||
): boolean => {
|
||||
if (start.kind === "anywhere") return true;
|
||||
const point = pointOf(event, bounds);
|
||||
if (start.kind === "when")
|
||||
return start.predicate({ event, origin, host, bounds, point });
|
||||
if (start.kind === "scroll-edge")
|
||||
return isAtScrollEdge(start.scroller, start.edge, host);
|
||||
|
||||
return withinEdge(start.edge, start.distance, host, bounds, point);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether `scroller` is scrolled all the way to `edge`. A scroller that is
|
||||
* missing (not yet mounted) counts as at the edge.
|
||||
*/
|
||||
export const isAtScrollEdge = (
|
||||
scroller: OriginGestureScroller,
|
||||
edge: OriginGestureEdge,
|
||||
host: HTMLElement,
|
||||
): boolean => {
|
||||
const element =
|
||||
typeof scroller === "string"
|
||||
? host.querySelector(scroller)
|
||||
: toValue(scroller);
|
||||
if (!element) return true;
|
||||
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
|
||||
const { clientHeight, clientWidth } = element;
|
||||
switch (edge) {
|
||||
case "top":
|
||||
return scrollTop <= 0;
|
||||
case "bottom":
|
||||
return scrollTop + clientHeight >= scrollHeight - 1;
|
||||
case "left":
|
||||
return scrollLeft <= 0;
|
||||
case "right":
|
||||
return scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether `point` lies within `distance` of one side of the host. */
|
||||
export const withinEdge = (
|
||||
edge: OriginGestureEdge,
|
||||
distance: OriginGestureDistance,
|
||||
host: HTMLElement,
|
||||
bounds: OriginRect,
|
||||
point: OriginGesturePoint,
|
||||
): boolean => {
|
||||
const horizontal = edge === "left" || edge === "right";
|
||||
const limit = resolveCssDistance(
|
||||
distance,
|
||||
horizontal ? "horizontal" : "vertical",
|
||||
host,
|
||||
bounds,
|
||||
);
|
||||
switch (edge) {
|
||||
case "left":
|
||||
return point.localX >= 0 && point.localX <= limit;
|
||||
case "right":
|
||||
return (
|
||||
point.localX <= bounds.width && bounds.width - point.localX <= limit
|
||||
);
|
||||
case "top":
|
||||
return point.localY >= 0 && point.localY <= limit;
|
||||
case "bottom":
|
||||
return (
|
||||
point.localY <= bounds.height && bounds.height - point.localY <= limit
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Movement tracking ---------------------------------------------------------
|
||||
|
||||
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
|
||||
|
||||
/** Velocity is the trend of the samples in this many recent milliseconds. */
|
||||
const VELOCITY_WINDOW = 100;
|
||||
/** Shortest span, in ms, velocity is measured over, so bursts stay finite. */
|
||||
const MIN_VELOCITY_SPAN = 8;
|
||||
|
||||
/**
|
||||
* Measures one pointer sequence's movement along a gesture direction.
|
||||
*
|
||||
* Progress and velocity are normalized by the gesture distance (by default
|
||||
* the host's size along the gesture axis), so `1` means one full distance.
|
||||
*/
|
||||
export class DirectionalTracker {
|
||||
/** Directed distance from the start, in CSS pixels. */
|
||||
distance = 0;
|
||||
/** Absolute cross-axis distance from the start, in CSS pixels. */
|
||||
crossDistance = 0;
|
||||
/** Directed distance ÷ gesture distance, unclamped so it can overshoot. */
|
||||
rawProgress = 0;
|
||||
/**
|
||||
* Directed velocity in progress units per second: the least-squares trend
|
||||
* of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a
|
||||
* final held-still moment do not distort a flick.
|
||||
*/
|
||||
velocity = 0;
|
||||
|
||||
private readonly samples: { time: number; position: number }[];
|
||||
|
||||
constructor(
|
||||
private readonly direction: OriginGestureDirection,
|
||||
private readonly start: PointerSample,
|
||||
/** Gesture distance in CSS pixels, read at every sample. */
|
||||
private readonly axisSize: () => number,
|
||||
) {
|
||||
this.samples = [{ time: start.timeStamp, position: 0 }];
|
||||
}
|
||||
|
||||
/** Directed distance ÷ gesture distance, clamped to `0..1`. */
|
||||
get progress(): number {
|
||||
return clamp01(this.rawProgress);
|
||||
}
|
||||
|
||||
/** Movement from the start without recording a sample. */
|
||||
displacement(event: Pick<PointerSample, "clientX" | "clientY">) {
|
||||
const dx = event.clientX - this.start.clientX;
|
||||
const dy = event.clientY - this.start.clientY;
|
||||
return {
|
||||
distance: directedDistance(this.direction, dx, dy),
|
||||
crossDistance: crossDistance(this.direction, dx, dy),
|
||||
};
|
||||
}
|
||||
|
||||
/** Record a sample, updating distance, progress, and velocity. */
|
||||
sample(event: PointerSample): void {
|
||||
const { distance, crossDistance } = this.displacement(event);
|
||||
this.distance = distance;
|
||||
this.crossDistance = crossDistance;
|
||||
const size = Math.max(1, this.axisSize());
|
||||
this.rawProgress = distance / size;
|
||||
|
||||
const samples = this.samples;
|
||||
samples.push({ time: event.timeStamp, position: distance });
|
||||
// Keep the window, but always at least two samples to measure between.
|
||||
const since = event.timeStamp - VELOCITY_WINDOW;
|
||||
while (samples.length > 2 && samples[1]!.time <= since) samples.shift();
|
||||
this.velocity = (DirectionalTracker.trend(samples) * 1000) / size;
|
||||
}
|
||||
|
||||
/** Least-squares slope of position over time, in pixels per ms. */
|
||||
private static trend(samples: readonly { time: number; position: number }[]) {
|
||||
const first = samples[0]!;
|
||||
const last = samples[samples.length - 1]!;
|
||||
if (last.time - first.time < MIN_VELOCITY_SPAN)
|
||||
return (last.position - first.position) / MIN_VELOCITY_SPAN;
|
||||
const meanTime =
|
||||
samples.reduce((sum, { time }) => sum + time, 0) / samples.length;
|
||||
const meanPosition =
|
||||
samples.reduce((sum, { position }) => sum + position, 0) / samples.length;
|
||||
let covariance = 0;
|
||||
let variance = 0;
|
||||
for (const { time, position } of samples) {
|
||||
covariance += (time - meanTime) * (position - meanPosition);
|
||||
variance += (time - meanTime) ** 2;
|
||||
}
|
||||
return covariance / variance;
|
||||
}
|
||||
}
|
||||
|
||||
// Recognizer ----------------------------------------------------------------
|
||||
|
||||
/** Directed movement in CSS pixels required before a gesture captures. */
|
||||
const DEFAULT_THRESHOLD = 8;
|
||||
/** Ratio by which directed movement must exceed cross-axis movement. */
|
||||
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
||||
|
||||
/** Resolve a definition's progress distance at pointer-down, in pixels. */
|
||||
export const resolveProgressDistance = (
|
||||
distance: OriginGestureProgressDistance | undefined,
|
||||
direction: OriginGestureDirection,
|
||||
host: HTMLElement,
|
||||
bounds: OriginRect,
|
||||
): (() => number) => {
|
||||
const horizontal = isHorizontal(direction);
|
||||
if (distance === undefined)
|
||||
return () => (horizontal ? host.clientWidth : host.clientHeight);
|
||||
const pixels =
|
||||
typeof distance === "function"
|
||||
? distance({ host, bounds })
|
||||
: resolveCssDistance(
|
||||
distance,
|
||||
horizontal ? "horizontal" : "vertical",
|
||||
host,
|
||||
bounds,
|
||||
);
|
||||
return () => pixels;
|
||||
};
|
||||
|
||||
/** The origin capabilities a recognizer needs. */
|
||||
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
||||
|
||||
/** State of one admitted pointer sequence, from pointer-down to release. */
|
||||
interface PointerSession {
|
||||
/** Definition current at pointer-down; later changes do not affect it. */
|
||||
readonly definition: OriginGestureDefinition;
|
||||
readonly pointerId: number;
|
||||
readonly host: HTMLElement;
|
||||
readonly bounds: OriginRect;
|
||||
/** Origin context captured at pointer-down. */
|
||||
readonly origin: OriginContext;
|
||||
readonly start: OriginGesturePoint;
|
||||
readonly startTime: number;
|
||||
readonly tracker: DirectionalTracker;
|
||||
/**
|
||||
* For a gesture sharing its axis with scrolling: whether it has claimed
|
||||
* this touch from native scrolling (`ours`) or not yet decided.
|
||||
*/
|
||||
scroll: "pending" | "ours";
|
||||
/** Latest pointer position, for the touch-move decision. */
|
||||
last: { readonly clientX: number; readonly clientY: number };
|
||||
/** Movement passed the recognition threshold and the pointer is captured. */
|
||||
captured: boolean;
|
||||
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
|
||||
armed: boolean;
|
||||
/** The scene operation, once navigation resolved and `begin()` was called. */
|
||||
operation: Promise<OriginOperationHandle | null> | null;
|
||||
/**
|
||||
* The operation's handle once `begin()` resolved, so pointer-up can finish
|
||||
* it synchronously, inside the event, without awaiting the promise.
|
||||
*/
|
||||
handle: OriginOperationHandle | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one host's pointer events into a scene operation.
|
||||
*
|
||||
* Lifecycle of a session:
|
||||
*
|
||||
* 1. **down**: an eligible primary pointer opens a session.
|
||||
* 2. **move**: once directed movement passes the threshold, the pointer is
|
||||
* captured and `.navigate()` is resolved; a non-null intent begins an
|
||||
* operation, while a declined one closes the session. Every later move
|
||||
* updates the operation's progress.
|
||||
* 3. **up**: the session closes and the operation finishes, using the
|
||||
* definition's `.complete()` predicate when present.
|
||||
* 4. **cancel**: pointer-cancel, lost pointer capture, a new primary
|
||||
* pointer-down, or disposal closes the session and cancels its operation.
|
||||
*
|
||||
* Navigation and `begin()` are asynchronous. Each continuation checks that
|
||||
* its session is still current, so a stale resolution never creates or
|
||||
* updates an orphan operation.
|
||||
*/
|
||||
export class GestureRecognizer {
|
||||
private session: PointerSession | null = null;
|
||||
|
||||
constructor(
|
||||
/** Reads the current definition; it may change between sessions. */
|
||||
private readonly definitionOf: () => OriginGestureDefinition,
|
||||
private readonly origin: RecognizerOrigin,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Called for each `touchmove`: whether native scrolling must be cancelled.
|
||||
*
|
||||
* Only gestures that share an axis with scrolling take part. The browser
|
||||
* sends its first cancelable `touchmove` after roughly 15–20px of movement,
|
||||
* so the decision uses all of that movement: along this gesture's axis and
|
||||
* direction claims the touch; clearly elsewhere lets it go to the browser
|
||||
* (which then scrolls and sends `pointercancel`); unclear holds it for one
|
||||
* more move.
|
||||
*/
|
||||
claimTouch(): boolean {
|
||||
const session = this.session;
|
||||
if (!session || !sharesScroll(session.definition)) return false;
|
||||
if (session.captured || session.scroll === "ours") return true;
|
||||
const { distance, crossDistance } = session.tracker.displacement(
|
||||
session.last,
|
||||
);
|
||||
const dominance =
|
||||
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
|
||||
const along = Math.abs(distance);
|
||||
if (distance > 0 && along >= crossDistance * dominance) {
|
||||
session.scroll = "ours";
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
along >= crossDistance * dominance ||
|
||||
crossDistance >= along * dominance
|
||||
) {
|
||||
this.session = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pointerDown(event: PointerEvent): void {
|
||||
if (!event.isPrimary) return;
|
||||
// Only one primary pointer exists at a time, so any session still open
|
||||
// lost its pointer-up (for example, released outside the window).
|
||||
void this.abandon();
|
||||
|
||||
const host = event.currentTarget;
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
!(host instanceof HTMLElement) ||
|
||||
isIgnoredTarget(event.target)
|
||||
)
|
||||
return;
|
||||
|
||||
const definition = this.definitionOf();
|
||||
const bounds = measureRect(host);
|
||||
const origin = this.origin.context.value;
|
||||
if (!matchesStart(definition.start, event, host, origin, bounds)) return;
|
||||
|
||||
// The component containing this declaration is the operation's origin.
|
||||
event.stopPropagation();
|
||||
this.session = {
|
||||
definition,
|
||||
pointerId: event.pointerId,
|
||||
host,
|
||||
bounds,
|
||||
origin,
|
||||
start: pointOf(event, bounds),
|
||||
startTime: event.timeStamp,
|
||||
tracker: new DirectionalTracker(
|
||||
definition.direction,
|
||||
event,
|
||||
resolveProgressDistance(
|
||||
definition.recognition.distance,
|
||||
definition.direction,
|
||||
host,
|
||||
bounds,
|
||||
),
|
||||
),
|
||||
scroll: "pending",
|
||||
last: { clientX: event.clientX, clientY: event.clientY },
|
||||
captured: false,
|
||||
armed: false,
|
||||
operation: null,
|
||||
handle: null,
|
||||
};
|
||||
}
|
||||
|
||||
async pointerMove(event: PointerEvent): Promise<void> {
|
||||
const session = this.sessionFor(event);
|
||||
if (!session) return;
|
||||
|
||||
session.last = { clientX: event.clientX, clientY: event.clientY };
|
||||
if (!session.captured) {
|
||||
if (!GestureRecognizer.recognizes(session, event)) return;
|
||||
session.captured = true;
|
||||
session.host.setPointerCapture?.(session.pointerId);
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
this.updateArming(session, event);
|
||||
const action = await this.resolveAction(session.definition);
|
||||
// The pointer may have been released or cancelled meanwhile.
|
||||
if (this.session !== session) return;
|
||||
if (!action) {
|
||||
this.session = null;
|
||||
return;
|
||||
}
|
||||
session.operation = this.origin.begin(action).then(
|
||||
(handle) => (session.handle = handle),
|
||||
(error) => {
|
||||
// Another recognizer on this origin won the race; that is expected.
|
||||
if (!(
|
||||
error instanceof OriginSceneError && error.code === "origin-busy"
|
||||
))
|
||||
warn("A gesture could not begin its operation.", error);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
this.updateArming(session, event);
|
||||
}
|
||||
|
||||
// Progress is read after the await so the latest buffered sample wins.
|
||||
const pending = session.operation;
|
||||
const handle = pending ? await pending : null;
|
||||
if (this.session === session && session.operation === pending)
|
||||
handle?.update(session.tracker.rawProgress, session.tracker.velocity);
|
||||
}
|
||||
|
||||
async pointerUp(event: PointerEvent): Promise<void> {
|
||||
const session = this.sessionFor(event);
|
||||
if (!session) return;
|
||||
this.session = null;
|
||||
if (!session.captured) return;
|
||||
|
||||
const { tracker, definition } = session;
|
||||
tracker.sample(event);
|
||||
const context = GestureRecognizer.contextOf(session, event);
|
||||
const commit = definition.completion?.(context);
|
||||
const { rawProgress: progress, velocity } = tracker;
|
||||
|
||||
// Usually the operation began long ago: finish it without awaiting, so
|
||||
// commit handlers run inside this pointer-up (iOS only allows haptics,
|
||||
// audio, and clipboard writes during the user's gesture).
|
||||
const handle =
|
||||
session.handle ?? (session.operation ? await session.operation : null);
|
||||
if (!handle) return;
|
||||
handle.update(progress, velocity);
|
||||
// finish() fixes the outcome synchronously, before the spring settles.
|
||||
const settled = handle.finish(
|
||||
commit === undefined ? undefined : { commit },
|
||||
);
|
||||
if (handle.intent === "commit")
|
||||
GestureRecognizer.notify(definition.commitHandlers ?? [], context);
|
||||
await settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host lost pointer capture without a pointer-up; cancel like
|
||||
* pointer-cancel. The event bubbles, and taking capture makes the touched
|
||||
* child lose its implicit touch capture, so only the host's own loss counts.
|
||||
*/
|
||||
pointerCaptureLost(event: PointerEvent): Promise<void> {
|
||||
const session = this.sessionFor(event);
|
||||
return session && event.target === session.host
|
||||
? this.abandon()
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
pointerCancel(): Promise<void> {
|
||||
return this.abandon();
|
||||
}
|
||||
|
||||
/** Close the current session, cancelling its operation if captured. */
|
||||
async abandon(): Promise<void> {
|
||||
const session = this.session;
|
||||
this.session = null;
|
||||
if (!session?.captured) return;
|
||||
const handle = session.operation ? await session.operation : null;
|
||||
await handle?.cancel();
|
||||
}
|
||||
|
||||
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
|
||||
private updateArming(session: PointerSession, event: PointerEvent) {
|
||||
const { definition, tracker } = session;
|
||||
const armHandlers = definition.armHandlers ?? [];
|
||||
const disarmHandlers = definition.disarmHandlers ?? [];
|
||||
if (!armHandlers.length && !disarmHandlers.length) return;
|
||||
const context = GestureRecognizer.contextOf(session, event);
|
||||
// Distance decides arming unless the definition supplies .complete():
|
||||
// velocity changes too quickly to drive a cue without flickering.
|
||||
const armed = definition.completion
|
||||
? definition.completion(context)
|
||||
: tracker.progress >=
|
||||
(definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
|
||||
if (armed === session.armed) return;
|
||||
session.armed = armed;
|
||||
GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context);
|
||||
}
|
||||
|
||||
private static contextOf(
|
||||
session: PointerSession,
|
||||
event: PointerEvent,
|
||||
): OriginGestureCompletionContext {
|
||||
const { tracker } = session;
|
||||
return {
|
||||
origin: session.origin,
|
||||
direction: session.definition.direction,
|
||||
progress: tracker.progress,
|
||||
velocity: tracker.velocity,
|
||||
distance: tracker.distance,
|
||||
crossDistance: tracker.crossDistance,
|
||||
duration: Math.max(0, event.timeStamp - session.startTime),
|
||||
event,
|
||||
host: session.host,
|
||||
bounds: session.bounds,
|
||||
start: session.start,
|
||||
current: pointOf(event, session.bounds),
|
||||
};
|
||||
}
|
||||
|
||||
private sessionFor(event: PointerEvent) {
|
||||
const session = this.session;
|
||||
return session && session.pointerId === event.pointerId ? session : null;
|
||||
}
|
||||
|
||||
/** Directed movement passed the threshold and dominates the cross axis. */
|
||||
private static recognizes(session: PointerSession, event: PointerEvent) {
|
||||
const { recognition } = session.definition;
|
||||
const { distance, crossDistance } = session.tracker.displacement(event);
|
||||
return (
|
||||
distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
|
||||
distance >=
|
||||
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the definition's navigation against the origin's current context
|
||||
* and pair it with its choreography. A declined navigation resolves to
|
||||
* `null`; a failing one also warns.
|
||||
*/
|
||||
private resolveAction(
|
||||
definition: OriginGestureDefinition,
|
||||
): Promise<OriginAction | null> {
|
||||
const fail = (error: unknown) => {
|
||||
warn("A gesture's navigate() factory failed.", error);
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
return Promise.resolve(
|
||||
definition.navigation(this.origin.context.value),
|
||||
).then(
|
||||
(navigation) =>
|
||||
navigation
|
||||
? { ...navigation, choreography: definition.choreography }
|
||||
: null,
|
||||
fail,
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.resolve(fail(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** Run handlers in order; a failing handler never interrupts the others. */
|
||||
private static notify(
|
||||
handlers: readonly ((context: OriginGestureCompletionContext) => void)[],
|
||||
context: OriginGestureCompletionContext,
|
||||
) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composables ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bind any number of definitions to one host. Recognizers are kept per list
|
||||
* position, so re-rendering with new definition objects never interrupts a
|
||||
* gesture in progress; each change applies from the next pointer-down.
|
||||
*/
|
||||
export const useOriginGestures = (
|
||||
definitions: MaybeRefOrGetter<readonly OriginGestureDefinition[]>,
|
||||
): OriginGestureBinding => {
|
||||
const origin = useOrigin();
|
||||
const recognizers: GestureRecognizer[] = [];
|
||||
|
||||
/** Grow or shrink the recognizer list to match the current definitions. */
|
||||
const current = () => {
|
||||
const count = toValue(definitions).length;
|
||||
while (recognizers.length < count) {
|
||||
const index = recognizers.length;
|
||||
recognizers.push(
|
||||
new GestureRecognizer(() => toValue(definitions)[index]!, origin),
|
||||
);
|
||||
}
|
||||
for (const removed of recognizers.splice(count)) void removed.abandon();
|
||||
return recognizers;
|
||||
};
|
||||
|
||||
// The host element, for scroll-edge state and selector scrollers.
|
||||
const host = shallowRef<HTMLElement | null>(null);
|
||||
// Bumped by scroll events; touch-action re-reads scroll edges on change.
|
||||
const scrolled = shallowRef(0);
|
||||
const directional = supportsDirectionalPan();
|
||||
const atEdge = (definition: Pick<OriginGestureDefinition, "start">) => {
|
||||
const { start } = definition;
|
||||
if (start.kind !== "scroll-edge") return false;
|
||||
const element = host.value;
|
||||
if (!element && typeof start.scroller === "string") return true;
|
||||
return isAtScrollEdge(start.scroller, start.edge, element!);
|
||||
};
|
||||
// A string, so hosts re-render only when touch-action actually changes,
|
||||
// never merely because something scrolled.
|
||||
const touchAction = computed(() => {
|
||||
void scrolled.value;
|
||||
return touchActionFor(toValue(definitions), atEdge, directional);
|
||||
});
|
||||
const sharesScrollAxis = computed(() =>
|
||||
toValue(definitions).some(sharesScroll),
|
||||
);
|
||||
|
||||
const style = computed(() => {
|
||||
const value = touchAction.value;
|
||||
return {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// Native scrolling stays on every pan no gesture needs.
|
||||
touchAction: value,
|
||||
// Inherited by descendants through the package stylesheet, so nested
|
||||
// scroll containers (where browsers restart touch-action) keep it too.
|
||||
"--nvo-touch-action": value,
|
||||
};
|
||||
});
|
||||
|
||||
// A recognizer whose component unmounts mid-gesture cancels its operation.
|
||||
onScopeDispose(() => {
|
||||
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
|
||||
});
|
||||
|
||||
// Only without directional touch-action do scroll-edge gestures need to
|
||||
// claim touches from JavaScript, the one path that blocks scrolling.
|
||||
const needsTouchmove = computed(() => sharesScrollAxis.value && !directional);
|
||||
|
||||
const onTouchmove = (event: TouchEvent) => {
|
||||
if (!event.cancelable) return;
|
||||
// Ask every recognizer: each one may let go of the touch as it answers.
|
||||
let claimed = false;
|
||||
for (const recognizer of current())
|
||||
if (recognizer.claimTouch()) claimed = true;
|
||||
if (claimed) event.preventDefault();
|
||||
};
|
||||
const onScrollCapture = () => {
|
||||
scrolled.value += 1;
|
||||
};
|
||||
const listeners = computed(() => ({
|
||||
...(sharesScrollAxis.value ? { onScrollCapture } : {}),
|
||||
...(needsTouchmove.value ? { onTouchmove } : {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
get style() {
|
||||
return style.value;
|
||||
},
|
||||
get needsTouchmove() {
|
||||
return needsTouchmove.value;
|
||||
},
|
||||
get listeners() {
|
||||
return listeners.value as Record<string, (event: Event) => void>;
|
||||
},
|
||||
ref: (element) => {
|
||||
host.value = element instanceof HTMLElement ? element : null;
|
||||
scrolled.value += 1;
|
||||
},
|
||||
onPointerdown: (event) => {
|
||||
for (const recognizer of current()) recognizer.pointerDown(event);
|
||||
},
|
||||
onPointermove: (event) => {
|
||||
for (const recognizer of current()) void recognizer.pointerMove(event);
|
||||
},
|
||||
onPointerup: (event) => {
|
||||
for (const recognizer of current()) void recognizer.pointerUp(event);
|
||||
},
|
||||
onPointercancel: () => {
|
||||
for (const recognizer of current()) void recognizer.pointerCancel();
|
||||
},
|
||||
onLostpointercapture: (event) => {
|
||||
for (const recognizer of current())
|
||||
void recognizer.pointerCaptureLost(event);
|
||||
},
|
||||
onTouchmove,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Install a component-owned pointer recognizer.
|
||||
*
|
||||
* Recognition is local to the element receiving these handlers. There is no
|
||||
* application-wide gesture table and no lookup of a currently active view.
|
||||
*
|
||||
* Builder definitions may independently describe their pointer-down region
|
||||
* and movement direction. Omitting `.from` recognizes pointer-down across the
|
||||
* whole element.
|
||||
*
|
||||
* Interactive controls and anything inside
|
||||
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
|
||||
* preserves native scrolling on the cross-axis through its returned style.
|
||||
*
|
||||
* @param definition - An immutable builder result, or a ref or getter
|
||||
* returning one. A changed definition applies from the next pointer-down.
|
||||
* @returns Pointer handlers and required host styles.
|
||||
* @throws If called outside a component rendered by `OriginScene`.
|
||||
*
|
||||
* @example Builder-defined backward gesture
|
||||
* ```ts
|
||||
* const swipeBack = useOriginGesture(
|
||||
* gesture
|
||||
* .from.left("32px")
|
||||
* .to.right()
|
||||
* .navigate((context) => context.canGoBack ? back() : null)
|
||||
* .animate(slideRight),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useOriginGesture(
|
||||
definition: MaybeRefOrGetter<OriginGestureDefinition>,
|
||||
): OriginGestureBinding {
|
||||
return useOriginGestures(() => [toValue(definition)]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Routeless, component-owned scene transitions and gesture recognition for Vue.
|
||||
*
|
||||
* The package renders flat, stable Vue component hosts and composes temporary
|
||||
* origin-relative operation frames. It does not depend on Vue Router or choose
|
||||
* a globally active view.
|
||||
*
|
||||
* Modules, from the bottom up:
|
||||
*
|
||||
* - `shared` geometry and environment helpers
|
||||
* - `choreography` visual effects, presets, and effect compositing
|
||||
* - `navigation` view recipes, intents, and actions (`forward`, …)
|
||||
* - `scene/graph` the scene model and its history rules
|
||||
* - `scene/scene` timing, measuring, compositing, `useOrigin`
|
||||
* - `gesture/definition` gesture types and the immutable builder
|
||||
* - `gesture/recognizer` pointer admission, tracking, and recognition
|
||||
* - `components/` Vue renderers and gesture hosts
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export type { OriginRect } from "./shared";
|
||||
|
||||
export type {
|
||||
OriginChoreography,
|
||||
OriginChoreographyContext,
|
||||
OriginEffect,
|
||||
OriginEffectSet,
|
||||
OriginOperationIntent,
|
||||
OriginOperationPhase,
|
||||
OriginSpring,
|
||||
} from "./choreography";
|
||||
export {
|
||||
defineOriginChoreography,
|
||||
fade,
|
||||
normalizedEffect,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
} from "./choreography";
|
||||
|
||||
export type * from "./navigation";
|
||||
export {
|
||||
above,
|
||||
back,
|
||||
forward,
|
||||
originAction,
|
||||
originView,
|
||||
replace,
|
||||
under,
|
||||
} from "./navigation";
|
||||
|
||||
export type * from "./scene/types";
|
||||
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
|
||||
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
|
||||
export {
|
||||
createOriginScene,
|
||||
originNodeScopeKey,
|
||||
useOrigin,
|
||||
} from "./scene/scene";
|
||||
|
||||
export type * from "./gesture/definition";
|
||||
export {
|
||||
allOf,
|
||||
anyOf,
|
||||
atScrollEdge,
|
||||
gesture,
|
||||
nearEdge,
|
||||
startedIn,
|
||||
} from "./gesture/definition";
|
||||
export { useOriginGesture } from "./gesture/recognizer";
|
||||
|
||||
/**
|
||||
* Convenience component that binds one `useOriginGesture()` recognizer to a
|
||||
* rendered HTML element. See `OriginGestureProps` for its public props.
|
||||
*/
|
||||
export { default as OriginGesture } from "./components/OriginGesture.vue";
|
||||
|
||||
/**
|
||||
* Policy-neutral host for multiple completed gesture definitions. The owning
|
||||
* page builds each definition; this component only installs their recognizers
|
||||
* and forwards pointer events across the shared surface.
|
||||
*/
|
||||
export { default as OriginGestureSurface } from "./components/OriginGestureSurface.vue";
|
||||
|
||||
/**
|
||||
* Renderer for an `OriginScene`. Every live view is mounted as a stable,
|
||||
* absolutely positioned sibling beneath this component.
|
||||
*/
|
||||
export { default as OriginScene } from "./components/OriginScene.vue";
|
||||
|
||||
// Makes the library build emit dist/style.css. Applications should import the
|
||||
// explicit `@native-vue-router/core-v2/style.css` export as shown in the README.
|
||||
import "./style.css";
|
||||
@@ -0,0 +1,434 @@
|
||||
import { markRaw, type Component } from "vue";
|
||||
import { isChoreography, type OriginChoreography } from "./choreography";
|
||||
|
||||
/*
|
||||
* Navigation vocabulary: view recipes (what to mount) and the intents and
|
||||
* actions (how history changes) that operations execute.
|
||||
*/
|
||||
|
||||
/** Props accepted by a Vue component definition. */
|
||||
export type PropsOf<T> = T extends abstract new (...args: any[]) => {
|
||||
$props: infer P;
|
||||
}
|
||||
? P
|
||||
: T extends (props: infer P, ...args: any[]) => any
|
||||
? P
|
||||
: never;
|
||||
|
||||
/**
|
||||
* A view is a recipe for creating a Vue component, not a mounted instance.
|
||||
*
|
||||
* Forward navigation uses the recipe to create a mounted history entry. That
|
||||
* instance remains mounted until a committed back operation pops it.
|
||||
*
|
||||
* @typeParam T - The component mounted by the recipe.
|
||||
*/
|
||||
export interface OriginView<T extends Component = Component> {
|
||||
/** The Vue component definition that will be mounted for this recipe. */
|
||||
readonly component: T;
|
||||
/** Props passed to the component when the recipe is mounted. */
|
||||
readonly props?: Readonly<PropsOf<T>>;
|
||||
/**
|
||||
* A developer-facing recipe identity used in diagnostics and as the prefix
|
||||
* of generated scene-node keys. It does not make Vue reuse an instance.
|
||||
*/
|
||||
readonly key?: string;
|
||||
/** A human-readable label used by diagnostics and DOM data attributes. */
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
|
||||
export interface OriginViewOptions {
|
||||
/**
|
||||
* Stable recipe identity used in diagnostics and generated node-key prefixes.
|
||||
* It does not preserve or reuse a mounted Vue component instance.
|
||||
*/
|
||||
key?: string;
|
||||
/** Human-readable label used by inspectors and DOM data attributes. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
let viewSequence = 0;
|
||||
|
||||
function componentName(component: Component): string | undefined {
|
||||
return typeof component === "object" && "name" in component
|
||||
? String(component.name)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a component and props into a lightweight, reusable view recipe.
|
||||
*
|
||||
* Components are marked raw so Vue never attempts to proxy their definitions
|
||||
* when recipes are placed in reactive scene/history structures.
|
||||
*
|
||||
* @param component - Vue component definition to mount.
|
||||
* @param props - Props captured by the recipe.
|
||||
* @param options - Optional recipe identity and diagnostic name.
|
||||
* @returns An immutable, reusable component recipe. It is not a Vue instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const profile = originView(
|
||||
* ProfileView,
|
||||
* { userId: "42" },
|
||||
* { key: "profile-42", name: "Profile" },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function originView<T extends Component = Component>(
|
||||
component: T,
|
||||
props?: PropsOf<T>,
|
||||
options: OriginViewOptions = {},
|
||||
): OriginView<T> {
|
||||
const name = options.name ?? componentName(component);
|
||||
return markRaw({
|
||||
component: markRaw(component),
|
||||
props,
|
||||
key: options.key ?? `${name ?? "view"}-${++viewSequence}`,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" | "replace" | "back";
|
||||
|
||||
/**
|
||||
* The target's stacking relationship to its origin while an operation exists.
|
||||
*/
|
||||
export type OriginPlacement = "above" | "under";
|
||||
|
||||
/**
|
||||
* A scene mutation without visual choreography.
|
||||
*
|
||||
* Gesture builders keep navigation intent separate from animation so the same
|
||||
* destination can be paired with different component-local interactions.
|
||||
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
|
||||
*/
|
||||
export interface OriginNavigationIntent {
|
||||
/**
|
||||
* 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. */
|
||||
readonly placement?: OriginPlacement;
|
||||
/** Retained-history mutation performed if the gesture commits. */
|
||||
readonly history: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** A complete request to create and animate a target view from an origin. */
|
||||
export interface OriginAction {
|
||||
/**
|
||||
* 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. */
|
||||
readonly choreography: OriginChoreography;
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"`
|
||||
*/
|
||||
readonly placement?: OriginPlacement;
|
||||
/**
|
||||
* History mutation applied to the target recipe.
|
||||
*
|
||||
* @defaultValue `"push"`
|
||||
*/
|
||||
readonly history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link originAction}. */
|
||||
export interface OriginActionOptions {
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"`
|
||||
*/
|
||||
placement?: OriginPlacement;
|
||||
/**
|
||||
* History mutation assigned to the created target node.
|
||||
*
|
||||
* @defaultValue `"push"`
|
||||
*/
|
||||
history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** History options shared by the {@link above} and {@link under} helpers. */
|
||||
export interface OriginPlacementActionOptions {
|
||||
/**
|
||||
* History mutation assigned to the created target node.
|
||||
*
|
||||
* @defaultValue `"push"`
|
||||
*/
|
||||
history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** Stacking options accepted by retained-history navigation helpers. */
|
||||
export interface OriginNavigationActionOptions {
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
|
||||
* for {@link back}
|
||||
*/
|
||||
placement?: OriginPlacement;
|
||||
}
|
||||
|
||||
/*
|
||||
* Every helper accepts either `(…, options?)`, producing an animation-free
|
||||
* navigation intent for gesture `.navigate()`, or `(…, choreography,
|
||||
* options?)`, producing a complete action for `begin()`/`perform()`.
|
||||
*/
|
||||
|
||||
interface HelperArguments<Options> {
|
||||
readonly choreography?: OriginChoreography;
|
||||
readonly options: Options;
|
||||
}
|
||||
|
||||
function helperArguments<Options extends object>(
|
||||
choreographyOrOptions: OriginChoreography | Options | undefined,
|
||||
options: Options | undefined,
|
||||
): HelperArguments<Partial<Options>> {
|
||||
return isChoreography(choreographyOrOptions)
|
||||
? { choreography: choreographyOrOptions, options: options ?? {} }
|
||||
: { options: choreographyOrOptions ?? {} };
|
||||
}
|
||||
|
||||
function navigation(
|
||||
target: OriginView | undefined,
|
||||
placement: OriginPlacement,
|
||||
history: OriginHistoryMode,
|
||||
): OriginNavigationIntent {
|
||||
return { target, placement, history };
|
||||
}
|
||||
|
||||
/** Back navigation resolves its target at execution time, so it has none. */
|
||||
function targetlessNavigation(
|
||||
placement: OriginPlacement,
|
||||
history: OriginHistoryMode,
|
||||
): OriginNavigationIntent {
|
||||
return { placement, history };
|
||||
}
|
||||
|
||||
/** Pair a navigation intent with choreography, or return it unchanged. */
|
||||
function materialize(
|
||||
intent: OriginNavigationIntent,
|
||||
choreography: OriginChoreography | undefined,
|
||||
): OriginNavigationIntent | OriginAction {
|
||||
if (!choreography) return intent;
|
||||
const { placement, history } = intent;
|
||||
return "target" in intent
|
||||
? { target: intent.target, choreography, placement, history }
|
||||
: { choreography, placement, history };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the scene mutation invoked by a click, gesture, hardware command, or
|
||||
* any other application event. `above` and `under` affect stacking only; they
|
||||
* do not imply a universal navigation direction.
|
||||
*
|
||||
* @param target - View recipe to create.
|
||||
* @param choreography - Visual routine controlling the operation.
|
||||
* @param options - Stacking and history behavior.
|
||||
* @returns An action that can be passed to `begin()`, `perform()`, or returned
|
||||
* from a gesture action factory.
|
||||
*/
|
||||
export function originAction(
|
||||
target: OriginView,
|
||||
choreography: OriginChoreography,
|
||||
options: OriginActionOptions = {},
|
||||
): OriginAction {
|
||||
return materialize(
|
||||
navigation(target, options.placement ?? "above", options.history ?? "push"),
|
||||
choreography,
|
||||
) as OriginAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a retained-history push intent or complete action.
|
||||
*
|
||||
* Committing the action parks its origin instance and leaves it mounted until
|
||||
* a later committed {@link back} action pops the new entry. 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 if the push commits.
|
||||
* @param options - Stacking options for an animation-free navigation intent.
|
||||
* @returns An animation-free intent for use with a gesture builder.
|
||||
*/
|
||||
export function forward(
|
||||
target: OriginView,
|
||||
options?: OriginNavigationActionOptions,
|
||||
): OriginNavigationIntent;
|
||||
export function forward(
|
||||
target: OriginView,
|
||||
choreography: OriginChoreography,
|
||||
options?: OriginNavigationActionOptions,
|
||||
): OriginAction;
|
||||
export function forward(
|
||||
target: OriginView,
|
||||
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
|
||||
maybeOptions?: OriginNavigationActionOptions,
|
||||
): OriginAction | OriginNavigationIntent {
|
||||
const { choreography, options } = helperArguments(
|
||||
choreographyOrOptions,
|
||||
maybeOptions,
|
||||
);
|
||||
return materialize(
|
||||
navigation(target, options.placement ?? "above", "push"),
|
||||
choreography,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
maybeOptions?: OriginNavigationActionOptions,
|
||||
): OriginAction | OriginNavigationIntent {
|
||||
const { choreography, options } = helperArguments(
|
||||
choreographyOrOptions,
|
||||
maybeOptions,
|
||||
);
|
||||
return materialize(
|
||||
navigation(target, options.placement ?? "above", "replace"),
|
||||
choreography,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a retained-history back intent or complete action.
|
||||
*
|
||||
* The action intentionally has no target recipe. At execution time the scene
|
||||
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
|
||||
* and removes the current instance only if the operation commits. Omit
|
||||
* choreography inside gesture `.navigate()`; provide it for a programmatic
|
||||
* action.
|
||||
*
|
||||
* @param options - Stacking options for an animation-free navigation intent.
|
||||
* @returns An animation-free back intent for use with a gesture builder.
|
||||
*/
|
||||
export function back(
|
||||
options?: OriginNavigationActionOptions,
|
||||
): OriginNavigationIntent;
|
||||
export function back(
|
||||
choreography: OriginChoreography,
|
||||
options?: OriginNavigationActionOptions,
|
||||
): OriginAction;
|
||||
export function back(
|
||||
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
|
||||
maybeOptions?: OriginNavigationActionOptions,
|
||||
): OriginAction | OriginNavigationIntent {
|
||||
const { choreography, options } = helperArguments(
|
||||
choreographyOrOptions,
|
||||
maybeOptions,
|
||||
);
|
||||
return materialize(
|
||||
targetlessNavigation(options.placement ?? "under", "back"),
|
||||
choreography,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an intent or action whose target is stacked above its origin.
|
||||
*
|
||||
* This helper controls stacking, not movement direction. The supplied
|
||||
* choreography may move either component however the application chooses.
|
||||
* Omitting choreography produces an intent for gesture `.navigate()`.
|
||||
*
|
||||
* @param target - View recipe to create.
|
||||
* @param choreography - Visual routine controlling the operation.
|
||||
* @param options - Optional history behavior.
|
||||
*/
|
||||
export function above(
|
||||
target: OriginView,
|
||||
options?: OriginPlacementActionOptions,
|
||||
): OriginNavigationIntent;
|
||||
export function above(
|
||||
target: OriginView,
|
||||
choreography: OriginChoreography,
|
||||
options?: OriginPlacementActionOptions,
|
||||
): OriginAction;
|
||||
export function above(
|
||||
target: OriginView,
|
||||
choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions,
|
||||
maybeOptions?: OriginPlacementActionOptions,
|
||||
): OriginAction | OriginNavigationIntent {
|
||||
const { choreography, options } = helperArguments(
|
||||
choreographyOrOptions,
|
||||
maybeOptions,
|
||||
);
|
||||
return materialize(
|
||||
navigation(target, "above", options.history ?? "push"),
|
||||
choreography,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an intent or action whose target is stacked underneath its origin.
|
||||
*
|
||||
* Commonly used for custom reveal effects, but it has no implicit history
|
||||
* meaning. Prefer {@link back} for retained-history navigation. Omitting
|
||||
* choreography produces an intent for gesture `.navigate()`.
|
||||
*
|
||||
* @param target - View recipe to create underneath the origin.
|
||||
* @param choreography - Visual routine controlling the operation.
|
||||
* @param options - Optional history behavior.
|
||||
*/
|
||||
export function under(
|
||||
target: OriginView,
|
||||
options?: OriginPlacementActionOptions,
|
||||
): OriginNavigationIntent;
|
||||
export function under(
|
||||
target: OriginView,
|
||||
choreography: OriginChoreography,
|
||||
options?: OriginPlacementActionOptions,
|
||||
): OriginAction;
|
||||
export function under(
|
||||
target: OriginView,
|
||||
choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions,
|
||||
maybeOptions?: OriginPlacementActionOptions,
|
||||
): OriginAction | OriginNavigationIntent {
|
||||
const { choreography, options } = helperArguments(
|
||||
choreographyOrOptions,
|
||||
maybeOptions,
|
||||
);
|
||||
return materialize(
|
||||
navigation(target, "under", options.history ?? "push"),
|
||||
choreography,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
|
||||
import type {
|
||||
OriginAction,
|
||||
OriginHistoryMode,
|
||||
OriginView,
|
||||
} from "../navigation";
|
||||
import { clamp01, type OriginRect } from "../shared";
|
||||
import type {
|
||||
MutableOriginOperation,
|
||||
OriginContext,
|
||||
OriginFinishOptions,
|
||||
OriginSceneNodeState,
|
||||
} from "./types";
|
||||
|
||||
/** Mutable storage for one mounted scene node. */
|
||||
export interface SceneNodeRecord {
|
||||
key: string;
|
||||
sequence: number;
|
||||
view: OriginView;
|
||||
/** Retained-history link: the entry a committed back returns to. */
|
||||
previousNodeKey?: string;
|
||||
state: OriginSceneNodeState;
|
||||
/** Visual link: the operation edge positioning this node as its target. */
|
||||
incomingOperationId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a scene operation was rejected.
|
||||
*
|
||||
* - `missing-node`: the node no longer exists.
|
||||
* - `missing-origin`: the origin node does not exist.
|
||||
* - `inert-origin`: the origin is parked or exposed and cannot originate.
|
||||
* - `origin-busy`: the origin already has a live outgoing operation.
|
||||
* - `invalid-action`: the action cannot be executed as written.
|
||||
* - `no-previous-entry`: back was requested without a retained entry.
|
||||
* - `undecided-forward`: back was requested through an unreleased forward.
|
||||
* - `target-busy`: the retained back target is transitioning, or still
|
||||
* positions the origin.
|
||||
* - `outside-scene`: `useOrigin()` was called outside an `<OriginScene>` view.
|
||||
*/
|
||||
export type OriginSceneErrorCode =
|
||||
| "missing-node"
|
||||
| "missing-origin"
|
||||
| "inert-origin"
|
||||
| "origin-busy"
|
||||
| "invalid-action"
|
||||
| "no-previous-entry"
|
||||
| "undecided-forward"
|
||||
| "target-busy"
|
||||
| "outside-scene";
|
||||
|
||||
/** Error thrown or rejected by scene operations, with a stable `code`. */
|
||||
export class OriginSceneError extends Error {
|
||||
override readonly name = "OriginSceneError";
|
||||
|
||||
constructor(
|
||||
readonly code: OriginSceneErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress at which a release commits when the choreography does not supply
|
||||
* its own `commitThreshold`.
|
||||
*/
|
||||
export const DEFAULT_COMMIT_THRESHOLD = 0.25;
|
||||
|
||||
/**
|
||||
* Release velocity (progress units per second) that commits a flick at any
|
||||
* progress when the choreography does not supply its own `commitVelocity`.
|
||||
*/
|
||||
export const DEFAULT_COMMIT_VELOCITY = 0.2;
|
||||
|
||||
/**
|
||||
* Decide a release outcome from explicit options, or from velocity and
|
||||
* progress: a flick at least `commitVelocity` fast decides by its direction,
|
||||
* forward committing and backward cancelling, regardless of distance. Slower
|
||||
* releases commit once progress reaches `commitThreshold`.
|
||||
*/
|
||||
export const shouldCommit = (
|
||||
operation: MutableOriginOperation,
|
||||
options: OriginFinishOptions,
|
||||
): boolean => {
|
||||
if (options.commit !== undefined) return options.commit;
|
||||
const { commitThreshold, commitVelocity } = operation.choreography;
|
||||
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
|
||||
if (operation.velocity >= flick) return true;
|
||||
if (operation.velocity <= -flick) return false;
|
||||
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
|
||||
};
|
||||
|
||||
/**
|
||||
* The scene model: nodes, operation edges, and the rules that rewrite them.
|
||||
*
|
||||
* Two independent structures share these records:
|
||||
*
|
||||
* - **History** links each node to its retained previous entry through
|
||||
* `previousNodeKey`. It changes only when operations commit or cancel.
|
||||
* - **Visual graph** links each target to the operation that positions it
|
||||
* through `incomingOperationId`; each operation links back to its origin.
|
||||
* Nodes without an incoming edge are listed in `roots`.
|
||||
*
|
||||
* Nodes are stored flat so Vue never re-parents a component when edges are
|
||||
* added, spliced, or removed. Every method is synchronous; timing (mounting,
|
||||
* measuring, settling) belongs to `Scene`.
|
||||
*/
|
||||
export class SceneGraph {
|
||||
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
|
||||
readonly operations = shallowReactive(
|
||||
new Map<number, MutableOriginOperation>(),
|
||||
);
|
||||
readonly roots = shallowRef<string[]>([]);
|
||||
/**
|
||||
* Connected resting edges temporarily removed by a back operation, keyed by
|
||||
* that back operation's id. Cancelling the back operation restores the edge.
|
||||
*/
|
||||
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||
/**
|
||||
* Operations indexed by origin key, in insertion order. Every addition and
|
||||
* removal goes through {@link attach} and {@link detach} to keep it in sync
|
||||
* with `operations`.
|
||||
*/
|
||||
private readonly outgoing = shallowReactive(
|
||||
new Map<string, readonly MutableOriginOperation[]>(),
|
||||
);
|
||||
private nodeSequence = 0;
|
||||
private operationSequence = 0;
|
||||
|
||||
constructor(initial: readonly OriginView[]) {
|
||||
this.roots.value = initial.map((view) => this.createNode(view).key);
|
||||
}
|
||||
|
||||
// Queries -----------------------------------------------------------------
|
||||
|
||||
node(key: string): SceneNodeRecord | undefined {
|
||||
return this.nodes.get(key);
|
||||
}
|
||||
|
||||
operation(id: number): MutableOriginOperation | undefined {
|
||||
return this.operations.get(id);
|
||||
}
|
||||
|
||||
/** The edge positioning `node` as its target, including resting edges. */
|
||||
incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined {
|
||||
return node.incomingOperationId
|
||||
? this.operations.get(node.incomingOperationId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Incoming edges positioning `node`, from the node outward to its root. */
|
||||
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
|
||||
const chain: MutableOriginOperation[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: SceneNodeRecord | undefined = node;
|
||||
while (current && !visited.has(current.key)) {
|
||||
visited.add(current.key);
|
||||
const incoming = this.incomingOf(current);
|
||||
if (!incoming) break;
|
||||
chain.push(incoming);
|
||||
current = this.nodes.get(incoming.originKey);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** The unfinished operation originated by a node, if any. */
|
||||
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||
return this.outgoing
|
||||
.get(nodeKey)
|
||||
?.find((operation) => operation.phase !== "finished");
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation whose source effects apply to a node. Unlike
|
||||
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
|
||||
*/
|
||||
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||
return this.outgoing.get(nodeKey)?.[0];
|
||||
}
|
||||
|
||||
/** Retained entries preceding `node`, oldest first. */
|
||||
historyOf(node: SceneNodeRecord): SceneNodeRecord[] {
|
||||
const history: SceneNodeRecord[] = [];
|
||||
const visited = new Set<string>([node.key]);
|
||||
let previousKey = node.previousNodeKey;
|
||||
while (previousKey && !visited.has(previousKey)) {
|
||||
visited.add(previousKey);
|
||||
const previous = this.nodes.get(previousKey);
|
||||
if (!previous) break;
|
||||
history.push(previous);
|
||||
previousKey = previous.previousNodeKey;
|
||||
}
|
||||
return history.reverse();
|
||||
}
|
||||
|
||||
contextFor(nodeKey: string): OriginContext {
|
||||
const node = this.nodes.get(nodeKey);
|
||||
if (!node)
|
||||
throw new OriginSceneError(
|
||||
"missing-node",
|
||||
`Origin scene node "${nodeKey}" no longer exists.`,
|
||||
);
|
||||
const history = this.historyOf(node).map((entry) => entry.view);
|
||||
return {
|
||||
nodeKey,
|
||||
view: node.view,
|
||||
canGoBack: Boolean(node.previousNodeKey),
|
||||
previous: history.at(-1),
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a node should currently receive input.
|
||||
*
|
||||
* Parked and exposed entries are inert. A target being cancelled is already
|
||||
* scheduled to disappear, and a source settling toward commit has ceded new
|
||||
* interactions to its target. This is node-local fate, not a global "active
|
||||
* view".
|
||||
*/
|
||||
isInteractive(nodeKey: string): boolean {
|
||||
const node = this.nodes.get(nodeKey);
|
||||
if (!node || node.state === "parked" || node.state === "exposed")
|
||||
return false;
|
||||
if (this.incomingOf(node)?.intent === "cancel") return false;
|
||||
return this.liveOutgoingOf(nodeKey)?.intent !== "commit";
|
||||
}
|
||||
|
||||
// Transitions -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate an action and add its operation edge (and, for push/replace, its
|
||||
* target node). The edge starts in the `preparing` phase.
|
||||
*
|
||||
* @throws If the origin cannot originate, or the action cannot be resolved.
|
||||
*/
|
||||
begin(
|
||||
originKey: string,
|
||||
action: OriginAction,
|
||||
originRect: OriginRect | undefined,
|
||||
): MutableOriginOperation {
|
||||
const origin = this.assertCanOriginate(originKey);
|
||||
const history = action.history ?? "push";
|
||||
if (action.choreography.persistAtRest && history !== "push")
|
||||
throw new OriginSceneError(
|
||||
"invalid-action",
|
||||
"persistAtRest choreographies require retained-history push navigation.",
|
||||
);
|
||||
|
||||
const { target, suspendedEdge } =
|
||||
history === "back"
|
||||
? this.resolveBackTarget(origin)
|
||||
: { target: this.createForwardTarget(origin, action, history) };
|
||||
|
||||
const operation = this.createOperation({
|
||||
originKey,
|
||||
targetKey: target.key,
|
||||
entryTargetKey: target.key,
|
||||
choreography: action.choreography,
|
||||
placement: action.placement ?? "above",
|
||||
history,
|
||||
originRect,
|
||||
});
|
||||
target.incomingOperationId = operation.id;
|
||||
target.state = "transitioning";
|
||||
origin.state = "transitioning";
|
||||
if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge);
|
||||
return operation;
|
||||
}
|
||||
|
||||
/** Apply interactive progress to an unfinished operation. */
|
||||
update(id: number, progress: number, velocity: number): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (!operation || operation.phase === "finished") return;
|
||||
operation.progress = clamp01(progress);
|
||||
operation.overshoot = progress - operation.progress;
|
||||
operation.velocity = velocity;
|
||||
if (operation.phase !== "preparing") operation.phase = "interactive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a committed operation.
|
||||
*
|
||||
* A normal push parks its origin and collapses the temporary visual edge, so
|
||||
* the target takes the origin's place in the visual graph. A connected
|
||||
* (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin
|
||||
* exposed but inert. Replace and back remove the origin.
|
||||
*
|
||||
* @returns Whether the operation's nodes still existed to be committed.
|
||||
*/
|
||||
commit(id: number): boolean {
|
||||
const operation = this.operations.get(id);
|
||||
if (!operation) return false;
|
||||
const origin = this.nodes.get(operation.originKey);
|
||||
const target = this.nodes.get(operation.targetKey);
|
||||
if (!origin || !target) return false;
|
||||
|
||||
if (operation.history === "push" && operation.choreography.persistAtRest) {
|
||||
operation.progress = 1;
|
||||
operation.overshoot = 0;
|
||||
operation.velocity = 0;
|
||||
operation.intent = "commit";
|
||||
operation.phase = "finished";
|
||||
origin.state = "exposed";
|
||||
this.activate(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.transferPosition(origin, target);
|
||||
operation.phase = "finished";
|
||||
this.detach(operation);
|
||||
this.suspendedEdges.delete(operation.id);
|
||||
|
||||
if (operation.history === "push") {
|
||||
origin.incomingOperationId = undefined;
|
||||
origin.state = "parked";
|
||||
} else {
|
||||
// Back pops the current entry; replace discards it in favor of the new
|
||||
// target. Both preserve every earlier retained instance, and any entry
|
||||
// still retained above the origin now sits on the target instead.
|
||||
for (const node of this.nodes.values())
|
||||
if (node.previousNodeKey === origin.key && node !== target)
|
||||
node.previousNodeKey = target.key;
|
||||
this.removeNode(origin.key);
|
||||
}
|
||||
|
||||
this.activate(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a cancelled operation, restoring the origin.
|
||||
*
|
||||
* Everything the edge created or now positions is undone. Entries from the
|
||||
* origin's retained history (a back target, or a replacement of one) return
|
||||
* to history; anything newer is discarded. A cancelled back that suspended
|
||||
* a connected resting edge reconnects it instead.
|
||||
*/
|
||||
cancel(id: number): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (!operation) return;
|
||||
const origin = this.nodes.get(operation.originKey);
|
||||
const entry = this.nodes.get(operation.entryTargetKey);
|
||||
const current = this.nodes.get(operation.targetKey);
|
||||
const suspendedEdge = this.suspendedEdges.get(id);
|
||||
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
|
||||
operation.phase = "finished";
|
||||
this.detach(operation);
|
||||
this.suspendedEdges.delete(id);
|
||||
|
||||
if (suspendedEdge && origin && entry && current === entry) {
|
||||
// Put the retained page back into its former position, then reconnect
|
||||
// the resting page→drawer edge. Both nodes keep their VNodes.
|
||||
this.discardOutgoing(entry.key, new Set(retained));
|
||||
this.transferPosition(origin, entry);
|
||||
this.attach(suspendedEdge);
|
||||
origin.incomingOperationId = suspendedEdge.id;
|
||||
entry.state = "exposed";
|
||||
} else {
|
||||
for (const node of new Set([entry, current])) {
|
||||
if (!node || node === origin) continue;
|
||||
if (retained.has(node.key)) this.returnToHistory(node, retained);
|
||||
else this.removeBranch(node.key, new Set(retained));
|
||||
}
|
||||
}
|
||||
|
||||
if (origin) this.activate(origin);
|
||||
}
|
||||
|
||||
// Transition steps --------------------------------------------------------
|
||||
|
||||
private assertCanOriginate(originKey: string): SceneNodeRecord {
|
||||
const origin = this.nodes.get(originKey);
|
||||
if (!origin)
|
||||
throw new OriginSceneError(
|
||||
"missing-origin",
|
||||
`Cannot animate from missing origin "${originKey}".`,
|
||||
);
|
||||
if (origin.state === "parked" || origin.state === "exposed")
|
||||
throw new OriginSceneError(
|
||||
"inert-origin",
|
||||
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
||||
);
|
||||
if (this.liveOutgoingOf(originKey))
|
||||
throw new OriginSceneError(
|
||||
"origin-busy",
|
||||
`Origin "${originKey}" already has an outgoing operation. ` +
|
||||
"A descendant target may start its own operation instead.",
|
||||
);
|
||||
return origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the retained previous entry for a back operation.
|
||||
*
|
||||
* If the origin's incoming edge comes from that previous entry (a forward
|
||||
* push still settling, or a connected resting edge), the edge is collapsed
|
||||
* first. Creating a new edge back to its origin would otherwise form X→Y→X.
|
||||
* Reciprocal choreographies begin at the same visual endpoints, so this
|
||||
* handoff is continuous without remounting either node.
|
||||
*/
|
||||
private resolveBackTarget(origin: SceneNodeRecord): {
|
||||
target: SceneNodeRecord;
|
||||
suspendedEdge?: MutableOriginOperation;
|
||||
} {
|
||||
const previousKey = origin.previousNodeKey;
|
||||
if (!previousKey)
|
||||
throw new OriginSceneError(
|
||||
"no-previous-entry",
|
||||
`Cannot go back from origin "${origin.key}" without a previous entry.`,
|
||||
);
|
||||
const target = this.nodes.get(previousKey);
|
||||
if (!target)
|
||||
throw new OriginSceneError(
|
||||
"no-previous-entry",
|
||||
`Cannot go back to missing retained entry "${previousKey}".`,
|
||||
);
|
||||
|
||||
let suspendedEdge: MutableOriginOperation | undefined;
|
||||
const incoming = this.incomingOf(origin);
|
||||
if (incoming?.originKey === target.key) {
|
||||
if (incoming.intent !== "commit")
|
||||
throw new OriginSceneError(
|
||||
"undecided-forward",
|
||||
"Cannot go back through an undecided forward operation.",
|
||||
);
|
||||
if (incoming.phase !== "finished") this.commit(incoming.id);
|
||||
|
||||
const resting = this.operations.get(incoming.id);
|
||||
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
|
||||
// Re-root A→page→drawer as A→drawer before adding drawer→page.
|
||||
this.detach(resting);
|
||||
this.transferPosition(target, origin);
|
||||
target.incomingOperationId = undefined;
|
||||
suspendedEdge = resting;
|
||||
}
|
||||
}
|
||||
|
||||
// A target still positioning the origin (through edges other than the
|
||||
// one collapsed above) would close a visual cycle.
|
||||
if (
|
||||
this.incomingChain(origin).some(
|
||||
(operation) => operation.originKey === target.key,
|
||||
)
|
||||
)
|
||||
throw new OriginSceneError(
|
||||
"target-busy",
|
||||
`Retained target "${target.key}" still positions origin "${origin.key}".`,
|
||||
);
|
||||
if (this.liveOutgoingOf(target.key))
|
||||
throw new OriginSceneError(
|
||||
"target-busy",
|
||||
`Retained target "${target.key}" already has an outgoing operation.`,
|
||||
);
|
||||
if (target.incomingOperationId)
|
||||
throw new OriginSceneError(
|
||||
"target-busy",
|
||||
`Retained target "${target.key}" is already transitioning.`,
|
||||
);
|
||||
return { target, suspendedEdge };
|
||||
}
|
||||
|
||||
/** Create the target node for a push or replace operation. */
|
||||
private createForwardTarget(
|
||||
origin: SceneNodeRecord,
|
||||
action: OriginAction,
|
||||
history: Exclude<OriginHistoryMode, "back">,
|
||||
): SceneNodeRecord {
|
||||
if (!action.target)
|
||||
throw new OriginSceneError(
|
||||
"invalid-action",
|
||||
`A "${history}" operation requires a target view recipe.`,
|
||||
);
|
||||
// A replacement skips the origin in the retained chain. The origin stays
|
||||
// mounted until commit so an interactive replacement can still cancel.
|
||||
const previousKey =
|
||||
history === "push" ? origin.key : origin.previousNodeKey;
|
||||
return this.createNode(action.target, previousKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a node presentable again. A node still holding a connected resting
|
||||
* edge (an open drawer, say) stays exposed rather than becoming active.
|
||||
*/
|
||||
private activate(node: SceneNodeRecord): void {
|
||||
const resting = this.presentedOutgoingOf(node.key);
|
||||
if (resting?.phase === "finished") {
|
||||
node.state = "exposed";
|
||||
return;
|
||||
}
|
||||
node.state = "active";
|
||||
this.refreshPresentedState(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute whether a presented node is participating in a live operation.
|
||||
* Parked and exposed nodes keep their retained state.
|
||||
*/
|
||||
private refreshPresentedState(node: SceneNodeRecord): void {
|
||||
if (node.state === "parked" || node.state === "exposed") return;
|
||||
const incoming = this.incomingOf(node);
|
||||
node.state =
|
||||
(incoming && incoming.phase !== "finished") ||
|
||||
this.liveOutgoingOf(node.key)
|
||||
? "transitioning"
|
||||
: "active";
|
||||
}
|
||||
|
||||
// Structural primitives ---------------------------------------------------
|
||||
|
||||
private createNode(
|
||||
view: OriginView,
|
||||
previousNodeKey?: string,
|
||||
): SceneNodeRecord {
|
||||
const sequence = ++this.nodeSequence;
|
||||
const node: SceneNodeRecord = shallowReactive({
|
||||
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
|
||||
sequence,
|
||||
view: markRaw(view),
|
||||
previousNodeKey,
|
||||
state: "active",
|
||||
incomingOperationId: undefined,
|
||||
});
|
||||
this.nodes.set(node.key, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private createOperation(
|
||||
init: Pick<
|
||||
MutableOriginOperation,
|
||||
| "originKey"
|
||||
| "targetKey"
|
||||
| "entryTargetKey"
|
||||
| "choreography"
|
||||
| "placement"
|
||||
| "history"
|
||||
| "originRect"
|
||||
>,
|
||||
): MutableOriginOperation {
|
||||
const operation = reactive<MutableOriginOperation>({
|
||||
id: ++this.operationSequence,
|
||||
originKey: init.originKey,
|
||||
targetKey: init.targetKey,
|
||||
entryTargetKey: init.entryTargetKey,
|
||||
choreography: markRaw(init.choreography),
|
||||
placement: init.placement,
|
||||
history: init.history,
|
||||
progress: 0,
|
||||
overshoot: 0,
|
||||
velocity: 0,
|
||||
phase: "preparing",
|
||||
intent: "undecided",
|
||||
originRect: init.originRect,
|
||||
});
|
||||
this.attach(operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private attach(operation: MutableOriginOperation): void {
|
||||
this.operations.set(operation.id, operation);
|
||||
const siblings = this.outgoing.get(operation.originKey) ?? [];
|
||||
this.outgoing.set(operation.originKey, [...siblings, operation]);
|
||||
}
|
||||
|
||||
private detach(operation: MutableOriginOperation): void {
|
||||
this.operations.delete(operation.id);
|
||||
const siblings = (this.outgoing.get(operation.originKey) ?? []).filter(
|
||||
(sibling) => sibling.id !== operation.id,
|
||||
);
|
||||
if (siblings.length) this.outgoing.set(operation.originKey, siblings);
|
||||
else this.outgoing.delete(operation.originKey);
|
||||
}
|
||||
|
||||
private removeNode(nodeKey: string): void {
|
||||
this.nodes.delete(nodeKey);
|
||||
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the operations a node started, because the node is leaving
|
||||
* the visible graph: nodes they created (push/replace targets, including
|
||||
* replacements whose history skips this node) are removed, and retained
|
||||
* entries they revealed (back targets) are restored as if that back
|
||||
* operation were cancelled.
|
||||
*/
|
||||
/** Park a retained entry again, discarding what it started meanwhile. */
|
||||
private returnToHistory(node: SceneNodeRecord, retained: Set<string>): void {
|
||||
this.discardOutgoing(node.key, new Set(retained));
|
||||
node.incomingOperationId = undefined;
|
||||
node.state = "parked";
|
||||
}
|
||||
|
||||
/** A node's key plus its retained history, which a cancel must keep. */
|
||||
private retainedKeys(node: SceneNodeRecord): Set<string> {
|
||||
return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]);
|
||||
}
|
||||
|
||||
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
|
||||
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
|
||||
// Includes finished resting edges: an open drawer closes with its page.
|
||||
if (operation.history === "back") {
|
||||
this.cancel(operation.id);
|
||||
} else {
|
||||
this.detach(operation);
|
||||
this.removeBranch(operation.entryTargetKey, visited);
|
||||
this.removeBranch(operation.targetKey, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node, every node whose retained history passes through it, and
|
||||
* every operation touching any of them. Earlier entries are untouched.
|
||||
* Operations the node itself started are discarded first.
|
||||
*/
|
||||
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
||||
if (visited.has(nodeKey)) return;
|
||||
visited.add(nodeKey);
|
||||
|
||||
for (const node of [...this.nodes.values()])
|
||||
if (node.previousNodeKey === nodeKey)
|
||||
this.removeBranch(node.key, visited);
|
||||
this.discardOutgoing(nodeKey, visited);
|
||||
for (const operation of [...this.operations.values()])
|
||||
if (
|
||||
operation.originKey === nodeKey ||
|
||||
operation.targetKey === nodeKey ||
|
||||
operation.entryTargetKey === nodeKey
|
||||
)
|
||||
this.detach(operation);
|
||||
this.removeNode(nodeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `target` the visual-graph position currently held by `from`: either
|
||||
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
|
||||
* moves; only edge bookkeeping changes.
|
||||
*/
|
||||
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
|
||||
const parent = this.incomingOf(from);
|
||||
if (parent) {
|
||||
parent.targetKey = target.key;
|
||||
target.incomingOperationId = parent.id;
|
||||
} else {
|
||||
this.roots.value = this.roots.value.map((key) =>
|
||||
key === from.key ? target.key : key,
|
||||
);
|
||||
target.incomingOperationId = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
markRaw,
|
||||
nextTick,
|
||||
shallowRef,
|
||||
type ComputedRef,
|
||||
type CSSProperties,
|
||||
type InjectionKey,
|
||||
} from "vue";
|
||||
import {
|
||||
composeEffects,
|
||||
normalizedEffect,
|
||||
type OriginEffect,
|
||||
type OriginEffectSet,
|
||||
type OriginSpring,
|
||||
type OriginOperationIntent,
|
||||
type OriginOperationPhase,
|
||||
} from "../choreography";
|
||||
import type { OriginAction, OriginView } from "../navigation";
|
||||
import {
|
||||
DEV,
|
||||
warn,
|
||||
clamp,
|
||||
clamp01,
|
||||
measureRect,
|
||||
prefersReducedMotion,
|
||||
windowRect,
|
||||
type OriginRect,
|
||||
} from "../shared";
|
||||
import {
|
||||
OriginSceneError,
|
||||
SceneGraph,
|
||||
shouldCommit,
|
||||
type SceneNodeRecord,
|
||||
} from "./graph";
|
||||
import type {
|
||||
CreateOriginSceneOptions,
|
||||
MutableOriginOperation,
|
||||
OriginContext,
|
||||
OriginFinishOptions,
|
||||
OriginOperation,
|
||||
OriginOperationHandle,
|
||||
OriginScene,
|
||||
OriginSceneNode,
|
||||
} from "./types";
|
||||
|
||||
// Compositing ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compositor invariants rather than visual theming. They stay inline so a
|
||||
* missing optional stylesheet can never place scene nodes back into normal
|
||||
* block/flex flow and vertically stack views.
|
||||
*
|
||||
* Deliberately not frozen: it is spread once per node per frame, and V8
|
||||
* spreads frozen objects markedly slower.
|
||||
*/
|
||||
const NODE_BASE_STYLE: Readonly<CSSProperties> = {
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
/** Base z-index keeping every node above content outside the scene. */
|
||||
const Z_INDEX_BASE = 1_000_000;
|
||||
/** z-index distance between adjacent relative layers. */
|
||||
const Z_INDEX_PER_LAYER = 10_000;
|
||||
|
||||
/**
|
||||
* Effect layers applied to a node, ordered outermost frame first.
|
||||
*
|
||||
* A node inherits the frame and target effects of every edge on its incoming
|
||||
* chain, then adds the frame and source effects of the edge it originates.
|
||||
* Ancestors' source effects are intentionally excluded: only the initiating
|
||||
* component receives the source side of an operation.
|
||||
*
|
||||
* @param effectsOf - The current effect set of an operation's choreography.
|
||||
*/
|
||||
export const nodeEffectLayers = (
|
||||
graph: SceneGraph,
|
||||
node: SceneNodeRecord,
|
||||
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
|
||||
): OriginEffect[] => {
|
||||
const chain = graph.incomingChain(node);
|
||||
// Evaluate choreographies node-first, then apply them outermost-first.
|
||||
const effectSets = chain.map(effectsOf);
|
||||
const layers: OriginEffect[] = [];
|
||||
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
||||
const targetLayer = chain[index]!.placement === "above" ? 1 : -1;
|
||||
layers.push(
|
||||
normalizedEffect(effectSets[index]!.frame),
|
||||
normalizedEffect(effectSets[index]!.target, targetLayer),
|
||||
);
|
||||
}
|
||||
|
||||
const outgoing = graph.presentedOutgoingOf(node.key);
|
||||
if (outgoing) {
|
||||
const effects = effectsOf(outgoing);
|
||||
layers.push(
|
||||
normalizedEffect(effects.frame),
|
||||
normalizedEffect(effects.source),
|
||||
);
|
||||
}
|
||||
return layers;
|
||||
};
|
||||
|
||||
/** Collapse every effect layer applied to a node into its host's style. */
|
||||
export const composeNodeStyle = (
|
||||
graph: SceneGraph,
|
||||
nodeKey: string,
|
||||
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
|
||||
): CSSProperties => {
|
||||
const node = graph.node(nodeKey);
|
||||
if (!node) return {};
|
||||
|
||||
const { style, layer } = composeEffects(
|
||||
nodeEffectLayers(graph, node, effectsOf),
|
||||
NODE_BASE_STYLE,
|
||||
);
|
||||
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
|
||||
style.pointerEvents = graph.isInteractive(nodeKey) ? "auto" : "none";
|
||||
if (node.state === "parked") {
|
||||
// Parked entries remain mounted in their original flat hosts. Keeping the
|
||||
// DOM preserves local component state and nested scroll positions, while
|
||||
// visibility/inert handling removes them from presentation.
|
||||
style.visibility = "hidden";
|
||||
style.contentVisibility = "hidden";
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
// Settling spring -----------------------------------------------------------
|
||||
|
||||
/*
|
||||
* A damped spring settles an operation toward 0 or 1. It keeps release
|
||||
* velocity continuous with pointer motion without imposing a fixed-duration
|
||||
* easing on custom choreographies.
|
||||
*/
|
||||
|
||||
const DEFAULT_STIFFNESS = 280;
|
||||
const DEFAULT_DAMPING = 30;
|
||||
/** Fixed integration step, in seconds. */
|
||||
const STEP = 1 / 120;
|
||||
/** Frame durations are clamped to this range, in seconds. */
|
||||
const MIN_FRAME = 0.001;
|
||||
const MAX_FRAME = 0.032;
|
||||
/** Release velocities beyond this magnitude are clamped. */
|
||||
const MAX_INITIAL_VELOCITY = 8;
|
||||
const POSITION_TOLERANCE = 0.002;
|
||||
const VELOCITY_TOLERANCE = 0.02;
|
||||
|
||||
/** Position and velocity of a spring, in progress units. */
|
||||
export interface SpringState {
|
||||
readonly position: number;
|
||||
readonly velocity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a spring by one animation frame.
|
||||
*
|
||||
* @param elapsedMs - Time since the previous frame in milliseconds.
|
||||
*/
|
||||
export const stepSpring = (
|
||||
state: SpringState,
|
||||
target: number,
|
||||
elapsedMs: number,
|
||||
spring: OriginSpring = {},
|
||||
): SpringState & { readonly done: boolean } => {
|
||||
const stiffness = spring.stiffness ?? DEFAULT_STIFFNESS;
|
||||
const damping = spring.damping ?? DEFAULT_DAMPING;
|
||||
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
|
||||
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
|
||||
const dt = elapsed / iterations;
|
||||
let { position, velocity } = state;
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const acceleration = (target - position) * stiffness - velocity * damping;
|
||||
velocity += acceleration * dt;
|
||||
position += velocity * dt;
|
||||
}
|
||||
const done =
|
||||
Math.abs(target - position) < POSITION_TOLERANCE &&
|
||||
Math.abs(velocity) < VELOCITY_TOLERANCE;
|
||||
return { position, velocity, done };
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a spring on `requestAnimationFrame` until it rests at `target` or
|
||||
* `onFrame` returns `false` because its subject no longer exists.
|
||||
*
|
||||
* Each frame reports the raw position; the final frame reports exactly
|
||||
* `target` with zero velocity.
|
||||
*/
|
||||
export const runSpring = (
|
||||
from: SpringState,
|
||||
target: number,
|
||||
onFrame: (position: number, velocity: number) => boolean,
|
||||
spring: OriginSpring = {},
|
||||
): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
let state: SpringState = {
|
||||
position: from.position,
|
||||
velocity: clamp(
|
||||
from.velocity,
|
||||
-MAX_INITIAL_VELOCITY,
|
||||
MAX_INITIAL_VELOCITY,
|
||||
),
|
||||
};
|
||||
let previous = performance.now();
|
||||
|
||||
const frame = (time: number) => {
|
||||
const next = stepSpring(state, target, time - previous, spring);
|
||||
previous = time;
|
||||
state = next;
|
||||
const alive = next.done
|
||||
? onFrame(target, 0)
|
||||
: onFrame(next.position, next.velocity);
|
||||
if (!alive || next.done) resolve();
|
||||
else window.requestAnimationFrame(frame);
|
||||
};
|
||||
window.requestAnimationFrame(frame);
|
||||
});
|
||||
|
||||
// Continuity check (development only) ---------------------------------------
|
||||
|
||||
/** A host's rendered transform as a 4×4 matrix, plus its opacity. */
|
||||
interface Pose {
|
||||
readonly matrix: readonly number[];
|
||||
readonly opacity: number;
|
||||
}
|
||||
|
||||
const warnedChoreographies = new WeakSet<object>();
|
||||
|
||||
/** Read a visible host's rendered pose; `null` if hidden or unreadable. */
|
||||
const poseOf = (host: HTMLElement): Pose | null => {
|
||||
const style = getComputedStyle(host);
|
||||
if (style.visibility === "hidden") return null;
|
||||
const matrix = parseMatrix(style.transform);
|
||||
const opacity = Number(style.opacity);
|
||||
return matrix && Number.isFinite(opacity) ? { matrix, opacity } : null;
|
||||
};
|
||||
|
||||
/** Parse a computed `transform` ("none", matrix() or matrix3d()). */
|
||||
const parseMatrix = (transform: string): number[] | null => {
|
||||
if (!transform || transform === "none")
|
||||
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const values = /^matrix(3d)?\(([^)]*)\)$/.exec(transform);
|
||||
if (!values) return null;
|
||||
const numbers = values[2]!.split(",").map(Number);
|
||||
if (numbers.some((value) => !Number.isFinite(value))) return null;
|
||||
if (values[1]) return numbers.length === 16 ? numbers : null;
|
||||
if (numbers.length !== 6) return null;
|
||||
const [a, b, c, d, e, f] = numbers as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1];
|
||||
};
|
||||
|
||||
/** Equal within half a pixel of translation and 1% of anything else. */
|
||||
const posesMatch = (a: Pose, b: Pose): boolean =>
|
||||
Math.abs(a.opacity - b.opacity) <= 0.01 &&
|
||||
a.matrix.every((value, index) => {
|
||||
const tolerance = index === 12 || index === 13 || index === 14 ? 0.5 : 0.01;
|
||||
return Math.abs(value - b.matrix[index]!) <= tolerance;
|
||||
});
|
||||
|
||||
// Scene ---------------------------------------------------------------------
|
||||
|
||||
/** Imperative controller for one operation, returned by `Scene.begin()`. */
|
||||
class OperationHandle implements OriginOperationHandle {
|
||||
readonly id: number;
|
||||
readonly originKey: string;
|
||||
readonly targetKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly scene: Scene,
|
||||
/** Kept after the graph drops it, so `intent` stays readable. */
|
||||
private readonly operation: MutableOriginOperation,
|
||||
/** The target at creation; later edge rewrites do not change it. */
|
||||
targetKey: string,
|
||||
) {
|
||||
markRaw(this);
|
||||
this.id = operation.id;
|
||||
this.originKey = operation.originKey;
|
||||
this.targetKey = targetKey;
|
||||
}
|
||||
|
||||
get intent(): OriginOperationIntent {
|
||||
return this.operation.intent;
|
||||
}
|
||||
|
||||
update(progress: number, velocity = 0): void {
|
||||
this.scene.graph.update(this.id, progress, velocity);
|
||||
}
|
||||
|
||||
finish(options?: OriginFinishOptions): Promise<boolean> {
|
||||
return this.scene.finish(this.id, options);
|
||||
}
|
||||
|
||||
async cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void> {
|
||||
await this.scene.finish(this.id, {
|
||||
commit: false,
|
||||
animate: options?.animate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One independent scene: the {@link SceneGraph} model plus the timing around
|
||||
* it — waiting for Vue to mount targets, measuring hosts, and settling
|
||||
* released operations with a spring.
|
||||
*/
|
||||
class Scene implements OriginScene {
|
||||
/** @internal */
|
||||
readonly graph: SceneGraph;
|
||||
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
|
||||
readonly operations: ComputedRef<readonly OriginOperation[]>;
|
||||
readonly roots: SceneGraph["roots"];
|
||||
|
||||
/** DOM registered by the renderer, used only for measurement. */
|
||||
private readonly hosts = new Map<string, HTMLElement>();
|
||||
private container: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
/**
|
||||
* Scene-container bounds, or the browser window before mounting. Cached and
|
||||
* refreshed on resize and whenever an operation begins, so compositing
|
||||
* never forces layout.
|
||||
*/
|
||||
private readonly viewport = shallowRef<OriginRect>(windowRect());
|
||||
/**
|
||||
* One memoized effect set per operation. It recomputes only when that
|
||||
* operation's progress, phase, intent, rects, or the viewport change, so
|
||||
* nodes sharing an edge do not evaluate its choreography again.
|
||||
*/
|
||||
/** The latest release (finish call) of each operation. */
|
||||
private readonly releases = new WeakMap<MutableOriginOperation, object>();
|
||||
/** Final outcome of each operation, once a release has decided it. */
|
||||
private readonly outcomes = new WeakMap<MutableOriginOperation, boolean>();
|
||||
/** Interrupted releases waiting for a later release to decide. */
|
||||
private readonly waiters = new WeakMap<
|
||||
MutableOriginOperation,
|
||||
Array<(committed: boolean) => void>
|
||||
>();
|
||||
private readonly effectSets = new WeakMap<
|
||||
MutableOriginOperation,
|
||||
ComputedRef<OriginEffectSet>
|
||||
>();
|
||||
|
||||
constructor(options: CreateOriginSceneOptions) {
|
||||
// Never proxy the scene: its methods rely on raw internal state.
|
||||
markRaw(this);
|
||||
this.graph = new SceneGraph(
|
||||
Array.isArray(options.initial) ? options.initial : [options.initial],
|
||||
);
|
||||
this.roots = this.graph.roots;
|
||||
this.nodes = computed(() =>
|
||||
[...this.graph.nodes.values()].map((node) => ({
|
||||
...node,
|
||||
history: this.graph.historyOf(node).map((entry) => entry.view),
|
||||
})),
|
||||
);
|
||||
this.operations = computed(() =>
|
||||
[...this.graph.operations.values()].filter(
|
||||
(operation) => operation.phase !== "finished",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
contextFor(nodeKey: string): OriginContext {
|
||||
return this.graph.contextFor(nodeKey);
|
||||
}
|
||||
|
||||
async begin(
|
||||
originKey: string,
|
||||
action: OriginAction,
|
||||
): Promise<OriginOperationHandle> {
|
||||
if (this.container) this.refreshViewport();
|
||||
// Only a scene at rest has a pose the new operation must continue from.
|
||||
const poses =
|
||||
DEV && this.operations.value.length === 0 ? this.capturePoses() : null;
|
||||
const operation = this.graph.begin(
|
||||
originKey,
|
||||
action,
|
||||
this.measure(originKey),
|
||||
);
|
||||
const { targetKey } = operation;
|
||||
|
||||
// Let Vue mount the target before measuring it. Gesture recognizers
|
||||
// buffer pointer progress while this short preparation step is pending.
|
||||
await nextTick();
|
||||
operation.targetRect = this.measure(targetKey);
|
||||
operation.phase = "interactive";
|
||||
if (poses) this.warnOnJump(poses, operation);
|
||||
return new OperationHandle(this, operation, targetKey);
|
||||
}
|
||||
|
||||
async perform(originKey: string, action: OriginAction): Promise<boolean> {
|
||||
const handle = await this.begin(originKey, action);
|
||||
return handle.finish({ commit: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix an operation's outcome, settle it, and rewrite the graph.
|
||||
*
|
||||
* Calling `update()` while an operation settles "catches" it: the spring
|
||||
* stops and progress is under control again. A later `finish()` then
|
||||
* decides the outcome; every earlier, interrupted `finish()` resolves with
|
||||
* that final outcome instead of applying its own.
|
||||
*
|
||||
* @internal Called through {@link OperationHandle}.
|
||||
*/
|
||||
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
|
||||
const operation = this.graph.operation(id);
|
||||
if (!operation) return Promise.resolve(false);
|
||||
if (operation.phase === "finished")
|
||||
return Promise.resolve(operation.intent === "commit");
|
||||
const release = {};
|
||||
this.releases.set(operation, release);
|
||||
return this.release(operation, release, options);
|
||||
}
|
||||
|
||||
private async release(
|
||||
operation: MutableOriginOperation,
|
||||
release: object,
|
||||
options: OriginFinishOptions,
|
||||
): Promise<boolean> {
|
||||
// The outcome is fixed at release, so the target can originate another
|
||||
// operation while this one is only visually settling.
|
||||
const commit = shouldCommit(operation, options);
|
||||
operation.intent = commit ? "commit" : "cancel";
|
||||
operation.phase = "settling";
|
||||
const current = () =>
|
||||
this.releases.get(operation) === release &&
|
||||
operation.phase === "settling";
|
||||
await this.settle(
|
||||
operation,
|
||||
commit ? 1 : 0,
|
||||
options.animate ?? true,
|
||||
current,
|
||||
);
|
||||
|
||||
// Another release already decided this operation.
|
||||
const decided = this.outcomes.get(operation);
|
||||
if (decided !== undefined) return decided;
|
||||
// A later operation may already have finalized this edge.
|
||||
const finished = (operation.phase as OriginOperationPhase) === "finished";
|
||||
if (!this.graph.operation(operation.id) || finished)
|
||||
return this.decide(
|
||||
operation,
|
||||
finished ? operation.intent === "commit" : commit,
|
||||
);
|
||||
// Caught by update() or superseded by a later finish(): wait for the
|
||||
// release that decides.
|
||||
if (!current())
|
||||
return new Promise<boolean>((resolve) =>
|
||||
this.waiters.set(operation, [
|
||||
...(this.waiters.get(operation) ?? []),
|
||||
resolve,
|
||||
]),
|
||||
);
|
||||
|
||||
return this.decide(
|
||||
operation,
|
||||
commit
|
||||
? this.graph.commit(operation.id)
|
||||
: (this.graph.cancel(operation.id), false),
|
||||
);
|
||||
}
|
||||
|
||||
/** Record an operation's final outcome and settle interrupted releases. */
|
||||
private decide(operation: MutableOriginOperation, committed: boolean) {
|
||||
this.outcomes.set(operation, committed);
|
||||
for (const resolve of this.waiters.get(operation) ?? []) resolve(committed);
|
||||
this.waiters.delete(operation);
|
||||
return committed;
|
||||
}
|
||||
|
||||
registerElement(nodeKey: string, element: HTMLElement | null): void {
|
||||
if (element) this.hosts.set(nodeKey, element);
|
||||
else this.hosts.delete(nodeKey);
|
||||
}
|
||||
|
||||
registerContainer(element: HTMLElement | null): void {
|
||||
if (element === this.container) return;
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.container = element;
|
||||
if (!element) return;
|
||||
this.refreshViewport();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this.resizeObserver = new ResizeObserver(() => this.refreshViewport());
|
||||
this.resizeObserver.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
styleForNode(nodeKey: string): CSSProperties {
|
||||
return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
|
||||
}
|
||||
|
||||
isNodeInteractive(nodeKey: string): boolean {
|
||||
return this.graph.isInteractive(nodeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an operation to 0 or 1, animated unless motion is disabled. The
|
||||
* spring stops early once `current()` reports the release was caught or
|
||||
* superseded.
|
||||
*/
|
||||
private async settle(
|
||||
operation: MutableOriginOperation,
|
||||
target: 0 | 1,
|
||||
animate: boolean,
|
||||
current: () => boolean,
|
||||
) {
|
||||
if (!animate || prefersReducedMotion()) {
|
||||
operation.progress = target;
|
||||
operation.overshoot = 0;
|
||||
operation.velocity = 0;
|
||||
return;
|
||||
}
|
||||
// Start from the raw position so a rubber-banded drag springs back
|
||||
// continuously instead of snapping its overshoot away.
|
||||
const from = {
|
||||
position: operation.progress + operation.overshoot,
|
||||
velocity: operation.velocity,
|
||||
};
|
||||
await runSpring(
|
||||
from,
|
||||
target,
|
||||
(position, velocity) => {
|
||||
if (!this.graph.operation(operation.id) || !current()) return false;
|
||||
operation.progress = clamp01(position);
|
||||
operation.overshoot = position - operation.progress;
|
||||
operation.velocity = velocity;
|
||||
return true;
|
||||
},
|
||||
operation.choreography.spring,
|
||||
);
|
||||
}
|
||||
|
||||
/** Development only: each visible host's rendered transform and opacity. */
|
||||
private capturePoses(): Map<string, Pose> {
|
||||
const poses = new Map<string, Pose>();
|
||||
for (const [key, host] of this.hosts) {
|
||||
const pose = poseOf(host);
|
||||
if (pose) poses.set(key, pose);
|
||||
}
|
||||
return poses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Development only: warn when an operation's first frame moved a node that
|
||||
* was visibly at rest, the one-frame "pop" of mismatched choreographies.
|
||||
*/
|
||||
private warnOnJump(
|
||||
before: Map<string, Pose>,
|
||||
operation: MutableOriginOperation,
|
||||
) {
|
||||
const { choreography } = operation;
|
||||
if (warnedChoreographies.has(choreography)) return;
|
||||
for (const [key, pose] of before) {
|
||||
const host = this.hosts.get(key);
|
||||
const after = host && poseOf(host);
|
||||
if (!after || posesMatch(pose, after)) continue;
|
||||
warnedChoreographies.add(choreography);
|
||||
warn(
|
||||
`"${choreography.name ?? "anonymous"}" moved node "${key}" on its ` +
|
||||
"first frame. At progress 0 its effects should match the pose the " +
|
||||
"scene was resting in, usually the previous choreography at " +
|
||||
"progress 1; otherwise the view jumps.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private measure(nodeKey: string): OriginRect | undefined {
|
||||
const host = this.hosts.get(nodeKey);
|
||||
return host ? measureRect(host) : undefined;
|
||||
}
|
||||
|
||||
/** Re-measure the container, replacing the cached rect only on change. */
|
||||
private refreshViewport(): void {
|
||||
const next = this.container ? measureRect(this.container) : windowRect();
|
||||
const current = this.viewport.value;
|
||||
if (
|
||||
next.top !== current.top ||
|
||||
next.left !== current.left ||
|
||||
next.width !== current.width ||
|
||||
next.height !== current.height
|
||||
)
|
||||
this.viewport.value = next;
|
||||
}
|
||||
|
||||
private readonly effectsOf = (
|
||||
operation: MutableOriginOperation,
|
||||
): OriginEffectSet => {
|
||||
let effects = this.effectSets.get(operation);
|
||||
if (!effects) {
|
||||
effects = computed(() =>
|
||||
operation.choreography.effects({
|
||||
progress: operation.progress,
|
||||
overshoot: operation.overshoot,
|
||||
velocity: operation.velocity,
|
||||
phase: operation.phase,
|
||||
intent: operation.intent,
|
||||
originRect: operation.originRect,
|
||||
targetRect: operation.targetRect,
|
||||
viewport: this.viewport.value,
|
||||
}),
|
||||
);
|
||||
this.effectSets.set(operation, effects);
|
||||
}
|
||||
return effects.value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one independent scene/history context.
|
||||
*
|
||||
* The implementation stores nodes flat. Operation edges describe how their
|
||||
* visual effects compose, but a Vue component never moves to a different VNode
|
||||
* parent as edges are added or removed. This is what preserves component state
|
||||
* while X→Y and Y→Z overlap.
|
||||
*
|
||||
* @param options - Initial root view recipe or recipes.
|
||||
* @returns A self-contained reactive scene. Render it through `OriginScene`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const scene = createOriginScene({
|
||||
* initial: originView(HomeView, undefined, { key: "home" }),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createOriginScene(
|
||||
options: CreateOriginSceneOptions,
|
||||
): OriginScene {
|
||||
return new Scene(options);
|
||||
}
|
||||
|
||||
// Node-scoped access --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Injection payload provided by each stable scene-node host.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface OriginNodeScope {
|
||||
/** Scene containing the node. */
|
||||
readonly scene: OriginScene;
|
||||
/** Unique key of the mounted node. */
|
||||
readonly nodeKey: string;
|
||||
}
|
||||
|
||||
/** Node-scoped controls returned by {@link useOrigin}. */
|
||||
export interface UseOrigin {
|
||||
/** Key of the mounted node containing the calling component. */
|
||||
readonly nodeKey: string;
|
||||
/** Scene containing the calling component. */
|
||||
readonly scene: OriginScene;
|
||||
/** Reactive context for the calling component's scene node. */
|
||||
readonly context: ComputedRef<OriginContext>;
|
||||
/** Reactive shorthand for `context.value.view`. */
|
||||
readonly view: ComputedRef<OriginView>;
|
||||
/** Reactive shorthand for `context.value.previous`. */
|
||||
readonly previous: ComputedRef<OriginView | undefined>;
|
||||
/** Reactive shorthand for `context.value.canGoBack`. */
|
||||
readonly canGoBack: ComputedRef<boolean>;
|
||||
/** Begin an interactively controlled operation from this component. */
|
||||
begin(action: OriginAction): Promise<OriginOperationHandle>;
|
||||
/** Programmatically create and commit a target from this component. */
|
||||
perform(action: OriginAction): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection key used by the internal scene-node host to establish origin
|
||||
* ownership for descendant components.
|
||||
*
|
||||
* Application code normally calls {@link useOrigin} instead of injecting this
|
||||
* key directly.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
|
||||
Symbol("origin-node-scope");
|
||||
|
||||
/**
|
||||
* Access the scene from the component that owns an interaction declaration.
|
||||
*
|
||||
* There is deliberately no `activeView`: the injected node is the origin
|
||||
* because this component is where the event or application action occurred.
|
||||
*
|
||||
* @returns Node-scoped scene state and operation controls.
|
||||
* @throws If called outside a component rendered by `OriginScene`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const origin = useOrigin();
|
||||
*
|
||||
* function openProfile() {
|
||||
* return origin.perform(
|
||||
* forward(originView(ProfileView), slideLeft),
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useOrigin(): UseOrigin {
|
||||
const scope = inject(originNodeScopeKey);
|
||||
if (!scope)
|
||||
throw new OriginSceneError(
|
||||
"outside-scene",
|
||||
"useOrigin() must be called inside an <OriginScene> view.",
|
||||
);
|
||||
|
||||
const { scene, nodeKey } = scope;
|
||||
const context = computed(() => scene.contextFor(nodeKey));
|
||||
|
||||
return {
|
||||
nodeKey,
|
||||
scene,
|
||||
context,
|
||||
view: computed(() => context.value.view),
|
||||
previous: computed(() => context.value.previous),
|
||||
canGoBack: computed(() => context.value.canGoBack),
|
||||
begin: (action) => scene.begin(nodeKey, action),
|
||||
perform: (action) => scene.perform(nodeKey, action),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
|
||||
import type {
|
||||
OriginChoreography,
|
||||
OriginOperationIntent,
|
||||
OriginOperationPhase,
|
||||
} from "../choreography";
|
||||
import type {
|
||||
OriginAction,
|
||||
OriginHistoryMode,
|
||||
OriginPlacement,
|
||||
OriginView,
|
||||
} from "../navigation";
|
||||
import type { OriginRect } from "../shared";
|
||||
|
||||
/**
|
||||
* Visibility/lifecycle role of a mounted scene node.
|
||||
*
|
||||
* - `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" | "exposed" | "parked";
|
||||
|
||||
/**
|
||||
* Read-only diagnostic representation of one mounted scene node.
|
||||
*
|
||||
* A node corresponds to one currently mounted Vue component instance.
|
||||
*/
|
||||
export interface OriginSceneNode {
|
||||
/** Unique identity for this particular mounted scene node. */
|
||||
readonly key: string;
|
||||
/** Monotonically increasing creation order within the scene. */
|
||||
readonly sequence: number;
|
||||
/** Recipe used to create the node's component. */
|
||||
readonly view: OriginView;
|
||||
/** Recipes for the retained instance chain preceding this node. */
|
||||
readonly history: readonly OriginView[];
|
||||
/** Key of the retained mounted entry immediately before this node. */
|
||||
readonly previousNodeKey?: string;
|
||||
/** Current visibility/lifecycle role of this mounted instance. */
|
||||
readonly state: OriginSceneNodeState;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Read-only diagnostic representation of one live operation edge. */
|
||||
export interface OriginOperation {
|
||||
/** Unique, monotonically increasing operation identity within the scene. */
|
||||
readonly id: number;
|
||||
/** Key of the mounted component that originated the operation. */
|
||||
readonly originKey: string;
|
||||
/** Key of the newly created or retained target component. */
|
||||
readonly targetKey: string;
|
||||
/** Original history entry targeted before visual-edge rewrites. */
|
||||
readonly entryTargetKey: string;
|
||||
/** Choreography currently calculating this edge's effects. */
|
||||
readonly choreography: OriginChoreography;
|
||||
/** Target stacking relationship to the origin. */
|
||||
readonly placement: OriginPlacement;
|
||||
/** Retained-history behavior performed if this operation commits. */
|
||||
readonly history: OriginHistoryMode;
|
||||
/** Normalized progress in the inclusive range `0..1`. */
|
||||
readonly progress: number;
|
||||
/** Drag distance past either end of `progress`; usually `0`. */
|
||||
readonly overshoot: number;
|
||||
/** Latest normalized velocity in progress units per second. */
|
||||
readonly velocity: number;
|
||||
/** Current operation lifecycle phase. */
|
||||
readonly phase: OriginOperationPhase;
|
||||
/** Selected operation outcome. */
|
||||
readonly intent: OriginOperationIntent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node-local information supplied to component-owned action factories.
|
||||
*
|
||||
* There is deliberately no global `current` or `active` view. The component
|
||||
* handling the event is the origin represented by this context.
|
||||
*/
|
||||
export interface OriginContext {
|
||||
/** Unique key of the mounted node that owns the interaction. */
|
||||
readonly nodeKey: string;
|
||||
/** Recipe used to create the origin node. */
|
||||
readonly view: OriginView;
|
||||
/** Whether this retained history entry has a mounted previous instance. */
|
||||
readonly canGoBack: boolean;
|
||||
/** Recipe belonging to the retained previous instance, when available. */
|
||||
readonly previous?: OriginView;
|
||||
/** Recipes belonging to all retained instances preceding this node. */
|
||||
readonly history: readonly OriginView[];
|
||||
}
|
||||
|
||||
/** Options controlling how a manually managed operation is resolved. */
|
||||
export interface OriginFinishOptions {
|
||||
/**
|
||||
* Override the choreography's progress/velocity decision. Omit it to use the
|
||||
* choreography's commit thresholds.
|
||||
*/
|
||||
commit?: boolean;
|
||||
/**
|
||||
* Whether to run the settling spring before finalizing the graph.
|
||||
*
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
/** Imperative controller for one mounted, live operation edge. */
|
||||
export interface OriginOperationHandle {
|
||||
/** Identity of the live operation controlled by this handle. */
|
||||
readonly id: number;
|
||||
/** Key of the source node that created the operation. */
|
||||
readonly originKey: string;
|
||||
/** Key of the newly mounted or retained target node. */
|
||||
readonly targetKey: string;
|
||||
/**
|
||||
* The operation's selected outcome. It becomes `commit` or `cancel`
|
||||
* synchronously when `finish()` or `cancel()` is called, before settling.
|
||||
*/
|
||||
readonly intent: OriginOperationIntent;
|
||||
/**
|
||||
* Set interactive progress and optional velocity.
|
||||
*
|
||||
* Progress is clamped to `0..1`; any excess is reported to choreographies as
|
||||
* `overshoot`. Velocity is normalized to progress units per second and is
|
||||
* used by the choreography's flick threshold.
|
||||
*/
|
||||
update(progress: number, velocity?: number): void;
|
||||
/**
|
||||
* Select commit/cancel, run the settling spring, and finalize the scene.
|
||||
*
|
||||
* @returns `true` when the target was retained, otherwise `false`.
|
||||
*/
|
||||
finish(options?: OriginFinishOptions): Promise<boolean>;
|
||||
/** Cancel the operation and remove its target branch. */
|
||||
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
|
||||
}
|
||||
|
||||
/** Options used to create an independent origin scene. */
|
||||
export interface CreateOriginSceneOptions {
|
||||
/**
|
||||
* One initial root recipe, or several independent roots rendered in the same
|
||||
* flat scene. Most applications begin with one root.
|
||||
*/
|
||||
initial: OriginView | readonly OriginView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A self-contained scene graph, view-recipe history, and animation compositor.
|
||||
*
|
||||
* Multiple scenes may coexist and do not share nodes, history, or operations.
|
||||
*/
|
||||
export interface OriginScene {
|
||||
/** Reactive snapshot of every currently mounted node. */
|
||||
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
|
||||
/** Reactive snapshot of every live animation/gesture operation. */
|
||||
readonly operations: ComputedRef<readonly OriginOperation[]>;
|
||||
/** Keys of visible operation-graph roots; parked history is excluded. */
|
||||
readonly roots: Readonly<ShallowRef<readonly string[]>>;
|
||||
/** Return the node-local action context for a mounted node key. */
|
||||
contextFor(nodeKey: string): OriginContext;
|
||||
/**
|
||||
* Create a forward target or reveal a retained back target, then return
|
||||
* manual control of its operation.
|
||||
*
|
||||
* @throws If the origin is missing, parked/exposed and inert, or already owns
|
||||
* an outgoing operation.
|
||||
*/
|
||||
begin(
|
||||
originKey: string,
|
||||
action: OriginAction,
|
||||
): Promise<OriginOperationHandle>;
|
||||
/**
|
||||
* Resolve a target and commit it programmatically using the settling spring.
|
||||
*
|
||||
* @returns `true` once the target has been committed.
|
||||
*/
|
||||
perform(originKey: string, action: OriginAction): Promise<boolean>;
|
||||
/** @internal Register or unregister a scene node's host element. */
|
||||
registerElement(nodeKey: string, element: HTMLElement | null): void;
|
||||
/** @internal Register or unregister the scene container used for measurement. */
|
||||
registerContainer(element: HTMLElement | null): void;
|
||||
/** @internal Calculate the fully composited inline style for one node host. */
|
||||
styleForNode(nodeKey: string): CSSProperties;
|
||||
/** @internal Determine whether a node should currently receive pointer input. */
|
||||
isNodeInteractive(nodeKey: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable operation storage used by the scene implementation.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface MutableOriginOperation {
|
||||
/** Unique operation identity. */
|
||||
id: number;
|
||||
/** Origin node key. */
|
||||
originKey: string;
|
||||
/** Newly created or retained target node key. */
|
||||
targetKey: string;
|
||||
/** Original history entry targeted before visual-edge rewrites. */
|
||||
entryTargetKey: string;
|
||||
/** Visual choreography for this edge. */
|
||||
choreography: OriginChoreography;
|
||||
/** Target stacking relationship. */
|
||||
placement: OriginPlacement;
|
||||
/** Retained-history behavior selected for this operation. */
|
||||
history: OriginHistoryMode;
|
||||
/** Normalized progress. */
|
||||
progress: number;
|
||||
/** Drag distance past either end of `progress`. */
|
||||
overshoot: number;
|
||||
/** Normalized velocity. */
|
||||
velocity: number;
|
||||
/** Current lifecycle phase. */
|
||||
phase: OriginOperationPhase;
|
||||
/** Selected outcome. */
|
||||
intent: OriginOperationIntent;
|
||||
/** Measured origin bounds, when an element was available. */
|
||||
originRect?: OriginRect;
|
||||
/** Measured target bounds, after the target mounted. */
|
||||
targetRect?: OriginRect;
|
||||
}
|
||||
|
||||
/** Public props accepted by the `OriginScene` renderer component. */
|
||||
export interface OriginSceneProps {
|
||||
/** Scene instance whose flat component nodes should be rendered. */
|
||||
scene: OriginScene;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** A rectangle measured in viewport CSS pixels. */
|
||||
export interface OriginRect {
|
||||
/** Distance from the viewport's top edge in CSS pixels. */
|
||||
top: number;
|
||||
/** Distance from the viewport's left edge in CSS pixels. */
|
||||
left: number;
|
||||
/** Rectangle width in CSS pixels. */
|
||||
width: number;
|
||||
/** Rectangle height in CSS pixels. */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Clamp a value to the inclusive unit range `0..1`. */
|
||||
export function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
/** Clamp a value to the inclusive range `min..max`. */
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** Measure an element's viewport bounds as a plain, serializable rectangle. */
|
||||
export function measureRect(element: Element): OriginRect {
|
||||
const { top, left, width, height } = element.getBoundingClientRect();
|
||||
return { top, left, width, height };
|
||||
}
|
||||
|
||||
/** Bounds of the browser window, or a unit rectangle outside a browser. */
|
||||
export function windowRect(): OriginRect {
|
||||
const browser = typeof window !== "undefined";
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: browser ? window.innerWidth : 1,
|
||||
height: browser ? window.innerHeight : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether animation should resolve immediately.
|
||||
*
|
||||
* True outside a browser, when `requestAnimationFrame` is unavailable, or when
|
||||
* the user prefers reduced motion.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window === "undefined" ||
|
||||
!window.requestAnimationFrame ||
|
||||
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
|
||||
);
|
||||
}
|
||||
|
||||
/** Report a recoverable problem to developers without interrupting the UI. */
|
||||
export function warn(message: string, error?: unknown): void {
|
||||
if (typeof console !== "undefined")
|
||||
console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : []));
|
||||
}
|
||||
|
||||
// Bundlers replace `process.env.NODE_ENV` in dependencies at build time, the
|
||||
// same convention Vue and Vue Router use for their development-only checks.
|
||||
declare const process: { env: { NODE_ENV?: string } };
|
||||
|
||||
/** Whether development-only checks and warnings are enabled. */
|
||||
export const DEV: boolean = process.env.NODE_ENV !== "production";
|
||||
@@ -0,0 +1,54 @@
|
||||
.nvo-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
contain: layout paint;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.nvo-node {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.nvo-node > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nvo-gesture {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Browsers only combine touch-action up to the nearest scroll container, so a
|
||||
* list scrolling inside a horizontal gesture host would otherwise let the
|
||||
* browser take horizontal drags (and cancel the gesture). Each host publishes
|
||||
* its touch-action as an inherited custom property; descendants adopt it.
|
||||
* Zero specificity, so applications can still override any element.
|
||||
*/
|
||||
.nvo-gesture :where(*) {
|
||||
touch-action: var(--nvo-touch-action);
|
||||
}
|
||||
|
||||
/* Opted-out subtrees, such as horizontal chip rows, keep their own panning. */
|
||||
.nvo-gesture :where([data-origin-gesture="ignore"]),
|
||||
.nvo-gesture :where([data-origin-gesture="ignore"] *) {
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nvo-node {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
originView,
|
||||
replace,
|
||||
useOriginGesture,
|
||||
type OriginGestureBinding,
|
||||
type OriginGestureCompletionContext,
|
||||
} from "../src";
|
||||
|
||||
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(replace(target)).toEqual({
|
||||
target,
|
||||
placement: "above",
|
||||
history: "replace",
|
||||
});
|
||||
expect(forward(target, testMotion)).toMatchObject({
|
||||
target,
|
||||
choreography: testMotion,
|
||||
history: "push",
|
||||
});
|
||||
expect(back(testMotion)).toMatchObject({
|
||||
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", () => {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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 "../src";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".onCommit()", () => {
|
||||
it("vibrates once when a release commits, and not when it cancels", async () => {
|
||||
const vibrate = vi.fn((_ms: number) => true);
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => vibrate(10))
|
||||
.animate(motion),
|
||||
);
|
||||
|
||||
await swipe(host, 20, []); // 10% progress: cancelled
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
await swipe(host, 150, []); // 75% progress: committed
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
expect(vibrate).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it("is available at every stage after the direction, in order", async () => {
|
||||
const calls: string[] = [];
|
||||
const definition = gesture.to
|
||||
.left()
|
||||
.onCommit(() => calls.push("directed"))
|
||||
.complete(({ progress }) => progress > 0.5)
|
||||
.onCommit(() => calls.push("completed"))
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit((context) => calls.push(`navigation ${context.direction}`))
|
||||
.animate(motion);
|
||||
expect(definition.commitHandlers).toHaveLength(3);
|
||||
expect(Object.isFrozen(definition.commitHandlers)).toBe(true);
|
||||
|
||||
const { host } = await mount(definition);
|
||||
await swipe(host, 150, []);
|
||||
expect(calls).toEqual(["directed", "completed", "navigation left"]);
|
||||
});
|
||||
|
||||
it("keeps earlier builder stages unchanged", () => {
|
||||
const base = gesture.to.left().navigate(() => null);
|
||||
const withHandler = base.onCommit(() => {});
|
||||
expect(base.animate(motion).commitHandlers).toEqual([]);
|
||||
expect(withHandler.animate(motion).commitHandlers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a failing handler without stopping later handlers or the commit", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const onError = (error: unknown) => errors.push(error);
|
||||
process.on("uncaughtException", onError);
|
||||
try {
|
||||
const after = vi.fn();
|
||||
const { scene, host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => {
|
||||
throw new Error("no vibration motor");
|
||||
})
|
||||
.onCommit(after)
|
||||
.animate(motion),
|
||||
);
|
||||
await swipe(host, 150, []);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||
"parked",
|
||||
"active",
|
||||
]);
|
||||
expect(errors).toEqual([new Error("no vibration motor")]);
|
||||
} finally {
|
||||
process.off("uncaughtException", onError);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs synchronously inside the pointer-up event", async () => {
|
||||
let calledDuringDispatch = false;
|
||||
let dispatching = false;
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => {
|
||||
calledDuringDispatch = dispatching;
|
||||
})
|
||||
.animate(motion),
|
||||
);
|
||||
host.dispatchEvent(pointer("pointerdown", 190, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 180, 500));
|
||||
await flush();
|
||||
host.dispatchEvent(pointer("pointermove", 40, 1000));
|
||||
await flush();
|
||||
|
||||
// No awaiting between the event and the handler: iOS only allows
|
||||
// haptics, audio, and clipboard writes during the user's gesture.
|
||||
dispatching = true;
|
||||
host.dispatchEvent(pointer("pointerup", 40, 1000));
|
||||
dispatching = false;
|
||||
expect(calledDuringDispatch).toBe(true);
|
||||
});
|
||||
|
||||
it("fires at release, before the settling animation finishes", async () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||
frames.push(cb),
|
||||
);
|
||||
const log: string[] = [];
|
||||
const { scene, host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => log.push("commit"))
|
||||
.animate(motion),
|
||||
);
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
||||
|
||||
await swipe(host, 150, log);
|
||||
expect(log).toEqual(["recognized", "commit", "released"]);
|
||||
// The spring is still waiting for frames; the operation is settling.
|
||||
expect(scene.operations.value[0]?.phase).toBe("settling");
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
shallowRef,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
allOf,
|
||||
anyOf,
|
||||
atScrollEdge,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
nearEdge,
|
||||
OriginGesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
originView,
|
||||
startedIn,
|
||||
type OriginChoreographyContext,
|
||||
type OriginGestureDefinition,
|
||||
type OriginGestureStartContext,
|
||||
} from "../src";
|
||||
import { DirectionalTracker } from "../src/gesture/recognizer";
|
||||
import { stepSpring } from "../src/scene/scene";
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const contexts: OriginChoreographyContext[] = [];
|
||||
const motion = defineOriginChoreography({
|
||||
name: "v2-test",
|
||||
effects: (context) => {
|
||||
contexts.push(context);
|
||||
return { target: { opacity: context.progress } };
|
||||
},
|
||||
});
|
||||
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||
const toTarget = () => forward(originView(Target));
|
||||
|
||||
function pointer(
|
||||
type: string,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
timeStamp: number,
|
||||
) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
function touchmove() {
|
||||
return new TouchEvent("touchmove", { bubbles: true, cancelable: true });
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
async function mount(root: Component) {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||
const scene = createOriginScene({ initial: originView(root) });
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(element);
|
||||
await nextTick();
|
||||
const host = element.querySelector("#host") as HTMLElement;
|
||||
Object.defineProperties(host, {
|
||||
clientWidth: { configurable: true, value: 200 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
});
|
||||
host.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||
return { scene, host };
|
||||
}
|
||||
|
||||
const hostFor = (
|
||||
definition: OriginGestureDefinition,
|
||||
children?: () => unknown,
|
||||
) =>
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGesture, { id: "host", gesture: definition }, () =>
|
||||
children ? children() : "host",
|
||||
),
|
||||
});
|
||||
|
||||
describe("recognition and native scrolling", () => {
|
||||
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
||||
|
||||
it("leaves scrolling to the browser through touch-action alone", async () => {
|
||||
const { scene, host } = await mount(hostFor(leftward));
|
||||
// A horizontal gesture keeps native vertical panning.
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
|
||||
await flush();
|
||||
const move = touchmove();
|
||||
host.dispatchEvent(move);
|
||||
// Ordinary gestures never block touchmove, so the browser never waits.
|
||||
expect(move.defaultPrevented).toBe(false);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes a swipe that starts with a vertical wobble", async () => {
|
||||
const { scene, host } = await mount(hostFor(leftward));
|
||||
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
|
||||
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
|
||||
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
|
||||
const { scene, host } = await mount(hostFor(leftward));
|
||||
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
|
||||
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
|
||||
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("claims touch-action per axis like native scrolling expects", async () => {
|
||||
const down = gesture.to.down().navigate(toTarget).animate(motion);
|
||||
const scroller = shallowRef<HTMLElement | null>(null);
|
||||
const dismiss = gesture.from
|
||||
.scrollEdge(scroller, "top")
|
||||
.to.down()
|
||||
.navigate(toTarget)
|
||||
.animate(motion);
|
||||
const surface = (gestures: OriginGestureDefinition[]) =>
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
|
||||
});
|
||||
expect(
|
||||
(await mount(surface([leftward, down]))).host.style.touchAction,
|
||||
).toBe("none");
|
||||
document.body.innerHTML = "";
|
||||
// A scroll-edge gesture at its edge reserves only its own pan: with the
|
||||
// horizontal gesture claiming pan-x, only scrolling down remains.
|
||||
expect(
|
||||
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
|
||||
).toBe("pan-down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll edges", () => {
|
||||
const scroller = shallowRef<HTMLElement | null>(null);
|
||||
const dismiss = gesture.from
|
||||
.scrollEdge(scroller, "top")
|
||||
.to.down()
|
||||
.navigate(toTarget)
|
||||
.animate(motion);
|
||||
|
||||
async function mountWithScroller(scrollTop: number) {
|
||||
const Root = hostFor(dismiss, () =>
|
||||
h("div", {
|
||||
id: "list",
|
||||
ref: (element) => (scroller.value = element as HTMLElement | null),
|
||||
}),
|
||||
);
|
||||
const mounted = await mount(Root);
|
||||
let top = scrollTop;
|
||||
Object.defineProperty(scroller.value!, "scrollTop", {
|
||||
get: () => top,
|
||||
configurable: true,
|
||||
});
|
||||
const scrollTo = async (value: number) => {
|
||||
top = value;
|
||||
scroller.value!.dispatchEvent(new Event("scroll"));
|
||||
await nextTick();
|
||||
};
|
||||
await scrollTo(scrollTop);
|
||||
return { ...mounted, scrollTo };
|
||||
}
|
||||
|
||||
describe("with directional touch-action", () => {
|
||||
it("reserves only the dismiss pan while the list is at the top", async () => {
|
||||
const { host, scrollTo } = await mountWithScroller(0);
|
||||
// Dragging up still scrolls natively; dragging down is the gesture's.
|
||||
expect(host.style.touchAction).toBe("pan-x pan-down");
|
||||
await scrollTo(120);
|
||||
expect(host.style.touchAction).toBe("manipulation");
|
||||
await scrollTo(0);
|
||||
expect(host.style.touchAction).toBe("pan-x pan-down");
|
||||
});
|
||||
|
||||
it("never blocks touchmove", async () => {
|
||||
const { host } = await mountWithScroller(0);
|
||||
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
|
||||
const move = touchmove();
|
||||
host.dispatchEvent(move);
|
||||
expect(move.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a downward drag only when the scroller is at the top", async () => {
|
||||
const atTop = await mountWithScroller(0);
|
||||
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
||||
await flush();
|
||||
expect(atTop.scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves a scrolled list to scroll", async () => {
|
||||
const scrolled = await mountWithScroller(120);
|
||||
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
||||
await flush();
|
||||
expect(scrolled.scene.operations.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without directional touch-action (Safari)", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("CSS", { supports: () => false });
|
||||
});
|
||||
|
||||
it("keeps the axis pannable and claims touches from JavaScript", async () => {
|
||||
const atTop = await mountWithScroller(0);
|
||||
expect(atTop.host.style.touchAction).toBe("manipulation");
|
||||
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||
// The browser's first cancelable touchmove arrives after its slop.
|
||||
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
|
||||
const first = touchmove();
|
||||
atTop.host.dispatchEvent(first);
|
||||
expect(first.defaultPrevented).toBe(true);
|
||||
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
|
||||
await flush();
|
||||
expect(atTop.scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lets an upward drag at the top scroll the list", async () => {
|
||||
const atTop = await mountWithScroller(0);
|
||||
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0));
|
||||
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16));
|
||||
const move = touchmove();
|
||||
atTop.host.dispatchEvent(move);
|
||||
expect(move.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("start-rule helpers", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
|
||||
const context = (target: Element, localX = 100): OriginGestureStartContext =>
|
||||
({
|
||||
event: { target } as unknown as PointerEvent,
|
||||
origin: {} as never,
|
||||
host,
|
||||
bounds: { top: 0, left: 0, width: 200, height: 400 },
|
||||
point: { clientX: localX, clientY: 10, localX, localY: 10 },
|
||||
}) as OriginGestureStartContext;
|
||||
|
||||
it("matches pointer-down inside a selector", () => {
|
||||
expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("composes with anyOf and allOf", () => {
|
||||
const body = host.querySelector("#body")!;
|
||||
const nearLeft = nearEdge("left", 40);
|
||||
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true);
|
||||
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true);
|
||||
expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it("checks scroll edges, treating a missing scroller as at the edge", () => {
|
||||
const list = document.createElement("div");
|
||||
Object.defineProperties(list, {
|
||||
scrollTop: { value: 0 },
|
||||
clientHeight: { value: 100 },
|
||||
scrollHeight: { value: 300 },
|
||||
});
|
||||
const body = host.querySelector("#body")!;
|
||||
expect(atScrollEdge(list, "top")(context(body))).toBe(true);
|
||||
expect(atScrollEdge(list, "bottom")(context(body))).toBe(false);
|
||||
expect(atScrollEdge(null, "top")(context(body))).toBe(true);
|
||||
expect(atScrollEdge(".missing", "top")(context(body))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress", () => {
|
||||
it("measures against a custom distance and reports overshoot", async () => {
|
||||
contexts.length = 0;
|
||||
const drawer = gesture.to
|
||||
.right({ distance: 100 })
|
||||
.navigate(toTarget)
|
||||
.animate(motion);
|
||||
const { scene, host } = await mount(hostFor(drawer));
|
||||
host.dispatchEvent(pointer("pointerdown", 0, 100, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 50, 100, 16));
|
||||
await flush();
|
||||
host.dispatchEvent(pointer("pointermove", 130, 100, 32));
|
||||
await flush();
|
||||
// Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot.
|
||||
expect(scene.operations.value[0]).toMatchObject({ progress: 1 });
|
||||
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3);
|
||||
scene.styleForNode(scene.operations.value[0]!.targetKey);
|
||||
expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3);
|
||||
|
||||
host.dispatchEvent(pointer("pointermove", -20, 100, 48));
|
||||
await flush();
|
||||
expect(scene.operations.value[0]).toMatchObject({ progress: 0 });
|
||||
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2);
|
||||
});
|
||||
|
||||
it("averages velocity over recent samples instead of the last step", () => {
|
||||
const tracker = new DirectionalTracker(
|
||||
"right",
|
||||
{ clientX: 0, clientY: 0, timeStamp: 0 },
|
||||
() => 100,
|
||||
);
|
||||
// Steady 1px/ms with one jittery, late sample.
|
||||
for (const [x, t] of [
|
||||
[16, 16],
|
||||
[32, 32],
|
||||
[48, 48],
|
||||
[52, 50],
|
||||
[80, 80],
|
||||
] as const)
|
||||
tracker.sample({ clientX: x, clientY: 0, timeStamp: t });
|
||||
expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px.
|
||||
|
||||
// Held still for longer than the window: the flick is over.
|
||||
tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 });
|
||||
expect(tracker.velocity).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("springs", () => {
|
||||
it("uses the choreography's stiffness and damping", () => {
|
||||
const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
|
||||
stiffness: 80,
|
||||
});
|
||||
const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
|
||||
stiffness: 600,
|
||||
});
|
||||
expect(stiff.position).toBeGreaterThan(soft.position);
|
||||
const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16);
|
||||
expect(unchanged.position).toBeGreaterThan(soft.position);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".onArm() and .onDisarm()", () => {
|
||||
it("fire when the drag crosses the commit threshold", async () => {
|
||||
const events: string[] = [];
|
||||
const definition = gesture.to
|
||||
.left()
|
||||
.navigate(toTarget)
|
||||
.onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`))
|
||||
.onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`))
|
||||
.animate(motion);
|
||||
const { host } = await mount(hostFor(definition));
|
||||
host.dispatchEvent(pointer("pointerdown", 190, 100, 0));
|
||||
for (const [x, t] of [
|
||||
[180, 16],
|
||||
[160, 32],
|
||||
[130, 48],
|
||||
[100, 64],
|
||||
[150, 80],
|
||||
[90, 96],
|
||||
] as const) {
|
||||
host.dispatchEvent(pointer("pointermove", x, 100, t));
|
||||
await flush();
|
||||
}
|
||||
// Default arming is distance-based: 25% of the 200px host is 50px.
|
||||
expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Property tests: random operation sequences and pointer streams must keep
|
||||
* the scene's structural invariants after every step, and must never leave
|
||||
* an operation stuck once all pointers are released.
|
||||
*/
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
fade,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
OriginSceneError,
|
||||
originView,
|
||||
replace,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
under,
|
||||
type OriginAction,
|
||||
type OriginOperationHandle,
|
||||
} from "../src";
|
||||
import type { SceneGraph } from "../src/scene/graph";
|
||||
import type { OriginScene as Scene } from "../src/scene/types";
|
||||
|
||||
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
|
||||
|
||||
// Support -------------------------------------------------------------------
|
||||
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
return {
|
||||
next,
|
||||
int: (n: number) => Math.floor(next() * n),
|
||||
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
||||
chance: (p: number) => next() < p,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic `requestAnimationFrame` and clock. */
|
||||
function installFrames() {
|
||||
let now = 1000;
|
||||
let queue: FrameRequestCallback[] = [];
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
|
||||
queue.push(cb)) as typeof window.requestAnimationFrame;
|
||||
performance.now = () => now;
|
||||
return {
|
||||
advance(ms = 16) {
|
||||
now += ms;
|
||||
const run = queue;
|
||||
queue = [];
|
||||
for (const cb of run) cb(now);
|
||||
},
|
||||
drain() {
|
||||
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(rounds = 4) {
|
||||
for (let i = 0; i < rounds; i += 1)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
const component = (name: string): Component =>
|
||||
defineComponent({ name, render: () => h("div", name) });
|
||||
|
||||
/** One-line summary of a scene, for failure traces. */
|
||||
const describeScene = (scene: Scene) => {
|
||||
const nodes = scene.nodes.value
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
|
||||
)
|
||||
.join(" ");
|
||||
const operations = scene.operations.value
|
||||
.map(
|
||||
(o) =>
|
||||
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
|
||||
)
|
||||
.join(", ");
|
||||
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
|
||||
};
|
||||
|
||||
const graphOf = (scene: Scene) =>
|
||||
(scene as unknown as { graph: SceneGraph }).graph;
|
||||
|
||||
/** Structural invariants that must hold after every step. */
|
||||
function checkInvariants(scene: Scene, label: string, rootCount: number) {
|
||||
const graph = graphOf(scene);
|
||||
const nodes = [...graph.nodes.values()];
|
||||
const roots = scene.roots.value;
|
||||
const fail = (message: string) => {
|
||||
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
|
||||
};
|
||||
|
||||
if (roots.length !== rootCount)
|
||||
fail(`expected ${rootCount} roots, found ${roots.length}`);
|
||||
if (new Set(roots).size !== roots.length) fail("duplicate roots");
|
||||
for (const key of roots) {
|
||||
const node = graph.node(key);
|
||||
if (!node) fail(`root ${key} does not exist`);
|
||||
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
// Incoming edges point back at their node.
|
||||
if (node.incomingOperationId !== undefined) {
|
||||
const incoming = graph.operation(node.incomingOperationId);
|
||||
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
|
||||
else if (incoming.targetKey !== node.key)
|
||||
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
|
||||
}
|
||||
|
||||
// History links exist and are acyclic.
|
||||
const seen = new Set([node.key]);
|
||||
for (let key = node.previousNodeKey; key;) {
|
||||
const previous = graph.node(key);
|
||||
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
|
||||
if (seen.has(key)) fail(`${node.key} has cyclic history`);
|
||||
seen.add(key);
|
||||
key = previous?.previousNodeKey;
|
||||
}
|
||||
|
||||
if (node.state === "parked") {
|
||||
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
|
||||
if (node.incomingOperationId !== undefined)
|
||||
fail(`parked ${node.key} is positioned by an edge`);
|
||||
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
|
||||
if (scene.styleForNode(node.key).visibility !== "hidden")
|
||||
fail(`parked ${node.key} is visible`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Every presented node reaches a root through acyclic incoming edges.
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
for (;;) {
|
||||
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
|
||||
visited.add(current.key);
|
||||
const incoming = graph.incomingOf(current);
|
||||
if (!incoming) break;
|
||||
const origin = graph.node(incoming.originKey);
|
||||
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
|
||||
current = origin!;
|
||||
}
|
||||
if (!roots.includes(current.key))
|
||||
fail(`${node.key} is detached from every root`);
|
||||
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
|
||||
fail(`exposed ${node.key} is live`);
|
||||
}
|
||||
|
||||
// Every live operation joins existing nodes; one per origin.
|
||||
const origins = new Set<string>();
|
||||
for (const operation of scene.operations.value) {
|
||||
if (!graph.node(operation.originKey))
|
||||
fail(`operation ${operation.id} has a missing origin`);
|
||||
if (!graph.node(operation.targetKey))
|
||||
fail(`operation ${operation.id} has a missing target`);
|
||||
if (origins.has(operation.originKey))
|
||||
fail(`${operation.originKey} has two live operations`);
|
||||
origins.add(operation.originKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Additional invariants once every operation has finished. */
|
||||
function checkResting(scene: Scene, rootCount: number, label: string) {
|
||||
checkInvariants(scene, label, rootCount);
|
||||
const states = scene.nodes.value.map((node) => node.state);
|
||||
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
|
||||
expect(states, `${label}: states`).not.toContain("transitioning");
|
||||
expect(
|
||||
states.filter((state) => state === "active"),
|
||||
`${label}: one active entry per root`,
|
||||
).toHaveLength(rootCount);
|
||||
}
|
||||
|
||||
// Scene operations ----------------------------------------------------------
|
||||
|
||||
const drawerOpen = defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66}%)` },
|
||||
}),
|
||||
});
|
||||
const drawerClose = defineOriginChoreography({
|
||||
name: "drawer-close",
|
||||
commitThreshold: 0.2,
|
||||
commitVelocity: 0.5,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66}%)` },
|
||||
}),
|
||||
});
|
||||
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
|
||||
const views = ["A", "B", "C", "D"].map((name) =>
|
||||
originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||
);
|
||||
|
||||
function randomAction(r: ReturnType<typeof rng>): OriginAction {
|
||||
const choreography = r.pick(choreographies);
|
||||
const target = r.pick(views);
|
||||
const placement = r.pick(["above", "under", undefined] as const);
|
||||
switch (r.int(6)) {
|
||||
case 0:
|
||||
return forward(target, choreography, { placement });
|
||||
case 1:
|
||||
return replace(target, choreography, { placement });
|
||||
case 2:
|
||||
case 3:
|
||||
return back(choreography, { placement });
|
||||
case 4:
|
||||
return above(target, choreography, {
|
||||
history: r.pick(["push", "replace", "back"] as const),
|
||||
});
|
||||
default:
|
||||
return under(target, choreography);
|
||||
}
|
||||
}
|
||||
|
||||
async function runOperations(seed: number) {
|
||||
const r = rng(seed);
|
||||
const frames = installFrames();
|
||||
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
|
||||
const scene = createOriginScene({ initial });
|
||||
const handles: OriginOperationHandle[] = [];
|
||||
const unexpected: unknown[] = [];
|
||||
const expectSceneError = (error: unknown) => {
|
||||
if (!(error instanceof OriginSceneError)) unexpected.push(error);
|
||||
};
|
||||
|
||||
const trace: string[] = [];
|
||||
for (let step = 0; step < 80; step += 1) {
|
||||
const label = () =>
|
||||
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
|
||||
const roll = r.next();
|
||||
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
|
||||
const live = handles.filter(({ id }) => liveIds.has(id));
|
||||
const pick = () =>
|
||||
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||
|
||||
if (roll < 0.3 || handles.length === 0) {
|
||||
const originKey = r.pick(scene.nodes.value).key;
|
||||
const action = randomAction(r);
|
||||
scene.begin(originKey, action).then(
|
||||
(handle) => {
|
||||
handles.push(handle);
|
||||
trace.push(
|
||||
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
trace.push(
|
||||
` begin ${originKey} ${action.history} rejected: ${error.code}`,
|
||||
);
|
||||
expectSceneError(error);
|
||||
},
|
||||
);
|
||||
} else if (roll < 0.55) {
|
||||
const handle = pick();
|
||||
const p = r.next() * 1.4 - 0.2;
|
||||
const v = r.next() * 3 - 1;
|
||||
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
|
||||
handle.update(p, v);
|
||||
} else if (roll < 0.75) {
|
||||
const handle = pick();
|
||||
const options = {
|
||||
commit: r.pick([true, false, undefined]),
|
||||
animate: r.chance(0.5),
|
||||
};
|
||||
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
|
||||
void handle
|
||||
.finish(options)
|
||||
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
|
||||
} else if (roll < 0.82) {
|
||||
const handle = pick();
|
||||
trace.push(`cancel #${handle.id}`);
|
||||
void handle
|
||||
.cancel({ animate: r.chance(0.5) })
|
||||
.then(() => trace.push(` cancelled #${handle.id}`));
|
||||
} else {
|
||||
trace.push("frames");
|
||||
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
|
||||
}
|
||||
if (!r.chance(0.15)) await flush();
|
||||
trace.push(describeScene(scene));
|
||||
checkInvariants(scene, label(), initial.length);
|
||||
}
|
||||
|
||||
// Release everything: every live operation finishes, then springs drain.
|
||||
await flush();
|
||||
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
|
||||
await flush();
|
||||
frames.drain();
|
||||
await flush();
|
||||
checkResting(
|
||||
scene,
|
||||
initial.length,
|
||||
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
|
||||
);
|
||||
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
|
||||
}
|
||||
|
||||
describe("scene properties", () => {
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||
it(`random operations ${seed}`, () => runOperations(seed));
|
||||
});
|
||||
|
||||
// Gestures ------------------------------------------------------------------
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const WIDTH = 300;
|
||||
const HEIGHT = 600;
|
||||
|
||||
function stubBounds(element: HTMLElement) {
|
||||
if (element.dataset.stubbed) return;
|
||||
element.dataset.stubbed = "1";
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: WIDTH },
|
||||
clientHeight: { configurable: true, value: HEIGHT },
|
||||
});
|
||||
element.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
|
||||
}
|
||||
|
||||
/** Views whose gestures can grow, collapse, and race chains of operations. */
|
||||
function gestureWorld() {
|
||||
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
|
||||
const slide = defineOriginChoreography({
|
||||
name: "slide",
|
||||
commitVelocity: 0.6,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
const surface = (name: string, gestures: () => unknown[]) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup: () => () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ gestures: gestures() as never, "data-gesture-host": "" },
|
||||
() => [h("input", { "data-input": "" }), name],
|
||||
),
|
||||
});
|
||||
|
||||
const A = surface("A", () => [
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(async () => {
|
||||
await Promise.resolve();
|
||||
return forward(viewsByName.B!);
|
||||
})
|
||||
.animate(slide),
|
||||
gesture.from
|
||||
.left(40)
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
gesture.from
|
||||
.when(({ point }) => point.localY < HEIGHT / 2)
|
||||
.to.down({ threshold: 12 })
|
||||
.complete(({ progress }) => progress > 0.2)
|
||||
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
|
||||
.animate(drawerOpen),
|
||||
gesture.from
|
||||
.bottom("20%")
|
||||
.to.up()
|
||||
.navigate((context) =>
|
||||
context.canGoBack ? back() : replace(viewsByName.A!),
|
||||
)
|
||||
.animate(fade),
|
||||
]);
|
||||
const B = surface("B", () => [
|
||||
gesture.to
|
||||
.left({ threshold: 6 })
|
||||
.navigate(() => forward(viewsByName.C!))
|
||||
.animate(slide),
|
||||
gesture.to
|
||||
.right({ threshold: 6 })
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
]);
|
||||
const C = surface("C", () => [
|
||||
gesture.to
|
||||
.up({ threshold: 4 })
|
||||
.navigate((context) =>
|
||||
context.canGoBack ? back() : forward(viewsByName.B!),
|
||||
)
|
||||
.animate(drawerClose),
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
throw new Error("intentional navigate failure");
|
||||
})
|
||||
.animate(slide),
|
||||
]);
|
||||
viewsByName.A = originView(A, undefined, { key: "a" });
|
||||
viewsByName.B = originView(B, undefined, { key: "b" });
|
||||
viewsByName.C = originView(C, undefined, { key: "c" });
|
||||
|
||||
const scene = createOriginScene({ initial: viewsByName.A });
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(root);
|
||||
return { scene, root };
|
||||
}
|
||||
|
||||
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
async function runPointers(seed: number) {
|
||||
const r = rng(seed * 7919);
|
||||
const frames = installFrames();
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { scene, root } = gestureWorld();
|
||||
await nextTick();
|
||||
let time = 100;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let pointerId = 1;
|
||||
let host: HTMLElement | null = null;
|
||||
let heading: "left" | "right" | "up" | "down" = "left";
|
||||
const trace: string[] = [];
|
||||
|
||||
const dispatch = (
|
||||
type: string,
|
||||
init: PointerEventInit,
|
||||
onInput = false,
|
||||
) => {
|
||||
trace.push(
|
||||
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
|
||||
);
|
||||
if (!host?.isConnected) return;
|
||||
const target = onInput
|
||||
? (host.querySelector("[data-input]") as HTMLElement)
|
||||
: host;
|
||||
target.dispatchEvent(pointerEvent(type, init, time));
|
||||
};
|
||||
|
||||
for (let step = 0; step < 120; step += 1) {
|
||||
time += r.int(40);
|
||||
const roll = r.next();
|
||||
if (roll < 0.15) {
|
||||
// A new primary pointer means the previous one ended: a browser
|
||||
// delivers its pointer-up to the captured host, or, if that was
|
||||
// lost, fires lostpointercapture there.
|
||||
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
});
|
||||
if (r.chance(0.8)) await flush(3);
|
||||
const nodes = scene.nodes.value.filter((node) =>
|
||||
scene.isNodeInteractive(node.key),
|
||||
);
|
||||
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
|
||||
host = root.querySelector(
|
||||
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
|
||||
);
|
||||
if (host) stubBounds(host);
|
||||
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
||||
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
||||
pointerId = r.chance(0.8) ? 1 : 2;
|
||||
heading = r.pick(["left", "right", "up", "down"] as const);
|
||||
dispatch(
|
||||
"pointerdown",
|
||||
{
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
button: r.chance(0.95) ? 0 : 1,
|
||||
isPrimary: r.chance(0.95),
|
||||
},
|
||||
r.chance(0.08),
|
||||
);
|
||||
} else if (roll < 0.65) {
|
||||
const amount = 5 + r.int(55);
|
||||
if (heading === "left") x -= amount;
|
||||
if (heading === "right") x += amount;
|
||||
if (heading === "up") y -= amount;
|
||||
if (heading === "down") y += amount;
|
||||
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.8) {
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.85) {
|
||||
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
|
||||
pointerId,
|
||||
});
|
||||
} else {
|
||||
trace.push("frames");
|
||||
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
|
||||
}
|
||||
if (r.chance(0.7)) {
|
||||
frames.advance(16);
|
||||
frames.advance(16);
|
||||
}
|
||||
if (!r.chance(0.2)) await flush(3);
|
||||
trace.push(describeScene(scene));
|
||||
checkInvariants(
|
||||
scene,
|
||||
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Lift the pointer; nothing may remain stuck mid-transition.
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
await flush();
|
||||
frames.drain();
|
||||
await flush();
|
||||
trace.push(describeScene(scene));
|
||||
checkResting(
|
||||
scene,
|
||||
1,
|
||||
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
|
||||
);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
}
|
||||
|
||||
describe("gesture properties", () => {
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||
it(`random pointer stream ${seed}`, () => runPointers(seed));
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
shallowRef,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
OriginSceneError,
|
||||
originView,
|
||||
type OriginGestureDefinition,
|
||||
} from "../src";
|
||||
import { shouldCommit } from "../src/scene/graph";
|
||||
|
||||
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: "recognizer-test",
|
||||
effects: ({ progress }) => ({ target: { opacity: progress } }),
|
||||
});
|
||||
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||
const toTarget = () => forward(originView(Target));
|
||||
|
||||
function pointer(
|
||||
type: string,
|
||||
clientX: number,
|
||||
timeStamp: number,
|
||||
pointerId = 1,
|
||||
) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
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));
|
||||
}
|
||||
|
||||
function stubBounds(host: HTMLElement) {
|
||||
Object.defineProperty(host, "clientWidth", { value: 200 });
|
||||
host.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||
}
|
||||
|
||||
/** Render `root` as the scene's initial view; return the scene and host. */
|
||||
async function mount(root: Component) {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||
const scene = createOriginScene({ initial: originView(root) });
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(element);
|
||||
await nextTick();
|
||||
const host = element.querySelector("#host") as HTMLElement;
|
||||
stubBounds(host);
|
||||
return { scene, host, app };
|
||||
}
|
||||
|
||||
const gestureHost = (definition: () => OriginGestureDefinition) =>
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGesture, { id: "host", gesture: definition() }, () => "host"),
|
||||
});
|
||||
|
||||
/** Press, then drag left far enough to capture and begin an operation. */
|
||||
async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) {
|
||||
host.dispatchEvent(pointer("pointerdown", x, 0, pointerId));
|
||||
host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId));
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("stuck gestures", () => {
|
||||
const definition = gesture.to.left().navigate(toTarget).animate(motion);
|
||||
|
||||
it("cancels a captured gesture when pointer capture is lost", async () => {
|
||||
const { scene, host } = await mount(gestureHost(() => definition));
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
host.dispatchEvent(pointer("lostpointercapture", 120, 120));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores a child losing implicit touch capture to the host", async () => {
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGesture, { id: "host", gesture: definition }, () =>
|
||||
h("span", { id: "child" }, "x"),
|
||||
),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
// Taking capture on the host makes the touched child lose its implicit
|
||||
// capture; that event bubbles to the host.
|
||||
host
|
||||
.querySelector("#child")!
|
||||
.dispatchEvent(pointer("lostpointercapture", 120, 110));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores lostpointercapture after a normal release", async () => {
|
||||
const { scene, host } = await mount(gestureHost(() => definition));
|
||||
await dragLeft(host);
|
||||
host.dispatchEvent(pointer("pointermove", 20, 200));
|
||||
host.dispatchEvent(pointer("pointerup", 20, 200));
|
||||
host.dispatchEvent(pointer("lostpointercapture", 20, 200));
|
||||
await flush();
|
||||
// Committed, not cancelled.
|
||||
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||
"parked",
|
||||
"active",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels a stale gesture when a new primary pointer goes down", async () => {
|
||||
const { scene, host } = await mount(gestureHost(() => definition));
|
||||
await dragLeft(host, 1);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
// pointer 1's pointer-up was lost; a new touch starts.
|
||||
host.dispatchEvent(pointer("pointerdown", 150, 300, 2));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels a captured gesture when its component unmounts", async () => {
|
||||
const show = shallowRef(true);
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h("div", [
|
||||
show.value
|
||||
? h(OriginGesture, { id: "host", gesture: definition }, () => "x")
|
||||
: null,
|
||||
]),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
show.value = false;
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure warnings", () => {
|
||||
it("warns when navigate() throws, without touching the scene", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const failing = gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
throw new Error("no route");
|
||||
})
|
||||
.animate(motion);
|
||||
const { scene, host } = await mount(gestureHost(() => failing));
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[native-vue-router] A gesture's navigate() factory failed.",
|
||||
new Error("no route"),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the scene rejects the operation", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// The initial view has no previous entry, so back() is rejected.
|
||||
const invalid = gesture.to
|
||||
.left()
|
||||
.navigate(() => back())
|
||||
.animate(motion);
|
||||
const { host } = await mount(gestureHost(() => invalid));
|
||||
await dragLeft(host);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const error = warn.mock.calls[0]![1] as OriginSceneError;
|
||||
expect(error).toBeInstanceOf(OriginSceneError);
|
||||
expect(error.code).toBe("no-previous-entry");
|
||||
});
|
||||
|
||||
it("stays quiet when two gestures race for the same origin", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const both = [
|
||||
gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion),
|
||||
gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion),
|
||||
];
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declining with null does not warn", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const declined = gesture.to
|
||||
.left()
|
||||
.navigate(() => null)
|
||||
.animate(motion);
|
||||
const { host } = await mount(gestureHost(() => declined));
|
||||
await dragLeft(host);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactive gesture props", () => {
|
||||
it("applies a new definition from the next pointer-down only", async () => {
|
||||
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
||||
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
|
||||
const current = shallowRef(leftward);
|
||||
const { scene, host } = await mount(gestureHost(() => current.value));
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
|
||||
// Swap mid-gesture: the drag in progress keeps the leftward definition.
|
||||
host.dispatchEvent(pointer("pointerdown", 180, 0));
|
||||
current.value = rightward;
|
||||
await nextTick();
|
||||
host.dispatchEvent(pointer("pointermove", 140, 100));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
// A short, held release (20%) cancels, leaving the origin interactive.
|
||||
host.dispatchEvent(pointer("pointerup", 140, 1000));
|
||||
await flush();
|
||||
|
||||
// The next touch uses the rightward definition.
|
||||
host.dispatchEvent(pointer("pointerdown", 20, 2000));
|
||||
host.dispatchEvent(pointer("pointermove", 80, 2100));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a gesture in progress across a parent re-render", async () => {
|
||||
const tick = shallowRef(0);
|
||||
// A new but equivalent definition object on every render.
|
||||
const { scene, host } = await mount(
|
||||
gestureHost(() => {
|
||||
void tick.value;
|
||||
return gesture.to.left().navigate(toTarget).animate(motion);
|
||||
}),
|
||||
);
|
||||
await dragLeft(host);
|
||||
tick.value += 1;
|
||||
await nextTick();
|
||||
host.dispatchEvent(pointer("pointermove", 20, 200));
|
||||
host.dispatchEvent(pointer("pointerup", 20, 200));
|
||||
await flush();
|
||||
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||
"parked",
|
||||
"active",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks a surface's changing gesture list and touch-action", async () => {
|
||||
const list = shallowRef<OriginGestureDefinition[]>([
|
||||
gesture.to.left().navigate(toTarget).animate(motion),
|
||||
]);
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ id: "host", gestures: list.value },
|
||||
() => "x",
|
||||
),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
|
||||
list.value = [
|
||||
...list.value,
|
||||
gesture.to.down().navigate(toTarget).animate(motion),
|
||||
];
|
||||
await nextTick();
|
||||
expect(host.style.touchAction).toBe("none");
|
||||
|
||||
host.dispatchEvent(
|
||||
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
|
||||
);
|
||||
const down = new PointerEvent("pointermove", {
|
||||
bubbles: true,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
clientX: 100,
|
||||
clientY: 80,
|
||||
});
|
||||
host.dispatchEvent(down);
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitVelocity", () => {
|
||||
const operation = (commitVelocity: number | undefined, velocity: number) =>
|
||||
({
|
||||
progress: 0.1,
|
||||
velocity,
|
||||
choreography: { effects: () => ({}), commitVelocity },
|
||||
}) as unknown as Parameters<typeof shouldCommit>[0];
|
||||
|
||||
it("defaults to 0.2", () => {
|
||||
expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
|
||||
expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the choreography's value when supplied", () => {
|
||||
expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false);
|
||||
expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true);
|
||||
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a fast flick's direction veto the distance", () => {
|
||||
const at = (progress: number, velocity: number) =>
|
||||
({
|
||||
progress,
|
||||
velocity,
|
||||
choreography: { effects: () => ({}) },
|
||||
}) as unknown as Parameters<typeof shouldCommit>[0];
|
||||
// Past the 0.36 threshold, but flicked back toward the start: cancels.
|
||||
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
|
||||
// Barely started, but flicked forward: commits.
|
||||
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
|
||||
// Slow releases fall back to distance, in either direction.
|
||||
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
|
||||
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
|
||||
});
|
||||
|
||||
it("still yields to an explicit decision", () => {
|
||||
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
OriginScene,
|
||||
originView,
|
||||
replace,
|
||||
under,
|
||||
} from "../src";
|
||||
|
||||
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)` },
|
||||
}),
|
||||
});
|
||||
|
||||
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" });
|
||||
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",
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
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("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" });
|
||||
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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.baseline/
|
||||
browser/app/dist/
|
||||
@@ -0,0 +1,41 @@
|
||||
# Benchmarks
|
||||
|
||||
Compares core-v2's performance against an earlier implementation (by default
|
||||
`main`).
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
|
||||
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
```
|
||||
|
||||
`scene.bench.ts` measures compositing, push/back cycles, and rendered frames.
|
||||
Run each implementation in its own process with `ONLY=`; running both in one
|
||||
process biases whichever runs second.
|
||||
|
||||
Behavioural verification lives with the unit tests: `tests/property.test.ts`
|
||||
drives random operation sequences and pointer streams and checks the scene's
|
||||
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
|
||||
|
||||
## Real-browser feel tests
|
||||
|
||||
`browser/` drives a development build in Chromium with emulated touch input,
|
||||
which happy-dom cannot model: implicit touch pointer capture, native
|
||||
scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
|
||||
|
||||
- **recognition** on human-like traces: wobbly and diagonal starts, a small
|
||||
wind-up the wrong way, a twitch at lift-off, slow and short drags, flicks,
|
||||
and vertical scrolls that must not trigger gestures;
|
||||
- **frame pacing** while a finger drags (p95 ≤ 20ms, ≤ 5% of frames over
|
||||
20ms) for a horizontal swipe and a scroll-edge dismiss;
|
||||
- **behaviour**: swipes over nested scrollers, native scrolling beside
|
||||
gestures, scroll-edge dismissal, `onCommit` inside `pointerup`, and the
|
||||
development continuity warning.
|
||||
|
||||
```sh
|
||||
npm run test:feel
|
||||
```
|
||||
|
||||
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.row {
|
||||
height: 80px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createApp, defineComponent, h, ref } from "vue";
|
||||
import "../../../src/style.css";
|
||||
import {
|
||||
OriginGesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
useOrigin,
|
||||
} from "../../../src";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
log: string[];
|
||||
inPointerup: boolean;
|
||||
scene: ReturnType<typeof createOriginScene>;
|
||||
scrollTopOf: (selector: string) => number;
|
||||
begin: (name: "matched" | "mismatched") => Promise<void>;
|
||||
}
|
||||
}
|
||||
window.log = [];
|
||||
window.inPointerup = false;
|
||||
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
|
||||
window.addEventListener("pointerup", () =>
|
||||
setTimeout(() => (window.inPointerup = false)),
|
||||
);
|
||||
const warn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
window.log.push("warn: " + String(args[0]));
|
||||
warn(...args);
|
||||
};
|
||||
|
||||
const rows = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
|
||||
|
||||
const Detail = defineComponent({
|
||||
name: "Detail",
|
||||
setup() {
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
const dismiss = gesture.from
|
||||
.scrollEdge(list, "top")
|
||||
.to.down()
|
||||
.navigate((c) => (c.canGoBack ? back() : null))
|
||||
.onCommit(() => window.log.push("dismiss committed"))
|
||||
.animate(
|
||||
defineOriginChoreography({
|
||||
name: "dismiss",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateY(${progress * 100}%)` },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return () =>
|
||||
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
|
||||
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const Home = defineComponent({
|
||||
name: "Home",
|
||||
setup() {
|
||||
const open = gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
|
||||
.onCommit(() =>
|
||||
window.log.push(`open committed inPointerup=${window.inPointerup}`),
|
||||
)
|
||||
.animate(slideLeft);
|
||||
return () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ gestures: [open], class: "page", id: "home" },
|
||||
() => h("div", { class: "list", id: "home-list" }, rows(40)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Continuity check pair: a drawer that rests at 80% and a close that starts
|
||||
// either from the same pose (matched) or from 0 (mismatched).
|
||||
const drawerOpen = defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 80}%)` },
|
||||
}),
|
||||
});
|
||||
const closes = {
|
||||
matched: defineOriginChoreography({
|
||||
name: "drawer-close-matched",
|
||||
effects: ({ progress }) => ({
|
||||
target: { transform: `translateX(${(1 - progress) * 80}%)` },
|
||||
}),
|
||||
}),
|
||||
mismatched: defineOriginChoreography({
|
||||
name: "drawer-close-mismatched",
|
||||
effects: ({ progress }) => ({
|
||||
target: { transform: `translateX(${(1 - progress) * 50}%)` },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const Drawer = defineComponent({
|
||||
name: "Drawer",
|
||||
render: () => h("div", { class: "page" }, "drawer"),
|
||||
});
|
||||
const Plain = defineComponent({
|
||||
name: "Plain",
|
||||
setup() {
|
||||
const origin = useOrigin();
|
||||
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
|
||||
() =>
|
||||
origin.perform(
|
||||
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
|
||||
);
|
||||
return () => h("div", { class: "page", id: "plain" }, "plain");
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const initial = params.get("view") === "plain" ? Plain : Home;
|
||||
const scene = createOriginScene({
|
||||
initial: originView(initial, undefined, { key: "root" }),
|
||||
});
|
||||
window.scene = scene;
|
||||
window.scrollTopOf = (selector) =>
|
||||
document.querySelector(selector)?.scrollTop ?? -1;
|
||||
window.begin = async (name) => {
|
||||
await (
|
||||
window as unknown as { openDrawer: () => Promise<boolean> }
|
||||
).openDrawer();
|
||||
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
|
||||
const handle = await scene.begin(drawerKey, back(closes[name]));
|
||||
await handle.cancel({ animate: false });
|
||||
await handle.finish({ commit: false, animate: false });
|
||||
};
|
||||
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");
|
||||
@@ -0,0 +1,8 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
build: { outDir: "dist", emptyOutDir: true, minify: false },
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dirname, "app/dist");
|
||||
const types = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
};
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = join(
|
||||
root,
|
||||
new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"),
|
||||
);
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(path);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
return res.end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": types[extname(path)] ?? "application/octet-stream",
|
||||
});
|
||||
res.end(body);
|
||||
}).listen(0);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
|
||||
// CHROMIUM_PATH overrides Playwright's bundled Chromium.
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROMIUM_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
hasTouch: true,
|
||||
isMobile: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
const results = [];
|
||||
const check = (name, ok, detail) => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
};
|
||||
|
||||
async function open(query = "") {
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (e) => console.log("pageerror", e.message));
|
||||
await page.goto(base + query);
|
||||
await page.waitForFunction(() => window.scene);
|
||||
return { page, cdp: await context.newCDPSession(page) };
|
||||
}
|
||||
async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: from[0], y: from[1] }],
|
||||
});
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = from[0] + ((to[0] - from[0]) * i) / steps;
|
||||
const y = from[1] + ((to[1] - from[1]) * i) / steps;
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x, y }],
|
||||
});
|
||||
await page.waitForTimeout(stepMs);
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
const states = (page) =>
|
||||
page.evaluate(() =>
|
||||
window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "),
|
||||
);
|
||||
const log = (page) => page.evaluate(() => window.log.slice());
|
||||
|
||||
// 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel).
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [340, 400], [60, 405]);
|
||||
const s = await states(t.page);
|
||||
check(
|
||||
"horizontal swipe over a scroller opens Detail",
|
||||
s === "root:parked detail:active",
|
||||
s,
|
||||
);
|
||||
const l = await log(t.page);
|
||||
check(
|
||||
"onCommit runs inside the real pointerup event",
|
||||
l.includes("open committed inPointerup=true"),
|
||||
l.join(" | "),
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 2. Vertical drag on the same host scrolls natively and leaves the gesture alone.
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [200, 700], [205, 300]);
|
||||
const s = await states(t.page);
|
||||
const top = await t.page.evaluate(() => window.scrollTopOf("#home-list"));
|
||||
check(
|
||||
"vertical drag scrolls the list natively",
|
||||
top > 100,
|
||||
`scrollTop=${top}`,
|
||||
);
|
||||
check(
|
||||
"vertical drag does not start the horizontal gesture",
|
||||
s === "root:active",
|
||||
s,
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 3. Scroll-edge dismiss.
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [340, 400], [60, 405]);
|
||||
check(
|
||||
"(setup) Detail open",
|
||||
(await states(t.page)) === "root:parked detail:active",
|
||||
);
|
||||
await drag(t, [200, 700], [200, 300]); // scroll the list down
|
||||
const scrolled = await t.page.evaluate(() =>
|
||||
window.scrollTopOf("#detail-list"),
|
||||
);
|
||||
check(
|
||||
"drag up scrolls the detail list",
|
||||
scrolled > 100,
|
||||
`scrollTop=${scrolled}`,
|
||||
);
|
||||
await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss
|
||||
const afterDown = await t.page.evaluate(() =>
|
||||
window.scrollTopOf("#detail-list"),
|
||||
);
|
||||
check(
|
||||
"drag down while scrolled scrolls the list, not dismiss",
|
||||
afterDown < scrolled &&
|
||||
(await states(t.page)) === "root:parked detail:active",
|
||||
`scrollTop ${scrolled} → ${afterDown}`,
|
||||
);
|
||||
await t.page.evaluate(
|
||||
() => (document.querySelector("#detail-list").scrollTop = 0),
|
||||
);
|
||||
await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses
|
||||
const s = await states(t.page);
|
||||
const l = await log(t.page);
|
||||
check(
|
||||
"drag down at the top dismisses",
|
||||
s === "root:active" && l.includes("dismiss committed"),
|
||||
s,
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 3b. At the top, an upward drag still scrolls the list.
|
||||
{
|
||||
const t = await open();
|
||||
await drag(t, [340, 400], [60, 405]);
|
||||
await drag(t, [200, 700], [200, 300]);
|
||||
const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list"));
|
||||
check(
|
||||
"at the top, drag up still scrolls (dismiss lets go)",
|
||||
top > 100 && (await states(t.page)) === "root:parked detail:active",
|
||||
`scrollTop=${top}`,
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 5. Recognition feel: human-like traces on the Home swipe (over a nested
|
||||
// scroller). Real fingers rarely start cleanly; every trace that should open
|
||||
// must open, and the rest must not.
|
||||
{
|
||||
const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]);
|
||||
const traces = [
|
||||
["clean swipe", steady(-200, 12), true],
|
||||
["short steady drag (31%)", steady(-120, 10), true],
|
||||
["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true],
|
||||
["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true],
|
||||
["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true],
|
||||
["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true],
|
||||
["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true],
|
||||
["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false],
|
||||
["quick flick (15%)", steady(-60, 4, 10), true],
|
||||
["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false],
|
||||
];
|
||||
for (const [name, steps, shouldOpen] of traces) {
|
||||
const t = await open();
|
||||
let [x, y] = [330, 400];
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||
for (const [dx, dy, ms] of steps) {
|
||||
x += dx; y += dy;
|
||||
if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
|
||||
await t.page.waitForTimeout(ms);
|
||||
}
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await t.page.waitForTimeout(800);
|
||||
const opened = (await states(t.page)) === "root:parked detail:active";
|
||||
check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed");
|
||||
await t.page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Frame pacing while a finger drags: no dropped frames from blocking
|
||||
// touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms.
|
||||
async function pacing(query, setup, from, to) {
|
||||
const gaps = [];
|
||||
for (let run = 0; run < 6; run++) {
|
||||
const t = await open(query);
|
||||
await setup(t);
|
||||
await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); });
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] });
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] });
|
||||
await t.page.waitForTimeout(16);
|
||||
}
|
||||
const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; });
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
gaps.push(...f.slice(1).map((v, i) => v - f[i]));
|
||||
await t.page.close();
|
||||
}
|
||||
gaps.sort((a, b) => a - b);
|
||||
return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length };
|
||||
}
|
||||
const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); };
|
||||
for (const [name, result] of [
|
||||
["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])],
|
||||
["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])],
|
||||
]) {
|
||||
check(
|
||||
`pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`,
|
||||
result.p95 <= 20 && result.over20 <= 0.05,
|
||||
`p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Continuity warning.
|
||||
for (const [name, expectWarning] of [
|
||||
["matched", false],
|
||||
["mismatched", true],
|
||||
]) {
|
||||
const t = await open("?view=plain");
|
||||
await t.page.evaluate((n) => window.begin(n), name);
|
||||
await t.page.waitForTimeout(100);
|
||||
const warned = (await log(t.page)).some((l) => l.includes("moved node"));
|
||||
check(
|
||||
`continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`,
|
||||
warned === expectWarning,
|
||||
(await log(t.page)).join(" | "),
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
console.log(`\n${results.length - failed}/${results.length} passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineComponent, h, type Component } from "vue";
|
||||
|
||||
export function component(name: string): Component {
|
||||
return defineComponent({ name, render: () => h("div", name) });
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { bench, describe } from "vitest";
|
||||
import * as next from "../src";
|
||||
import * as base from "./.baseline";
|
||||
import { component } from "./harness";
|
||||
|
||||
type Impl = typeof next;
|
||||
const all = { baseline: base as unknown as Impl, rewrite: next };
|
||||
const impls = process.env.ONLY
|
||||
? { [process.env.ONLY]: all[process.env.ONLY as keyof typeof all] }
|
||||
: all;
|
||||
|
||||
const motion = next.defineOriginChoreography({
|
||||
name: "bench",
|
||||
effects: ({ progress }) => ({
|
||||
frame: { transform: `translateY(${progress}px)` },
|
||||
source: {
|
||||
transform: `translateX(${-progress * 100}px)`,
|
||||
opacity: 1 - progress,
|
||||
},
|
||||
target: {
|
||||
transform: `translateX(${(1 - progress) * 100}px)`,
|
||||
style: { filter: "none" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
/** Long enough for both implementations to reach steady-state JIT. */
|
||||
const options = { time: 4000, warmupTime: 1500, warmupIterations: 2000 };
|
||||
|
||||
const views = Array.from({ length: 12 }, (_, i) =>
|
||||
next.originView(component(`V${i}`), undefined, { key: `v${i}` }),
|
||||
);
|
||||
|
||||
/** A scene with `depth` simultaneously live, chained operations. */
|
||||
async function liveChain(M: Impl, depth: number) {
|
||||
const scene = M.createOriginScene({ initial: views[0]! });
|
||||
const handles: next.OriginOperationHandle[] = [];
|
||||
let origin = scene.nodes.value[0]!.key;
|
||||
for (let i = 1; i <= depth; i += 1) {
|
||||
const handle = await scene.begin(origin, M.forward(views[i]!, motion));
|
||||
handle.update(0.5);
|
||||
handles.push(handle);
|
||||
origin = handle.targetKey;
|
||||
}
|
||||
return { scene, handles };
|
||||
}
|
||||
|
||||
for (const depth of [3, 10]) {
|
||||
describe(`styleForNode, every node, ${depth} live chained operations`, async () => {
|
||||
for (const [name, M] of Object.entries(impls)) {
|
||||
const { scene, handles } = await liveChain(M, depth);
|
||||
let p = 0;
|
||||
bench(
|
||||
name,
|
||||
() => {
|
||||
p = (p + 0.013) % 1;
|
||||
handles[0]!.update(p);
|
||||
for (const node of scene.nodes.value) scene.styleForNode(node.key);
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("push → back cycle (begin + finish, no animation)", () => {
|
||||
for (const [name, M] of Object.entries(impls)) {
|
||||
const scene = M.createOriginScene({ initial: views[0]! });
|
||||
const root = scene.nodes.value[0]!.key;
|
||||
bench(
|
||||
name,
|
||||
async () => {
|
||||
const push = await scene.begin(root, M.forward(views[1]!, motion));
|
||||
await push.finish({ commit: true, animate: false });
|
||||
const pop = await scene.begin(push.targetKey, M.back(motion));
|
||||
await pop.finish({ commit: true, animate: false });
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("rendered frame: update progress + Vue re-render, 6 live operations", async () => {
|
||||
for (const [name, M] of Object.entries(impls)) {
|
||||
const { scene, handles } = await liveChain(M, 6);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
createApp({ render: () => h(M.OriginScene, { scene }) }).mount(host);
|
||||
await nextTick();
|
||||
let p = 0;
|
||||
bench(
|
||||
name,
|
||||
async () => {
|
||||
p = (p + 0.013) % 1;
|
||||
for (const handle of handles) handle.update(p);
|
||||
await nextTick();
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
# Extract core-v2 sources from a git ref into ./.baseline for comparison.
|
||||
set -eu
|
||||
ref="${1:-main}"
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(git -C "$here" rev-parse --show-toplevel)"
|
||||
tmp="$(mktemp -d)"
|
||||
git -C "$root" archive "$ref" packages/core-v2/src | tar -x -C "$tmp"
|
||||
rm -rf "$here/.baseline"
|
||||
mv "$tmp/packages/core-v2/src" "$here/.baseline"
|
||||
rm -f "$here/.baseline"/*.test.ts
|
||||
rm -rf "$tmp"
|
||||
echo "Baseline extracted from $ref into $here/.baseline"
|
||||
@@ -0,0 +1,16 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/*
|
||||
* Benchmarks against a previous core-v2 implementation.
|
||||
* Run `setup-baseline.sh <git-ref>` first; see README.md.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [vue() as never],
|
||||
test: {
|
||||
root: __dirname,
|
||||
environment: "happy-dom",
|
||||
testTimeout: 120_000,
|
||||
benchmark: { include: ["*.bench.ts"] },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { resolve } from "node:path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "index",
|
||||
cssFileName: "style",
|
||||
},
|
||||
// V2 intentionally has no vue-router dependency. Vue is supplied by the
|
||||
// consuming application so every scene shares the application's renderer.
|
||||
rollupOptions: { external: ["vue"] },
|
||||
},
|
||||
});
|
||||
@@ -1,760 +0,0 @@
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onScopeDispose,
|
||||
provide,
|
||||
ref,
|
||||
shallowReactive,
|
||||
watch,
|
||||
type PropType,
|
||||
type InjectionKey,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
import {
|
||||
routeLocationKey,
|
||||
RouterView,
|
||||
type RouteLocationNormalizedLoaded,
|
||||
type RouteLocationRaw,
|
||||
} from "vue-router";
|
||||
import { nativeRouterKey } from "./runtime";
|
||||
import type {
|
||||
NativeDirection,
|
||||
NativeNavigationOptions,
|
||||
NativeRouterRuntime,
|
||||
NativeSourceRect,
|
||||
NativeViewEntry,
|
||||
NativeViewLifecycle,
|
||||
NativeViewRole,
|
||||
} from "./types";
|
||||
|
||||
const nativeViewLifecycleKey: InjectionKey<NativeViewLifecycle> = Symbol(
|
||||
"native-view-lifecycle",
|
||||
);
|
||||
|
||||
export function useNativeRouter() {
|
||||
const runtime = inject<NativeRouterRuntime>(nativeRouterKey);
|
||||
if (!runtime)
|
||||
throw new Error(
|
||||
"Native Vue Router is not installed. Call app.use(nativeRouter).",
|
||||
);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function useNativeViewLifecycle() {
|
||||
const lifecycle = inject<NativeViewLifecycle>(nativeViewLifecycleKey);
|
||||
if (!lifecycle)
|
||||
throw new Error(
|
||||
"Native view lifecycle APIs must be used inside NativeRouterView.",
|
||||
);
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
type NativeViewHook = () => void;
|
||||
|
||||
function onNativeViewState(
|
||||
source: Readonly<{ value: boolean }>,
|
||||
entering: boolean,
|
||||
hook: NativeViewHook,
|
||||
) {
|
||||
onMounted(() => {
|
||||
if (entering && source.value) hook();
|
||||
});
|
||||
watch(
|
||||
() => source.value,
|
||||
(value, previous) => {
|
||||
if (value === entering && previous !== entering) hook();
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
}
|
||||
|
||||
export function onNativeViewActivate(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isActive, true, hook);
|
||||
}
|
||||
|
||||
export function onNativeViewDeactivate(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isActive, false, hook);
|
||||
}
|
||||
|
||||
export function onNativeViewShow(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isVisible, true, hook);
|
||||
}
|
||||
|
||||
export function onNativeViewHide(hook: NativeViewHook) {
|
||||
onNativeViewState(useNativeViewLifecycle().isVisible, false, hook);
|
||||
}
|
||||
|
||||
export function onNativeViewEvict(
|
||||
hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void,
|
||||
) {
|
||||
const lifecycle = useNativeViewLifecycle();
|
||||
onBeforeUnmount(() => {
|
||||
if (lifecycle.status.value === "evicted")
|
||||
hook(lifecycle.evictionReason.value);
|
||||
});
|
||||
}
|
||||
|
||||
function useNativeViewEffect(
|
||||
source: Readonly<{ value: boolean }>,
|
||||
effect: () => void | (() => void),
|
||||
) {
|
||||
let cleanup: void | (() => void);
|
||||
const stopEffect = () => {
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
};
|
||||
const stopWatch = watch(
|
||||
() => source.value,
|
||||
(enabled) => {
|
||||
stopEffect();
|
||||
if (enabled) cleanup = effect();
|
||||
},
|
||||
{ immediate: true, flush: "sync" },
|
||||
);
|
||||
onScopeDispose(() => {
|
||||
stopWatch();
|
||||
stopEffect();
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs an effect only while this route is the semantically active route. */
|
||||
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
|
||||
useNativeViewEffect(useNativeViewLifecycle().isActive, effect);
|
||||
}
|
||||
|
||||
/** Runs an effect while this route is active or participating in a transition. */
|
||||
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
|
||||
useNativeViewEffect(useNativeViewLifecycle().isVisible, effect);
|
||||
}
|
||||
|
||||
const NativeRouteScope = defineComponent({
|
||||
name: "NativeRouteScope",
|
||||
props: {
|
||||
route: {
|
||||
type: Object as PropType<RouteLocationNormalizedLoaded>,
|
||||
required: true,
|
||||
},
|
||||
entryKey: { type: String, required: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const runtime = useNativeRouter();
|
||||
const scopedRoute = shallowReactive({
|
||||
...props.route,
|
||||
}) as RouteLocationNormalizedLoaded;
|
||||
watch(
|
||||
() => props.route,
|
||||
(route) => Object.assign(scopedRoute, route),
|
||||
{ immediate: true },
|
||||
);
|
||||
provide(routeLocationKey, scopedRoute);
|
||||
const entry = computed(() =>
|
||||
runtime.entries.value.find(
|
||||
(candidate) => candidate.key === props.entryKey,
|
||||
),
|
||||
);
|
||||
const role = computed<NativeViewRole>(() =>
|
||||
entry.value ? interactiveRole(entry.value, runtime) : "inactive",
|
||||
);
|
||||
provide<NativeViewLifecycle>(nativeViewLifecycleKey, {
|
||||
key: props.entryKey,
|
||||
route: computed(() => entry.value?.route ?? props.route),
|
||||
status: computed(() => entry.value?.status ?? "evicted"),
|
||||
role,
|
||||
isActive: computed(() => runtime.activeKey.value === props.entryKey),
|
||||
isVisible: computed(() => role.value !== "inactive"),
|
||||
isPreview: computed(
|
||||
() => runtime.transaction.value?.toKey === props.entryKey,
|
||||
),
|
||||
isCached: computed(() =>
|
||||
Boolean(entry.value?.mounted && role.value === "inactive"),
|
||||
),
|
||||
evictionReason: computed(() => entry.value?.evictionReason),
|
||||
});
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
function interactiveRole(
|
||||
entry: NativeViewEntry,
|
||||
runtime: NativeRouterRuntime,
|
||||
): NativeViewRole {
|
||||
const transaction = runtime.transaction.value;
|
||||
if (!transaction)
|
||||
return entry.key === runtime.activeKey.value ? "active" : "inactive";
|
||||
if (entry.key === transaction.fromKey) return "from";
|
||||
if (entry.key === transaction.toKey) return "to";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
export const NativeRouterView = defineComponent({
|
||||
name: "NativeRouterView",
|
||||
setup(_, { slots }) {
|
||||
const runtime = useNativeRouter();
|
||||
return () => {
|
||||
const transaction = runtime.transaction.value;
|
||||
const style = transaction
|
||||
? { "--native-progress": String(transaction.progress) }
|
||||
: undefined;
|
||||
const children = runtime.entries.value
|
||||
.filter((entry) => entry.mounted)
|
||||
.map((entry) => {
|
||||
const role = interactiveRole(entry, runtime);
|
||||
const definition = transaction
|
||||
? runtime.presentationFor(transaction.presentation)
|
||||
: undefined;
|
||||
const customStyle =
|
||||
transaction && (role === "from" || role === "to")
|
||||
? definition?.layerStyle?.({
|
||||
progress: transaction.progress,
|
||||
role,
|
||||
direction: transaction.direction,
|
||||
sourceRect: transaction.sourceRect,
|
||||
})
|
||||
: undefined;
|
||||
return h(
|
||||
"section",
|
||||
{
|
||||
key: entry.key,
|
||||
class: ["nvr-view", `nvr-view--${role}`],
|
||||
style: customStyle,
|
||||
"data-native-role": role,
|
||||
"data-native-route": entry.route.fullPath,
|
||||
"data-native-presentation": transaction?.presentation,
|
||||
"data-native-direction": transaction?.direction,
|
||||
inert: role === "inactive" ? "" : undefined,
|
||||
"aria-hidden": role === "inactive" ? "true" : undefined,
|
||||
},
|
||||
[
|
||||
h(
|
||||
RouterView,
|
||||
{ route: entry.route },
|
||||
{
|
||||
default: ({
|
||||
Component,
|
||||
route,
|
||||
}: {
|
||||
Component: VNode | null;
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
}) => {
|
||||
if (slots.default)
|
||||
return slots.default({ Component, route, entry });
|
||||
return Component
|
||||
? h(
|
||||
NativeRouteScope,
|
||||
{ route, entryKey: entry.key },
|
||||
{ default: () => Component },
|
||||
)
|
||||
: null;
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"nvr-router-view",
|
||||
transaction && "nvr-router-view--interactive",
|
||||
],
|
||||
style,
|
||||
"data-native-presentation": transaction?.presentation,
|
||||
"data-native-direction": transaction?.direction,
|
||||
"data-native-transaction": transaction?.id,
|
||||
"data-native-velocity": transaction
|
||||
? String(transaction.velocity)
|
||||
: undefined,
|
||||
},
|
||||
children,
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const NativeLink = defineComponent({
|
||||
name: "NativeLink",
|
||||
props: {
|
||||
to: {
|
||||
type: [String, Object] as PropType<RouteLocationRaw>,
|
||||
required: true,
|
||||
},
|
||||
replace: Boolean,
|
||||
presentation: String,
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
const runtime = useNativeRouter();
|
||||
const href = computed(() => runtime.router.resolve(props.to).href);
|
||||
const activate = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
void (props.replace
|
||||
? runtime.replace(props.to, { presentation: props.presentation })
|
||||
: runtime.push(props.to, { presentation: props.presentation }));
|
||||
};
|
||||
return () =>
|
||||
h(
|
||||
"a",
|
||||
{ ...attrs, href: href.value, onClick: activate },
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function shouldIgnoreGesture(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceRect(element: HTMLElement): NativeSourceRect {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function createPointerGesture(
|
||||
element: () => HTMLElement | null,
|
||||
begin: (direction: NativeDirection) => Promise<number | null>,
|
||||
directionForDelta: (deltaX: number) => NativeDirection | null,
|
||||
) {
|
||||
let pointerId = -1;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let lastX = 0;
|
||||
let lastTime = 0;
|
||||
let captured = false;
|
||||
let beginPromise: Promise<number | null> | null = null;
|
||||
let ending = false;
|
||||
let bufferedProgress = 0;
|
||||
let bufferedVelocity = 0;
|
||||
let gestureSign = 0;
|
||||
|
||||
const down = (event: PointerEvent) => {
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
shouldIgnoreGesture(event.target)
|
||||
)
|
||||
return;
|
||||
pointerId = event.pointerId;
|
||||
startX = lastX = event.clientX;
|
||||
startY = event.clientY;
|
||||
lastTime = event.timeStamp;
|
||||
captured = false;
|
||||
beginPromise = null;
|
||||
ending = false;
|
||||
bufferedProgress = 0;
|
||||
bufferedVelocity = 0;
|
||||
gestureSign = 0;
|
||||
};
|
||||
const move = async (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
if (!captured) {
|
||||
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return;
|
||||
const direction = directionForDelta(dx);
|
||||
if (!direction) return reset();
|
||||
captured = true;
|
||||
ending = false;
|
||||
gestureSign = Math.sign(dx);
|
||||
element()?.setPointerCapture(pointerId);
|
||||
beginPromise = begin(direction);
|
||||
}
|
||||
event.preventDefault();
|
||||
const elapsed = Math.max(8, event.timeStamp - lastTime);
|
||||
const width = Math.max(1, element()?.clientWidth ?? window.innerWidth);
|
||||
bufferedVelocity =
|
||||
((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width);
|
||||
bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width));
|
||||
lastX = event.clientX;
|
||||
lastTime = event.timeStamp;
|
||||
const pending = beginPromise;
|
||||
if (pending) {
|
||||
const id = await pending;
|
||||
if (pending !== beginPromise || ending) return;
|
||||
if (id === null) return reset();
|
||||
const runtime = injectRuntimeFromElement(element());
|
||||
runtime?.updateInteractive(bufferedProgress, bufferedVelocity);
|
||||
}
|
||||
};
|
||||
const up = async (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
ending = true;
|
||||
const runtime = injectRuntimeFromElement(element());
|
||||
const pending = beginPromise;
|
||||
const shouldFinish = captured;
|
||||
const progress = bufferedProgress;
|
||||
const velocity = bufferedVelocity;
|
||||
// Detach this pointer before awaiting preload/navigation/animation work. A
|
||||
// new gesture may now start without this release callback erasing it.
|
||||
reset();
|
||||
const id = pending ? await pending : null;
|
||||
if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) {
|
||||
runtime.updateInteractive(progress, velocity);
|
||||
await runtime.finishInteractive();
|
||||
}
|
||||
};
|
||||
const cancel = async () => {
|
||||
ending = true;
|
||||
const runtime = injectRuntimeFromElement(element());
|
||||
const pending = beginPromise;
|
||||
const shouldCancel = captured;
|
||||
reset();
|
||||
const id = pending ? await pending : null;
|
||||
if (shouldCancel && id !== null && runtime?.transaction.value?.id === id)
|
||||
await runtime.cancelInteractive();
|
||||
};
|
||||
const reset = () => {
|
||||
pointerId = -1;
|
||||
captured = false;
|
||||
beginPromise = null;
|
||||
ending = false;
|
||||
bufferedProgress = 0;
|
||||
bufferedVelocity = 0;
|
||||
gestureSign = 0;
|
||||
};
|
||||
return { down, move, up, cancel };
|
||||
}
|
||||
|
||||
const runtimeByElement = new WeakMap<Element, NativeRouterRuntime>();
|
||||
function injectRuntimeFromElement(element: Element | null) {
|
||||
return element ? runtimeByElement.get(element) : undefined;
|
||||
}
|
||||
|
||||
export const NativeGestureLink = defineComponent({
|
||||
name: "NativeGestureLink",
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
to: {
|
||||
type: [String, Object] as PropType<RouteLocationRaw>,
|
||||
required: true,
|
||||
},
|
||||
presentation: { type: String, default: "reveal" },
|
||||
replace: Boolean,
|
||||
direction: {
|
||||
type: String as PropType<"left" | "right" | "any">,
|
||||
default: "any",
|
||||
},
|
||||
as: { type: String, default: "div" },
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
const runtime = useNativeRouter();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
let dragDistance = 0;
|
||||
let suppressClick = false;
|
||||
const gesture = createPointerGesture(
|
||||
() => root.value,
|
||||
async (direction) =>
|
||||
runtime.beginInteractive("push", props.to, {
|
||||
presentation: props.presentation,
|
||||
replace: props.replace,
|
||||
direction,
|
||||
sourceRect: root.value ? sourceRect(root.value) : undefined,
|
||||
}),
|
||||
(dx) => {
|
||||
if (props.direction === "left" && dx >= 0) return null;
|
||||
if (props.direction === "right" && dx <= 0) return null;
|
||||
return dx < 0 ? "forward" : "back";
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => void runtime.cancelInteractive());
|
||||
const click = (event: MouseEvent) => {
|
||||
if (suppressClick) {
|
||||
suppressClick = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
void runtime.push(props.to, {
|
||||
presentation: props.presentation,
|
||||
replace: props.replace,
|
||||
});
|
||||
};
|
||||
return () =>
|
||||
h(
|
||||
props.as,
|
||||
{
|
||||
...attrs,
|
||||
ref: (value: unknown) => {
|
||||
root.value = value as HTMLElement | null;
|
||||
if (root.value) runtimeByElement.set(root.value, runtime);
|
||||
},
|
||||
class: ["nvr-gesture-link", attrs.class],
|
||||
onPointerdown: (event: PointerEvent) => {
|
||||
// Component-owned gestures outrank their containing navigator.
|
||||
event.stopPropagation();
|
||||
dragDistance = 0;
|
||||
suppressClick = false;
|
||||
gesture.down(event);
|
||||
},
|
||||
onPointermove: (event: PointerEvent) => {
|
||||
if (event.buttons) {
|
||||
dragDistance += Math.abs(event.movementX);
|
||||
if (dragDistance > 8) suppressClick = true;
|
||||
}
|
||||
void gesture.move(event);
|
||||
},
|
||||
onPointerup: gesture.up,
|
||||
onPointercancel: gesture.cancel,
|
||||
onClick: click,
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const NativeNavigator = defineComponent({
|
||||
name: "NativeNavigator",
|
||||
props: {
|
||||
siblings: {
|
||||
type: Array as PropType<RouteLocationRaw[]>,
|
||||
default: () => [],
|
||||
},
|
||||
edgeWidth: { type: Number, default: 28 },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const runtime = useNativeRouter();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
let candidate: "back" | "sibling" | null = null;
|
||||
const gesture = createPointerGesture(
|
||||
() => root.value,
|
||||
async (direction) => {
|
||||
if (candidate === "back") return runtime.beginInteractive("pop");
|
||||
const current = runtime.router.currentRoute.value.fullPath;
|
||||
const index = props.siblings.findIndex(
|
||||
(route) => runtime.router.resolve(route).fullPath === current,
|
||||
);
|
||||
const nextIndex = index + (direction === "forward" ? 1 : -1);
|
||||
const target = props.siblings[nextIndex];
|
||||
if (index < 0 || !target) return null;
|
||||
return runtime.beginInteractive("sibling", target, {
|
||||
replace:
|
||||
runtime.router.resolve(target).meta.native?.siblingHistory !==
|
||||
"push",
|
||||
direction,
|
||||
presentation: "slide",
|
||||
});
|
||||
},
|
||||
(dx) => {
|
||||
const rtl =
|
||||
getComputedStyle(root.value ?? document.documentElement).direction ===
|
||||
"rtl";
|
||||
const logicalDx = rtl ? -dx : dx;
|
||||
if (candidate === "back") return logicalDx > 0 ? "back" : null;
|
||||
return logicalDx < 0 ? "forward" : "back";
|
||||
},
|
||||
);
|
||||
const gestureSettingAllowsNavigation = () =>
|
||||
runtime.router.currentRoute.value.meta.native?.gesture !== false;
|
||||
const atLeadingEdge = (event: PointerEvent) => {
|
||||
const rect = root.value?.getBoundingClientRect();
|
||||
const rtl =
|
||||
getComputedStyle(root.value ?? document.documentElement).direction ===
|
||||
"rtl";
|
||||
return rect
|
||||
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <=
|
||||
props.edgeWidth
|
||||
: false;
|
||||
};
|
||||
const captureDown = (event: PointerEvent) => {
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
shouldIgnoreGesture(event.target)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!atLeadingEdge(event) ||
|
||||
!gestureSettingAllowsNavigation() ||
|
||||
!runtime.canGoBack.value
|
||||
)
|
||||
return;
|
||||
candidate = "back";
|
||||
gesture.down(event);
|
||||
// The application shell owns the physical back edge. Component-owned
|
||||
// route gestures retain priority everywhere else.
|
||||
event.stopPropagation();
|
||||
};
|
||||
const down = (event: PointerEvent) => {
|
||||
candidate =
|
||||
props.siblings.length && gestureSettingAllowsNavigation()
|
||||
? "sibling"
|
||||
: null;
|
||||
if (candidate) gesture.down(event);
|
||||
};
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
ref: (value: unknown) => {
|
||||
root.value = value as HTMLElement | null;
|
||||
if (root.value) runtimeByElement.set(root.value, runtime);
|
||||
},
|
||||
class: "nvr-navigator",
|
||||
"data-native-can-go-back": String(runtime.canGoBack.value),
|
||||
onPointerdownCapture: captureDown,
|
||||
onPointerdown: down,
|
||||
onPointermove: gesture.move,
|
||||
onPointerup: gesture.up,
|
||||
onPointercancel: gesture.cancel,
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const NativeDismissGesture = defineComponent({
|
||||
name: "NativeDismissGesture",
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
as: { type: String, default: "div" },
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
const runtime = useNativeRouter();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
let pointerId = -1;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let lastY = 0;
|
||||
let lastTime = 0;
|
||||
let captured = false;
|
||||
let beginPromise: Promise<number | null> | null = null;
|
||||
let ending = false;
|
||||
let progress = 0;
|
||||
let velocity = 0;
|
||||
|
||||
const reset = () => {
|
||||
pointerId = -1;
|
||||
captured = false;
|
||||
beginPromise = null;
|
||||
ending = false;
|
||||
progress = 0;
|
||||
velocity = 0;
|
||||
};
|
||||
const down = (event: PointerEvent) => {
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
shouldIgnoreGesture(event.target)
|
||||
)
|
||||
return;
|
||||
event.stopPropagation();
|
||||
pointerId = event.pointerId;
|
||||
startX = event.clientX;
|
||||
startY = lastY = event.clientY;
|
||||
lastTime = event.timeStamp;
|
||||
captured = false;
|
||||
beginPromise = null;
|
||||
ending = false;
|
||||
progress = 0;
|
||||
velocity = 0;
|
||||
};
|
||||
const move = async (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
if (!captured) {
|
||||
if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return;
|
||||
captured = true;
|
||||
ending = false;
|
||||
root.value?.setPointerCapture(pointerId);
|
||||
beginPromise = runtime.beginInteractive("dismiss", undefined, {
|
||||
direction: "back",
|
||||
});
|
||||
}
|
||||
event.preventDefault();
|
||||
progress = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight),
|
||||
),
|
||||
);
|
||||
const height = Math.max(
|
||||
1,
|
||||
root.value?.clientHeight ?? window.innerHeight,
|
||||
);
|
||||
velocity =
|
||||
((event.clientY - lastY) * 1000) /
|
||||
(Math.max(8, event.timeStamp - lastTime) * height);
|
||||
lastY = event.clientY;
|
||||
lastTime = event.timeStamp;
|
||||
const pending = beginPromise;
|
||||
if (pending) {
|
||||
const id = await pending;
|
||||
if (pending !== beginPromise || ending) return;
|
||||
if (id === null) return reset();
|
||||
runtime.updateInteractive(progress, velocity);
|
||||
}
|
||||
};
|
||||
const up = async (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
ending = true;
|
||||
const pending = beginPromise;
|
||||
const shouldFinish = captured;
|
||||
const finalProgress = progress;
|
||||
const finalVelocity = velocity;
|
||||
reset();
|
||||
const id = pending ? await pending : null;
|
||||
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
|
||||
runtime.updateInteractive(finalProgress, finalVelocity);
|
||||
await runtime.finishInteractive();
|
||||
}
|
||||
};
|
||||
const cancel = async () => {
|
||||
ending = true;
|
||||
const pending = beginPromise;
|
||||
const shouldCancel = captured;
|
||||
reset();
|
||||
const id = pending ? await pending : null;
|
||||
if (shouldCancel && id !== null && runtime.transaction.value?.id === id)
|
||||
await runtime.cancelInteractive();
|
||||
};
|
||||
onBeforeUnmount(() => void cancel());
|
||||
return () =>
|
||||
h(
|
||||
props.as,
|
||||
{
|
||||
...attrs,
|
||||
ref: (value: unknown) => {
|
||||
root.value = value as HTMLElement | null;
|
||||
},
|
||||
class: ["nvr-dismiss-gesture", attrs.class],
|
||||
onPointerdown: down,
|
||||
onPointermove: move,
|
||||
onPointerup: up,
|
||||
onPointercancel: cancel,
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export function navigationOptionsFromElement(
|
||||
element: HTMLElement,
|
||||
): NativeNavigationOptions {
|
||||
return { sourceRect: sourceRect(element) };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user