Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,3 +27,5 @@ apps/demo/dev-dist
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
@@ -9,7 +9,7 @@ The repository includes a reusable headless core, a platform-adaptive visual pre
|
|||||||
- Interactive edge pop that can be held indefinitely at any progress.
|
- Interactive edge pop that can be held indefinitely at any progress.
|
||||||
- Ordered horizontal route paging with replace-by-default history.
|
- Ordered horizontal route paging with replace-by-default history.
|
||||||
- Component-originated route dragging with a live target route.
|
- 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.
|
- 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.
|
- Guarded commits: previews do not alter the URL, and rejected navigation springs back.
|
||||||
- Cold-start predictive back through declared parent routes.
|
- Cold-start predictive back through declared parent routes.
|
||||||
@@ -127,6 +127,7 @@ No rAF loop or browser performance observer runs before `start()`, and `stop()`
|
|||||||
|
|
||||||
Design and engineering documentation:
|
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)
|
- [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)
|
- [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md)
|
||||||
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)
|
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)
|
||||||
|
|||||||
@@ -389,6 +389,69 @@ test("manually unloads an inactive route through the public API demo", async ({
|
|||||||
await expect(page.getByTestId("stories-view")).toHaveCount(0);
|
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 ({
|
test("records a navigation frame profile across route changes", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -421,10 +484,213 @@ test("opens and dismisses the compose sheet", async ({ page }) => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "New message" }),
|
page.getByRole("heading", { name: "New message" }),
|
||||||
).toBeVisible();
|
).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 page.getByRole("button", { name: "Cancel" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
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 }) => {
|
test("keeps the target live during a held component drag", async ({ page }) => {
|
||||||
await page.goto("/inbox");
|
await page.goto("/inbox");
|
||||||
const row = page.locator(".conversation-row").first();
|
const row = page.locator(".conversation-row").first();
|
||||||
@@ -528,13 +794,16 @@ test("always opens compose as a vertical sheet after prior navigation", async ({
|
|||||||
test("drags a sheet down to dismiss it", async ({ page }) => {
|
test("drags a sheet down to dismiss it", async ({ page }) => {
|
||||||
await page.goto("/inbox");
|
await page.goto("/inbox");
|
||||||
await page.getByRole("button", { name: "Compose" }).click();
|
await page.getByRole("button", { name: "Compose" }).click();
|
||||||
const sheet = await page.locator(".sheet-screen").boundingBox();
|
await expect(page).toHaveURL(/\/compose$/);
|
||||||
if (!sheet) throw new Error("Sheet did not render");
|
await waitForTransition(page);
|
||||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
|
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.down();
|
||||||
await page.mouse.move(
|
await page.mouse.move(
|
||||||
sheet.x + sheet.width / 2,
|
handle.x + handle.width / 2,
|
||||||
sheet.y + sheet.height * 0.55,
|
handle.y + sheet.height * 0.75,
|
||||||
{ steps: 14 },
|
{ steps: 14 },
|
||||||
);
|
);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|||||||
44
apps/demo/src/compatibility-lab.ts
Normal file
44
apps/demo/src/compatibility-lab.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
39
apps/demo/src/components/CompatibilityAsyncProbe.vue
Normal file
39
apps/demo/src/components/CompatibilityAsyncProbe.vue
Normal file
@@ -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>
|
||||||
41
apps/demo/src/components/CompatibilityCompositionProbe.vue
Normal file
41
apps/demo/src/components/CompatibilityCompositionProbe.vue
Normal file
@@ -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>
|
||||||
29
apps/demo/src/components/CompatibilityInjectProbe.vue
Normal file
29
apps/demo/src/components/CompatibilityInjectProbe.vue
Normal file
@@ -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>
|
||||||
36
apps/demo/src/components/CompatibilityKeepAliveProbe.vue
Normal file
36
apps/demo/src/components/CompatibilityKeepAliveProbe.vue
Normal file
@@ -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>
|
||||||
56
apps/demo/src/components/CompatibilityOptionsProbe.vue
Normal file
56
apps/demo/src/components/CompatibilityOptionsProbe.vue
Normal file
@@ -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 { createCapacitorAdapter } from "@native-vue-router/capacitor";
|
||||||
import { createElectronRendererAdapter } from "@native-vue-router/electron";
|
import { createElectronRendererAdapter } from "@native-vue-router/electron";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
import { demoAppValueKey } from "./compatibility-lab";
|
||||||
import { createPwaAdapter } from "./pwa";
|
import { createPwaAdapter } from "./pwa";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
@@ -27,6 +28,7 @@ const nativeRouter = createNativeRouter({
|
|||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(nativeRouter);
|
app.use(nativeRouter);
|
||||||
|
app.provide(demoAppValueKey, "Injected from the demo application root");
|
||||||
|
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|||||||
@@ -104,6 +104,31 @@ const routes: RouteRecordRaw[] = [
|
|||||||
native: { presentation: "push", parent: "/profile", gesture: "edge" },
|
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({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
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;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
@@ -65,7 +66,7 @@ html[data-pwa-edge-guard="active"] body {
|
|||||||
.app-frame {
|
.app-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100dvh;
|
height: 100vh;
|
||||||
max-width: 740px;
|
max-width: 740px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -662,18 +663,19 @@ html[data-pwa-edge-guard="active"] body {
|
|||||||
font-size: 21px;
|
font-size: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-screen {
|
.compose-sheet {
|
||||||
border-radius: 22px 22px 0 0;
|
--nvr-sheet-background: #0b0d12;
|
||||||
|
--nvr-sheet-backdrop: rgba(0, 0, 0, 0.42);
|
||||||
}
|
}
|
||||||
.sheet-handle {
|
.compose-sheet-content {
|
||||||
position: sticky;
|
min-height: 0;
|
||||||
z-index: 12;
|
background:
|
||||||
top: 8px;
|
radial-gradient(
|
||||||
width: 38px;
|
circle at 88% 4%,
|
||||||
height: 5px;
|
rgba(124, 92, 255, 0.09),
|
||||||
margin: 8px auto 0;
|
transparent 24%
|
||||||
border-radius: 4px;
|
),
|
||||||
background: #4e5360;
|
#0b0d12;
|
||||||
}
|
}
|
||||||
.sheet-header {
|
.sheet-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -685,12 +687,23 @@ html[data-pwa-edge-guard="active"] body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
.sheet-header small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.sheet-header button {
|
.sheet-header button {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: #9b88ff;
|
color: #9b88ff;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
.sheet-header button:last-child {
|
||||||
|
justify-self: end;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
.compose-search {
|
.compose-search {
|
||||||
margin: 0 16px 14px;
|
margin: 0 16px 14px;
|
||||||
}
|
}
|
||||||
@@ -884,6 +897,226 @@ html[data-pwa-edge-guard="active"] body {
|
|||||||
background: #ff6c76;
|
background: #ff6c76;
|
||||||
box-shadow: 0 0 9px #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 {
|
.empty-state {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -1,37 +1,66 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from "vue";
|
||||||
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
|
import { NativeSheet, useNativeRouter } from "@native-vue-router/core";
|
||||||
import AppAvatar from '../components/AppAvatar.vue'
|
import AppAvatar from "../components/AppAvatar.vue";
|
||||||
import { useDemoStore } from '../data'
|
import { useDemoStore } from "../data";
|
||||||
|
|
||||||
const native = useNativeRouter()
|
const native = useNativeRouter();
|
||||||
const store = useDemoStore()
|
const store = useDemoStore();
|
||||||
const query = ref('')
|
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) {
|
async function choose(id: string) {
|
||||||
await native.cancelInteractive()
|
await native.cancelInteractive();
|
||||||
await native.replace(`/chat/${id}`, { presentation: 'push' })
|
await native.replace(`/chat/${id}`, { presentation: "push" });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NativeDismissGesture as="main" class="screen sheet-screen">
|
<NativeSheet
|
||||||
<div class="sheet-handle" aria-hidden="true" />
|
v-model="sheetBreakpoint"
|
||||||
<header class="sheet-header">
|
class="compose-sheet"
|
||||||
<button type="button" @click="native.dismiss()">Cancel</button>
|
aria-label="New message"
|
||||||
<h1>New message</h1>
|
:breakpoints="sheetBreakpoints"
|
||||||
<span />
|
:initial-breakpoint="0.62"
|
||||||
</header>
|
>
|
||||||
<label class="search-field compose-search">
|
<main class="compose-sheet-content">
|
||||||
<span>To:</span>
|
<header class="sheet-header">
|
||||||
<input v-model="query" autofocus placeholder="Search people" />
|
<button type="button" @click="native.dismiss()">Cancel</button>
|
||||||
</label>
|
<div>
|
||||||
<div class="conversation-list">
|
<h1>New message</h1>
|
||||||
<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)">
|
<small data-testid="sheet-size">
|
||||||
<AppAvatar :person="person" />
|
{{ snapEnabled ? `${Math.round(sheetBreakpoint * 100)}%` : "Auto" }}
|
||||||
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
|
</small>
|
||||||
<span class="chevron">›</span>
|
</div>
|
||||||
</button>
|
<button type="button" @click="snapEnabled = !snapEnabled">
|
||||||
</div>
|
{{ snapEnabled ? "Fit content" : "Use snap points" }}
|
||||||
</NativeDismissGesture>
|
</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)"
|
||||||
|
>
|
||||||
|
<AppAvatar :person="person" />
|
||||||
|
<div class="conversation-copy">
|
||||||
|
<strong>{{ person.name }}</strong>
|
||||||
|
<p>{{ person.handle }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="chevron">›</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</NativeSheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
|
import { NativeLink, useNativeRouter } from "@native-vue-router/core";
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from "../components/AppHeader.vue";
|
||||||
import { setStoryEntryBlocked, storyEntryGuard } from '../guard-state'
|
import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
|
||||||
|
|
||||||
const native = useNativeRouter()
|
const native = useNativeRouter();
|
||||||
|
|
||||||
function toggleStoryGuard() {
|
function toggleStoryGuard() {
|
||||||
setStoryEntryBlocked(!storyEntryGuard.blockEntry)
|
setStoryEntryBlocked(!storyEntryGuard.blockEntry);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -24,13 +24,35 @@ function toggleStoryGuard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-list">
|
<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>
|
<a
|
||||||
<button type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard">
|
href="/profile/runtime-lab"
|
||||||
<span>⌽</span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : 'Off' }}</i>
|
@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>
|
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
103
apps/demo/src/views/VueCompatibilityAwayView.vue
Normal file
103
apps/demo/src/views/VueCompatibilityAwayView.vue
Normal file
@@ -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>
|
||||||
427
apps/demo/src/views/VueCompatibilityView.vue
Normal file
427
apps/demo/src/views/VueCompatibilityView.vue
Normal file
@@ -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>
|
||||||
@@ -87,7 +87,7 @@ useNativeViewActiveEffect(() => {
|
|||||||
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
|
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
|
## Optional performance profiler
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ Transactions are interruptible. Starting a new navigation while a spring is sett
|
|||||||
|
|
||||||
## Rendering and presentation
|
## 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.
|
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 component gesture link owns drags that begin on that component away from the back edge.
|
||||||
- A sheet dismissal surface owns downward vertical drags.
|
- 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.
|
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
|
## History and cache are intentionally separate
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics
|
|||||||
|
|
||||||
## Accessibility
|
## 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.
|
||||||
|
|||||||
38
experiments/router-guard-history/README.md
Normal file
38
experiments/router-guard-history/README.md
Normal file
@@ -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.
|
||||||
12
experiments/router-guard-history/index.html
Normal file
12
experiments/router-guard-history/index.html
Normal file
@@ -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>
|
||||||
18
experiments/router-guard-history/package.json
Normal file
18
experiments/router-guard-history/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
138
experiments/router-guard-history/src/App.vue
Normal file
138
experiments/router-guard-history/src/App.vue
Normal file
@@ -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>
|
||||||
78
experiments/router-guard-history/src/experiment.ts
Normal file
78
experiments/router-guard-history/src/experiment.ts
Normal file
@@ -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";
|
||||||
|
}
|
||||||
13
experiments/router-guard-history/src/main.ts
Normal file
13
experiments/router-guard-history/src/main.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
71
experiments/router-guard-history/src/router.ts
Normal file
71
experiments/router-guard-history/src/router.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
256
experiments/router-guard-history/src/style.css
Normal file
256
experiments/router-guard-history/src/style.css
Normal file
@@ -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>
|
||||||
7
experiments/router-guard-history/tsconfig.json
Normal file
7
experiments/router-guard-history/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
6
experiments/router-guard-history/vite.config.ts
Normal file
6
experiments/router-guard-history/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
});
|
||||||
@@ -30,11 +30,33 @@ provide(routeLocationKey, scopedRoute);
|
|||||||
const entry = computed(() =>
|
const entry = computed(() =>
|
||||||
runtime.entries.value.find((candidate) => candidate.key === props.entryKey),
|
runtime.entries.value.find((candidate) => candidate.key === props.entryKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function isUnderlay(entryKey: string) {
|
||||||
|
let presentedEntry = runtime.entries.value.find(
|
||||||
|
(candidate) => candidate.key === runtime.activeKey.value,
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (
|
||||||
|
presentedEntry?.presentation === "sheet" &&
|
||||||
|
presentedEntry.underlayKey &&
|
||||||
|
!seen.has(presentedEntry.underlayKey)
|
||||||
|
) {
|
||||||
|
if (presentedEntry.underlayKey === entryKey) return true;
|
||||||
|
seen.add(presentedEntry.underlayKey);
|
||||||
|
presentedEntry = runtime.entries.value.find(
|
||||||
|
(candidate) => candidate.key === presentedEntry?.underlayKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const role = computed<NativeViewRole>(() => {
|
const role = computed<NativeViewRole>(() => {
|
||||||
const transaction = runtime.transaction.value;
|
const transaction = runtime.transaction.value;
|
||||||
if (!entry.value) return "inactive";
|
if (!entry.value) return "inactive";
|
||||||
if (!transaction)
|
if (!transaction) {
|
||||||
|
if (isUnderlay(entry.value.key)) return "underlay";
|
||||||
return entry.value.key === runtime.activeKey.value ? "active" : "inactive";
|
return entry.value.key === runtime.activeKey.value ? "active" : "inactive";
|
||||||
|
}
|
||||||
if (entry.value.key === transaction.fromKey) return "from";
|
if (entry.value.key === transaction.fromKey) return "from";
|
||||||
if (entry.value.key === transaction.toKey) return "to";
|
if (entry.value.key === transaction.toKey) return "to";
|
||||||
return "inactive";
|
return "inactive";
|
||||||
|
|||||||
748
packages/core/src/components/NativeRouterView.test.ts
Normal file
748
packages/core/src/components/NativeRouterView.test.ts
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
import {
|
||||||
|
KeepAlive,
|
||||||
|
Suspense,
|
||||||
|
Teleport,
|
||||||
|
Transition,
|
||||||
|
computed,
|
||||||
|
createApp,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
inject,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onBeforeMount,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onBeforeUpdate,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
onUpdated,
|
||||||
|
provide,
|
||||||
|
reactive,
|
||||||
|
type App,
|
||||||
|
type Component,
|
||||||
|
type ComputedRef,
|
||||||
|
type InjectionKey,
|
||||||
|
type PropType,
|
||||||
|
type VNode,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
RouterView,
|
||||||
|
createMemoryHistory,
|
||||||
|
createRouter,
|
||||||
|
useRoute,
|
||||||
|
useRouter,
|
||||||
|
type RouteLocationNormalizedLoaded,
|
||||||
|
type RouteRecordRaw,
|
||||||
|
type Router,
|
||||||
|
} from "vue-router";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createNativeRouter } from "../runtime";
|
||||||
|
import type { NativeRouterRuntime } from "../types";
|
||||||
|
import {
|
||||||
|
onNativeViewActivate,
|
||||||
|
onNativeViewDeactivate,
|
||||||
|
onNativeViewEvict,
|
||||||
|
onNativeViewHide,
|
||||||
|
onNativeViewShow,
|
||||||
|
useNativeViewLifecycle,
|
||||||
|
} from "./lifecycle";
|
||||||
|
import NativeRouterView from "./NativeRouterView.vue";
|
||||||
|
|
||||||
|
const appValueKey: InjectionKey<string> = Symbol("compatibility-app-value");
|
||||||
|
const pageValueKey: InjectionKey<ComputedRef<string>> = Symbol(
|
||||||
|
"compatibility-page-value",
|
||||||
|
);
|
||||||
|
|
||||||
|
interface CompatibilityState {
|
||||||
|
revision: number;
|
||||||
|
kept: "a" | "b";
|
||||||
|
transitionVisible: boolean;
|
||||||
|
teleportVisible: boolean;
|
||||||
|
suspenseVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompatibilityFixtures {
|
||||||
|
state: CompatibilityState;
|
||||||
|
events: string[];
|
||||||
|
routes: RouteRecordRaw[];
|
||||||
|
resolveSuspense(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MountedHarness {
|
||||||
|
app: App;
|
||||||
|
container: HTMLElement;
|
||||||
|
native: NativeRouterRuntime;
|
||||||
|
router: Router;
|
||||||
|
teleportTarget: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedApps: App[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, "matchMedia").mockReturnValue({
|
||||||
|
matches: true,
|
||||||
|
} as MediaQueryList);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of mountedApps.splice(0)) app.unmount();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function flushVue() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await nextTick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountHarness(
|
||||||
|
routes: RouteRecordRaw[],
|
||||||
|
initialRoute: string,
|
||||||
|
rootComponent?: Component,
|
||||||
|
): Promise<MountedHarness> {
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||||
|
await router.push(initialRoute);
|
||||||
|
await router.isReady();
|
||||||
|
|
||||||
|
const native = createNativeRouter({ router, cache: { maxInactive: 8 } });
|
||||||
|
const Root = defineComponent({
|
||||||
|
name: "CompatibilityTestRoot",
|
||||||
|
setup: () => () => h(NativeRouterView),
|
||||||
|
});
|
||||||
|
const app = createApp(rootComponent ?? Root);
|
||||||
|
app.use(router);
|
||||||
|
app.use(native);
|
||||||
|
app.provide(appValueKey, "provided-by-app");
|
||||||
|
|
||||||
|
const teleportTarget = document.createElement("div");
|
||||||
|
teleportTarget.id = "compatibility-teleport";
|
||||||
|
document.body.append(teleportTarget);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
app.mount(container);
|
||||||
|
mountedApps.push(app);
|
||||||
|
await flushVue();
|
||||||
|
|
||||||
|
return { app, container, native, router, teleportTarget };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectHookOrder(events: string[], prefix: string, hooks: string[]) {
|
||||||
|
let previous = -1;
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const index = events.indexOf(`${prefix}${hook}`);
|
||||||
|
expect(index, `${prefix}${hook} should have fired`).toBeGreaterThan(
|
||||||
|
previous,
|
||||||
|
);
|
||||||
|
previous = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompatibilityFixtures(): CompatibilityFixtures {
|
||||||
|
const state = reactive<CompatibilityState>({
|
||||||
|
revision: 0,
|
||||||
|
kept: "a",
|
||||||
|
transitionVisible: true,
|
||||||
|
teleportVisible: true,
|
||||||
|
suspenseVisible: false,
|
||||||
|
});
|
||||||
|
const events: string[] = [];
|
||||||
|
let resolveSuspense!: () => void;
|
||||||
|
const suspenseReady = new Promise<void>((resolve) => {
|
||||||
|
resolveSuspense = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const OptionsLifecycleProbe = defineComponent({
|
||||||
|
name: "OptionsLifecycleProbe",
|
||||||
|
props: {
|
||||||
|
label: { type: String, required: true },
|
||||||
|
revision: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
beforeCreate() {
|
||||||
|
events.push(`options:${this.label}:beforeCreate`);
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
events.push(`options:${this.label}:created`);
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
events.push(`options:${this.label}:beforeMount`);
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
events.push(`options:${this.label}:mounted`);
|
||||||
|
},
|
||||||
|
beforeUpdate() {
|
||||||
|
events.push(`options:${this.label}:beforeUpdate`);
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
events.push(`options:${this.label}:updated`);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
events.push(`options:${this.label}:beforeUnmount`);
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
events.push(`options:${this.label}:unmounted`);
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "options-revision" },
|
||||||
|
String(this.revision),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const CompositionLifecycleProbe = defineComponent({
|
||||||
|
name: "CompositionLifecycleProbe",
|
||||||
|
props: {
|
||||||
|
label: { type: String, required: true },
|
||||||
|
revision: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const event = (hook: string) =>
|
||||||
|
events.push(`composition:${props.label}:${hook}`);
|
||||||
|
onBeforeMount(() => event("beforeMount"));
|
||||||
|
onMounted(() => event("mounted"));
|
||||||
|
onBeforeUpdate(() => event("beforeUpdate"));
|
||||||
|
onUpdated(() => event("updated"));
|
||||||
|
onBeforeUnmount(() => event("beforeUnmount"));
|
||||||
|
onUnmounted(() => event("unmounted"));
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "composition-revision" },
|
||||||
|
String(props.revision),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const KeepAliveProbe = defineComponent({
|
||||||
|
name: "KeepAliveProbe",
|
||||||
|
props: {
|
||||||
|
label: { type: String, required: true },
|
||||||
|
variant: { type: String as PropType<"a" | "b">, required: true },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const event = (hook: string) =>
|
||||||
|
events.push(`keep:${props.label}:${props.variant}:${hook}`);
|
||||||
|
onBeforeMount(() => event("beforeMount"));
|
||||||
|
onMounted(() => event("mounted"));
|
||||||
|
onActivated(() => event("activated"));
|
||||||
|
onDeactivated(() => event("deactivated"));
|
||||||
|
onBeforeUnmount(() => event("beforeUnmount"));
|
||||||
|
onUnmounted(() => event("unmounted"));
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": `keep-alive-${props.variant}` },
|
||||||
|
props.variant,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const NativeLifecycleProbe = defineComponent({
|
||||||
|
name: "NativeLifecycleProbe",
|
||||||
|
props: { label: { type: String, required: true } },
|
||||||
|
setup(props) {
|
||||||
|
const lifecycle = useNativeViewLifecycle();
|
||||||
|
const event = (hook: string) =>
|
||||||
|
events.push(`native:${props.label}:${hook}`);
|
||||||
|
onNativeViewActivate(() => event("activate"));
|
||||||
|
onNativeViewDeactivate(() => event("deactivate"));
|
||||||
|
onNativeViewShow(() => event("show"));
|
||||||
|
onNativeViewHide(() => event("hide"));
|
||||||
|
onNativeViewEvict((reason) => event(`evict:${String(reason)}`));
|
||||||
|
return () =>
|
||||||
|
h("span", {
|
||||||
|
"data-testid": "native-lifecycle",
|
||||||
|
"data-active": String(lifecycle.isActive.value),
|
||||||
|
"data-visible": String(lifecycle.isVisible.value),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const TeleportedProbe = defineComponent({
|
||||||
|
name: "TeleportedProbe",
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
const appValue = inject(appValueKey);
|
||||||
|
const pageValue = inject(pageValueKey);
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-testid": "teleported-probe",
|
||||||
|
"data-route": route.fullPath,
|
||||||
|
"data-app-value": appValue,
|
||||||
|
"data-page-value": pageValue?.value,
|
||||||
|
},
|
||||||
|
`teleport revision ${state.revision}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const OptionsRouteProbe = defineComponent({
|
||||||
|
name: "OptionsRouteProbe",
|
||||||
|
render() {
|
||||||
|
const route = (
|
||||||
|
this as unknown as { $route: RouteLocationNormalizedLoaded }
|
||||||
|
).$route;
|
||||||
|
return h("span", { "data-testid": "options-route" }, route.fullPath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const AsyncProbe = defineComponent({
|
||||||
|
name: "AsyncProbe",
|
||||||
|
async setup() {
|
||||||
|
await suspenseReady;
|
||||||
|
return () => h("span", { "data-testid": "suspense-ready" }, "ready");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const CompatibilityPage = defineComponent({
|
||||||
|
name: "CompatibilityPage",
|
||||||
|
props: { id: String },
|
||||||
|
setup(props) {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const appValue = inject(appValueKey);
|
||||||
|
provide(
|
||||||
|
pageValueKey,
|
||||||
|
computed(() => `page:${route.fullPath}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transitionEvent = (hook: string) =>
|
||||||
|
events.push(`transition:${route.fullPath}:${hook}`);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const label = route.fullPath;
|
||||||
|
return h("article", { "data-testid": "compatibility-page" }, [
|
||||||
|
h("span", { "data-testid": "route-full-path" }, route.fullPath),
|
||||||
|
h("span", { "data-testid": "route-name" }, String(route.name)),
|
||||||
|
h("span", { "data-testid": "route-param" }, String(route.params.id)),
|
||||||
|
h("span", { "data-testid": "route-prop" }, String(props.id)),
|
||||||
|
h("span", { "data-testid": "route-query" }, String(route.query.tab)),
|
||||||
|
h("span", { "data-testid": "route-hash" }, route.hash),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "route-meta" },
|
||||||
|
String(route.meta.section),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "route-matched" },
|
||||||
|
route.matched.map((record) => String(record.name)).join(","),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "router-current-route" },
|
||||||
|
router.currentRoute.value.fullPath,
|
||||||
|
),
|
||||||
|
h("span", { "data-testid": "injected-app-value" }, appValue),
|
||||||
|
h(OptionsRouteProbe),
|
||||||
|
h(OptionsLifecycleProbe, { label, revision: state.revision }),
|
||||||
|
h(CompositionLifecycleProbe, { label, revision: state.revision }),
|
||||||
|
h(KeepAlive, null, {
|
||||||
|
default: () =>
|
||||||
|
h(KeepAliveProbe, {
|
||||||
|
key: state.kept,
|
||||||
|
label,
|
||||||
|
variant: state.kept,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
h(
|
||||||
|
Transition,
|
||||||
|
{
|
||||||
|
css: false,
|
||||||
|
onBeforeEnter: () => transitionEvent("beforeEnter"),
|
||||||
|
onEnter: (_element: Element, done: () => void) => {
|
||||||
|
transitionEvent("enter");
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
onAfterEnter: () => transitionEvent("afterEnter"),
|
||||||
|
onBeforeLeave: () => transitionEvent("beforeLeave"),
|
||||||
|
onLeave: (_element: Element, done: () => void) => {
|
||||||
|
transitionEvent("leave");
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
onAfterLeave: () => transitionEvent("afterLeave"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
state.transitionVisible
|
||||||
|
? h(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "transition-child" },
|
||||||
|
String(state.revision),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
h(NativeLifecycleProbe, { label }),
|
||||||
|
state.teleportVisible
|
||||||
|
? h(Teleport, { to: "#compatibility-teleport" }, h(TeleportedProbe))
|
||||||
|
: null,
|
||||||
|
state.suspenseVisible
|
||||||
|
? h(
|
||||||
|
Suspense,
|
||||||
|
{ timeout: 0 },
|
||||||
|
{
|
||||||
|
default: () => h(AsyncProbe),
|
||||||
|
fallback: () =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "suspense-fallback" },
|
||||||
|
"loading",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const UsersLayout = defineComponent({
|
||||||
|
name: "UsersLayout",
|
||||||
|
setup: () => () =>
|
||||||
|
h("div", { "data-testid": "users-layout" }, h(RouterView)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/users",
|
||||||
|
name: "users",
|
||||||
|
component: UsersLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ":id",
|
||||||
|
name: "user",
|
||||||
|
component: CompatibilityPage,
|
||||||
|
props: true,
|
||||||
|
meta: { section: "people" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/other",
|
||||||
|
name: "other",
|
||||||
|
component: CompatibilityPage,
|
||||||
|
meta: { section: "other" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { state, events, routes, resolveSuspense };
|
||||||
|
}
|
||||||
|
|
||||||
|
function textWithin(root: ParentNode, testId: string) {
|
||||||
|
return root.querySelector(`[data-testid="${testId}"]`)?.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NativeRouterView Vue compatibility", () => {
|
||||||
|
it("scopes params, query, hash, metadata, route props, and nested matches to a preview", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
const { container, native, router, teleportTarget } = await mountHarness(
|
||||||
|
fixtures.routes,
|
||||||
|
"/users/one?tab=summary#top",
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = container.querySelector('[data-native-role="active"]')!;
|
||||||
|
expect(textWithin(active, "route-full-path")).toBe(
|
||||||
|
"/users/one?tab=summary#top",
|
||||||
|
);
|
||||||
|
expect(textWithin(active, "route-name")).toBe("user");
|
||||||
|
expect(textWithin(active, "route-param")).toBe("one");
|
||||||
|
expect(textWithin(active, "route-prop")).toBe("one");
|
||||||
|
expect(textWithin(active, "route-query")).toBe("summary");
|
||||||
|
expect(textWithin(active, "route-hash")).toBe("#top");
|
||||||
|
expect(textWithin(active, "route-meta")).toBe("people");
|
||||||
|
expect(textWithin(active, "route-matched")).toBe("users,user");
|
||||||
|
expect(textWithin(active, "options-route")).toBe(
|
||||||
|
"/users/one?tab=summary#top",
|
||||||
|
);
|
||||||
|
|
||||||
|
await native.beginInteractive("push", "/users/two?tab=activity#details");
|
||||||
|
await flushVue();
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(
|
||||||
|
"/users/one?tab=summary#top",
|
||||||
|
);
|
||||||
|
const preview = container.querySelector('[data-native-role="to"]')!;
|
||||||
|
expect(textWithin(preview, "route-full-path")).toBe(
|
||||||
|
"/users/two?tab=activity#details",
|
||||||
|
);
|
||||||
|
expect(textWithin(preview, "route-param")).toBe("two");
|
||||||
|
expect(textWithin(preview, "route-prop")).toBe("two");
|
||||||
|
expect(textWithin(preview, "route-query")).toBe("activity");
|
||||||
|
expect(textWithin(preview, "route-hash")).toBe("#details");
|
||||||
|
expect(textWithin(preview, "options-route")).toBe(
|
||||||
|
"/users/two?tab=activity#details",
|
||||||
|
);
|
||||||
|
expect(textWithin(preview, "router-current-route")).toBe(
|
||||||
|
"/users/one?tab=summary#top",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
teleportTarget.querySelector(
|
||||||
|
'[data-route="/users/two?tab=activity#details"]',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
dataset: {
|
||||||
|
appValue: "provided-by-app",
|
||||||
|
pageValue: "page:/users/two?tab=activity#details",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await native.cancelInteractive();
|
||||||
|
await flushVue();
|
||||||
|
expect(container.querySelector('[data-native-role="to"]')).toBeNull();
|
||||||
|
expect(
|
||||||
|
teleportTarget.querySelector(
|
||||||
|
'[data-route="/users/two?tab=activity#details"]',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
await native.push("/users/two?tab=activity#details", {
|
||||||
|
presentation: "none",
|
||||||
|
});
|
||||||
|
await flushVue();
|
||||||
|
const committed = container.querySelector('[data-native-role="active"]')!;
|
||||||
|
expect(textWithin(committed, "route-full-path")).toBe(
|
||||||
|
"/users/two?tab=activity#details",
|
||||||
|
);
|
||||||
|
expect(textWithin(committed, "options-route")).toBe(
|
||||||
|
"/users/two?tab=activity#details",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs Options API and Composition API lifecycle hooks through updates and eviction", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
const { native } = await mountHarness(
|
||||||
|
fixtures.routes,
|
||||||
|
"/users/one?tab=summary",
|
||||||
|
);
|
||||||
|
const optionsPrefix = "options:/users/one?tab=summary:";
|
||||||
|
const compositionPrefix = "composition:/users/one?tab=summary:";
|
||||||
|
|
||||||
|
expectHookOrder(fixtures.events, optionsPrefix, [
|
||||||
|
"beforeCreate",
|
||||||
|
"created",
|
||||||
|
"beforeMount",
|
||||||
|
"mounted",
|
||||||
|
]);
|
||||||
|
expectHookOrder(fixtures.events, compositionPrefix, [
|
||||||
|
"beforeMount",
|
||||||
|
"mounted",
|
||||||
|
]);
|
||||||
|
expect(fixtures.events).toContain("native:/users/one?tab=summary:activate");
|
||||||
|
expect(fixtures.events).toContain("native:/users/one?tab=summary:show");
|
||||||
|
|
||||||
|
fixtures.state.revision += 1;
|
||||||
|
await flushVue();
|
||||||
|
expectHookOrder(fixtures.events, optionsPrefix, [
|
||||||
|
"beforeUpdate",
|
||||||
|
"updated",
|
||||||
|
]);
|
||||||
|
expectHookOrder(fixtures.events, compositionPrefix, [
|
||||||
|
"beforeUpdate",
|
||||||
|
"updated",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await native.push("/other", { presentation: "none" });
|
||||||
|
await flushVue();
|
||||||
|
expect(fixtures.events).not.toContain(`${optionsPrefix}beforeUnmount`);
|
||||||
|
expect(fixtures.events).toContain(
|
||||||
|
"native:/users/one?tab=summary:deactivate",
|
||||||
|
);
|
||||||
|
expect(fixtures.events).toContain("native:/users/one?tab=summary:hide");
|
||||||
|
expect(fixtures.events).toContain("native:/other:activate");
|
||||||
|
expect(fixtures.events).toContain("native:/other:show");
|
||||||
|
|
||||||
|
native.trimCache({ includePinned: true });
|
||||||
|
await flushVue();
|
||||||
|
expectHookOrder(fixtures.events, optionsPrefix, [
|
||||||
|
"beforeUnmount",
|
||||||
|
"unmounted",
|
||||||
|
]);
|
||||||
|
expectHookOrder(fixtures.events, compositionPrefix, [
|
||||||
|
"beforeUnmount",
|
||||||
|
"unmounted",
|
||||||
|
]);
|
||||||
|
expect(fixtures.events).toContain(
|
||||||
|
"native:/users/one?tab=summary:evict:trimmed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Vue KeepAlive activation and deactivation semantics", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
await mountHarness(fixtures.routes, "/users/one");
|
||||||
|
|
||||||
|
expectHookOrder(fixtures.events, "keep:/users/one:a:", [
|
||||||
|
"beforeMount",
|
||||||
|
"mounted",
|
||||||
|
"activated",
|
||||||
|
]);
|
||||||
|
|
||||||
|
fixtures.state.kept = "b";
|
||||||
|
await flushVue();
|
||||||
|
expect(fixtures.events).toContain("keep:/users/one:a:deactivated");
|
||||||
|
expectHookOrder(fixtures.events, "keep:/users/one:b:", [
|
||||||
|
"beforeMount",
|
||||||
|
"mounted",
|
||||||
|
"activated",
|
||||||
|
]);
|
||||||
|
|
||||||
|
fixtures.state.kept = "a";
|
||||||
|
await flushVue();
|
||||||
|
expect(fixtures.events).toContain("keep:/users/one:b:deactivated");
|
||||||
|
expect(
|
||||||
|
fixtures.events.filter(
|
||||||
|
(event) => event === "keep:/users/one:a:activated",
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
fixtures.events.filter((event) => event === "keep:/users/one:a:mounted"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports the standard RouterView Transition and KeepAlive scoped-slot pattern", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
const WrappedRouterView = defineComponent({
|
||||||
|
name: "WrappedRouterView",
|
||||||
|
setup() {
|
||||||
|
return () =>
|
||||||
|
h(NativeRouterView, null, {
|
||||||
|
default: ({
|
||||||
|
Component: RoutedComponent,
|
||||||
|
}: {
|
||||||
|
Component: VNode | null;
|
||||||
|
}) =>
|
||||||
|
h(
|
||||||
|
Transition,
|
||||||
|
{ css: false },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(KeepAlive, null, {
|
||||||
|
default: () => RoutedComponent,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { container, native } = await mountHarness(
|
||||||
|
fixtures.routes,
|
||||||
|
"/users/one?tab=summary",
|
||||||
|
WrappedRouterView,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
textWithin(
|
||||||
|
container.querySelector('[data-native-role="active"]')!,
|
||||||
|
"options-route",
|
||||||
|
),
|
||||||
|
).toBe("/users/one?tab=summary");
|
||||||
|
|
||||||
|
await native.beginInteractive("push", "/users/two?tab=activity");
|
||||||
|
await flushVue();
|
||||||
|
const preview = container.querySelector('[data-native-role="to"]')!;
|
||||||
|
expect(textWithin(preview, "route-full-path")).toBe(
|
||||||
|
"/users/two?tab=activity",
|
||||||
|
);
|
||||||
|
expect(textWithin(preview, "options-route")).toBe(
|
||||||
|
"/users/two?tab=activity",
|
||||||
|
);
|
||||||
|
|
||||||
|
await native.cancelInteractive();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs JavaScript Transition enter and leave hooks", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
const { container } = await mountHarness(fixtures.routes, "/users/one");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="transition-child"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
fixtures.state.transitionVisible = false;
|
||||||
|
await flushVue();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="transition-child"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expectHookOrder(fixtures.events, "transition:/users/one:", [
|
||||||
|
"beforeLeave",
|
||||||
|
"leave",
|
||||||
|
"afterLeave",
|
||||||
|
]);
|
||||||
|
|
||||||
|
fixtures.state.transitionVisible = true;
|
||||||
|
await flushVue();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="transition-child"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expectHookOrder(fixtures.events, "transition:/users/one:", [
|
||||||
|
"beforeEnter",
|
||||||
|
"enter",
|
||||||
|
"afterEnter",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Teleport reactive, preserves injection, and removes teleported content on eviction", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
const { native, teleportTarget } = await mountHarness(
|
||||||
|
fixtures.routes,
|
||||||
|
"/users/one",
|
||||||
|
);
|
||||||
|
|
||||||
|
const initial = teleportTarget.querySelector<HTMLElement>(
|
||||||
|
'[data-route="/users/one"]',
|
||||||
|
)!;
|
||||||
|
expect(initial.textContent).toBe("teleport revision 0");
|
||||||
|
expect(initial.dataset.appValue).toBe("provided-by-app");
|
||||||
|
expect(initial.dataset.pageValue).toBe("page:/users/one");
|
||||||
|
|
||||||
|
fixtures.state.revision += 1;
|
||||||
|
await flushVue();
|
||||||
|
expect(initial.textContent).toBe("teleport revision 1");
|
||||||
|
|
||||||
|
await native.push("/other", { presentation: "none" });
|
||||||
|
await flushVue();
|
||||||
|
expect(
|
||||||
|
teleportTarget.querySelector('[data-route="/users/one"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
teleportTarget.querySelector('[data-route="/other"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
native.trimCache({ includePinned: true });
|
||||||
|
await flushVue();
|
||||||
|
expect(
|
||||||
|
teleportTarget.querySelector('[data-route="/users/one"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
teleportTarget.querySelector('[data-route="/other"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Suspense fallback and resolved async content inside a native view", async () => {
|
||||||
|
const fixtures = createCompatibilityFixtures();
|
||||||
|
const { container } = await mountHarness(fixtures.routes, "/users/one");
|
||||||
|
|
||||||
|
fixtures.state.suspenseVisible = true;
|
||||||
|
await flushVue();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="suspense-fallback"]')?.textContent,
|
||||||
|
).toBe("loading");
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="suspense-ready"]'),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
fixtures.resolveSuspense();
|
||||||
|
await flushVue();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="suspense-fallback"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="suspense-ready"]')?.textContent,
|
||||||
|
).toBe("ready");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,28 @@ function roleFor(entry: NativeViewEntry) {
|
|||||||
return interactiveRole(entry, runtime);
|
return interactiveRole(entry, runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUnderlay(
|
||||||
|
entry: NativeViewEntry,
|
||||||
|
nativeRuntime: NativeRouterRuntime,
|
||||||
|
) {
|
||||||
|
let presentedEntry = nativeRuntime.entries.value.find(
|
||||||
|
(candidate) => candidate.key === nativeRuntime.activeKey.value,
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (
|
||||||
|
presentedEntry?.presentation === "sheet" &&
|
||||||
|
presentedEntry.underlayKey &&
|
||||||
|
!seen.has(presentedEntry.underlayKey)
|
||||||
|
) {
|
||||||
|
if (presentedEntry.underlayKey === entry.key) return true;
|
||||||
|
seen.add(presentedEntry.underlayKey);
|
||||||
|
presentedEntry = nativeRuntime.entries.value.find(
|
||||||
|
(candidate) => candidate.key === presentedEntry?.underlayKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function layerStyleFor(entry: NativeViewEntry) {
|
function layerStyleFor(entry: NativeViewEntry) {
|
||||||
const currentTransaction = transaction.value;
|
const currentTransaction = transaction.value;
|
||||||
if (!currentTransaction) return undefined;
|
if (!currentTransaction) return undefined;
|
||||||
@@ -42,8 +64,10 @@ function interactiveRole(
|
|||||||
nativeRuntime: NativeRouterRuntime,
|
nativeRuntime: NativeRouterRuntime,
|
||||||
): NativeViewRole {
|
): NativeViewRole {
|
||||||
const currentTransaction = nativeRuntime.transaction.value;
|
const currentTransaction = nativeRuntime.transaction.value;
|
||||||
if (!currentTransaction)
|
if (!currentTransaction) {
|
||||||
|
if (isUnderlay(entry, nativeRuntime)) return "underlay";
|
||||||
return entry.key === nativeRuntime.activeKey.value ? "active" : "inactive";
|
return entry.key === nativeRuntime.activeKey.value ? "active" : "inactive";
|
||||||
|
}
|
||||||
if (entry.key === currentTransaction.fromKey) return "from";
|
if (entry.key === currentTransaction.fromKey) return "from";
|
||||||
if (entry.key === currentTransaction.toKey) return "to";
|
if (entry.key === currentTransaction.toKey) return "to";
|
||||||
return "inactive";
|
return "inactive";
|
||||||
@@ -72,24 +96,29 @@ function interactiveRole(
|
|||||||
:style="layerStyleFor(entry)"
|
:style="layerStyleFor(entry)"
|
||||||
:data-native-role="roleFor(entry)"
|
:data-native-role="roleFor(entry)"
|
||||||
:data-native-route="entry.route.fullPath"
|
:data-native-route="entry.route.fullPath"
|
||||||
|
:data-native-view-presentation="entry.presentation"
|
||||||
:data-native-presentation="transaction?.presentation"
|
:data-native-presentation="transaction?.presentation"
|
||||||
:data-native-direction="transaction?.direction"
|
:data-native-direction="transaction?.direction"
|
||||||
:inert="roleFor(entry) === 'inactive' ? true : undefined"
|
:inert="
|
||||||
:aria-hidden="roleFor(entry) === 'inactive' ? 'true' : undefined"
|
roleFor(entry) === 'inactive' || roleFor(entry) === 'underlay'
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:aria-hidden="
|
||||||
|
roleFor(entry) === 'inactive' || roleFor(entry) === 'underlay'
|
||||||
|
? 'true'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<RouterView v-slot="{ Component, route }" :route="entry.route">
|
<RouterView v-slot="{ Component, route }" :route="entry.route">
|
||||||
<slot
|
<NativeRouteScope :route="route" :entry-key="entry.key">
|
||||||
v-if="slots.default"
|
<slot
|
||||||
:Component="Component"
|
v-if="slots.default"
|
||||||
:route="route"
|
:Component="Component"
|
||||||
:entry="entry"
|
:route="route"
|
||||||
/>
|
:entry="entry"
|
||||||
<NativeRouteScope
|
/>
|
||||||
v-else-if="Component"
|
<component v-else-if="Component" :is="Component" />
|
||||||
:route="route"
|
|
||||||
:entry-key="entry.key"
|
|
||||||
>
|
|
||||||
<component :is="Component" />
|
|
||||||
</NativeRouteScope>
|
</NativeRouteScope>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
182
packages/core/src/components/NativeSheet.test.ts
Normal file
182
packages/core/src/components/NativeSheet.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
createRouter,
|
||||||
|
type RouteRecordRaw,
|
||||||
|
} from "vue-router";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createNativeRouter } from "../runtime";
|
||||||
|
import NativeRouterView from "./NativeRouterView.vue";
|
||||||
|
import NativeSheet from "./NativeSheet.vue";
|
||||||
|
import {
|
||||||
|
adjacentSheetBreakpoint,
|
||||||
|
nearestSheetBreakpoint,
|
||||||
|
normalizeSheetBreakpoints,
|
||||||
|
} from "./sheet";
|
||||||
|
|
||||||
|
const mountedApps: ReturnType<typeof createApp>[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, "matchMedia").mockReturnValue({
|
||||||
|
matches: true,
|
||||||
|
} as MediaQueryList);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of mountedApps.splice(0)) app.unmount();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function flushVue() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await nextTick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sheet breakpoints", () => {
|
||||||
|
it("normalizes, finds, and moves between fractional breakpoints", () => {
|
||||||
|
expect(normalizeSheetBreakpoints([1, 0.5, 0.5, -1, 1.2, 0.25])).toEqual([
|
||||||
|
0.25, 0.5, 1,
|
||||||
|
]);
|
||||||
|
expect(nearestSheetBreakpoint([0.25, 0.5, 1], 0.62)).toBe(0.5);
|
||||||
|
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "up")).toBe(1);
|
||||||
|
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "down")).toBe(0.25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NativeSheet", () => {
|
||||||
|
it("retains a visible inert underlay and exposes keyboard snap points", async () => {
|
||||||
|
const BasePage = defineComponent({
|
||||||
|
name: "BasePage",
|
||||||
|
setup: () => () => h("main", { "data-testid": "base" }, "Base"),
|
||||||
|
});
|
||||||
|
const SheetPage = defineComponent({
|
||||||
|
name: "SheetPage",
|
||||||
|
setup: () => () =>
|
||||||
|
h(
|
||||||
|
NativeSheet,
|
||||||
|
{ breakpoints: [0.5, 1], initialBreakpoint: 0.5 },
|
||||||
|
{ default: () => h("p", "Sheet content") },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: "/", component: BasePage },
|
||||||
|
{
|
||||||
|
path: "/sheet",
|
||||||
|
component: SheetPage,
|
||||||
|
meta: { native: { presentation: "sheet", parent: "/" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||||
|
await router.push("/");
|
||||||
|
await router.isReady();
|
||||||
|
const native = createNativeRouter({ router });
|
||||||
|
const Root = defineComponent({
|
||||||
|
setup: () => () => h(NativeRouterView),
|
||||||
|
});
|
||||||
|
const app = createApp(Root);
|
||||||
|
app.use(router);
|
||||||
|
app.use(native);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
app.mount(container);
|
||||||
|
mountedApps.push(app);
|
||||||
|
await flushVue();
|
||||||
|
|
||||||
|
expect(await native.present("/sheet", "sheet")).toBe(true);
|
||||||
|
await flushVue();
|
||||||
|
|
||||||
|
const active = container.querySelector('[data-native-role="active"]');
|
||||||
|
const underlay = container.querySelector('[data-native-role="underlay"]');
|
||||||
|
const surface = container.querySelector<HTMLElement>("[data-native-sheet]");
|
||||||
|
const handle = container.querySelector<HTMLElement>(".nvr-sheet__handle");
|
||||||
|
const root = container.querySelector<HTMLElement>(".nvr-sheet");
|
||||||
|
const body = container.querySelector<HTMLElement>(".nvr-sheet__body");
|
||||||
|
expect(active?.getAttribute("data-native-view-presentation")).toBe("sheet");
|
||||||
|
expect(active?.hasAttribute("data-native-sheet-surface")).toBe(true);
|
||||||
|
expect(underlay?.hasAttribute("inert")).toBe(true);
|
||||||
|
expect(underlay?.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
expect(surface?.dataset.nativeSheetMode).toBe("breakpoints");
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||||
|
|
||||||
|
handle?.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
|
||||||
|
);
|
||||||
|
await flushVue();
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
|
||||||
|
|
||||||
|
if (!root || !body) throw new Error("Sheet body did not render");
|
||||||
|
Object.defineProperty(root, "clientHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 1_000,
|
||||||
|
});
|
||||||
|
Object.defineProperties(body, {
|
||||||
|
clientHeight: { configurable: true, value: 300 },
|
||||||
|
scrollHeight: { configurable: true, value: 600 },
|
||||||
|
});
|
||||||
|
handle?.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
||||||
|
);
|
||||||
|
await flushVue();
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||||
|
|
||||||
|
const dispatchContentPointer = (
|
||||||
|
type: "pointerdown" | "pointermove" | "pointerup",
|
||||||
|
pointerId: number,
|
||||||
|
clientY: number,
|
||||||
|
) =>
|
||||||
|
body.dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
button: 0,
|
||||||
|
clientX: 20,
|
||||||
|
clientY,
|
||||||
|
isPrimary: true,
|
||||||
|
pointerId,
|
||||||
|
pointerType: "mouse",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Once content wins a gesture, reaching an edge later must not reinterpret
|
||||||
|
// all of the gesture's accumulated distance as sheet movement.
|
||||||
|
body.scrollTop = 100;
|
||||||
|
dispatchContentPointer("pointerdown", 2, 220);
|
||||||
|
dispatchContentPointer("pointermove", 2, 170);
|
||||||
|
body.scrollTop = 300;
|
||||||
|
dispatchContentPointer("pointermove", 2, 120);
|
||||||
|
dispatchContentPointer("pointerup", 2, 120);
|
||||||
|
await flushVue();
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||||
|
|
||||||
|
// Reversing a content-owned gesture at the other boundary keeps content
|
||||||
|
// ownership until release instead of moving both systems.
|
||||||
|
body.scrollTop = 100;
|
||||||
|
dispatchContentPointer("pointerdown", 3, 180);
|
||||||
|
dispatchContentPointer("pointermove", 3, 120);
|
||||||
|
body.scrollTop = 0;
|
||||||
|
dispatchContentPointer("pointermove", 3, 260);
|
||||||
|
dispatchContentPointer("pointerup", 3, 260);
|
||||||
|
await flushVue();
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||||
|
|
||||||
|
body.scrollTop = 300;
|
||||||
|
dispatchContentPointer("pointerdown", 4, 220);
|
||||||
|
dispatchContentPointer("pointermove", 4, 120);
|
||||||
|
dispatchContentPointer("pointerup", 4, 120);
|
||||||
|
await flushVue();
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
|
||||||
|
|
||||||
|
body.scrollTop = 0;
|
||||||
|
dispatchContentPointer("pointerdown", 5, 120);
|
||||||
|
dispatchContentPointer("pointermove", 5, 220);
|
||||||
|
dispatchContentPointer("pointerup", 5, 220);
|
||||||
|
await flushVue();
|
||||||
|
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||||
|
|
||||||
|
expect(await native.dismiss()).toBe(true);
|
||||||
|
await flushVue();
|
||||||
|
expect(router.currentRoute.value.path).toBe("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
562
packages/core/src/components/NativeSheet.vue
Normal file
562
packages/core/src/components/NativeSheet.vue
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
watch,
|
||||||
|
type PropType,
|
||||||
|
} from "vue";
|
||||||
|
import { useNativeRouter } from "./lifecycle";
|
||||||
|
import {
|
||||||
|
adjacentSheetBreakpoint,
|
||||||
|
nearestSheetBreakpoint,
|
||||||
|
normalizeSheetBreakpoints,
|
||||||
|
} from "./sheet";
|
||||||
|
|
||||||
|
defineOptions({ name: "NativeSheet", inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Fractions of the available, safe-area-contained route height. */
|
||||||
|
breakpoints: {
|
||||||
|
type: Array as PropType<number[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
/** Initial fraction. The nearest declared breakpoint is used. */
|
||||||
|
initialBreakpoint: Number,
|
||||||
|
/** Current fraction for v-model. */
|
||||||
|
modelValue: Number,
|
||||||
|
dismissible: { type: Boolean, default: true },
|
||||||
|
backdropDismiss: { type: Boolean, default: true },
|
||||||
|
showHandle: { type: Boolean, default: true },
|
||||||
|
ariaLabel: { type: String, default: "Sheet" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: number];
|
||||||
|
"breakpoint-change": [value: number];
|
||||||
|
dismiss: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const runtime = useNativeRouter();
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
const surface = ref<HTMLElement | null>(null);
|
||||||
|
const handle = ref<HTMLElement | null>(null);
|
||||||
|
const body = ref<HTMLElement | null>(null);
|
||||||
|
const content = ref<HTMLElement | null>(null);
|
||||||
|
const height = ref<number>();
|
||||||
|
const activeBreakpoint = ref<number>();
|
||||||
|
const dragging = ref(false);
|
||||||
|
const ready = ref(false);
|
||||||
|
const dismissing = ref(false);
|
||||||
|
const normalizedBreakpoints = computed(() =>
|
||||||
|
normalizeSheetBreakpoints(props.breakpoints),
|
||||||
|
);
|
||||||
|
const usesBreakpoints = computed(() => normalizedBreakpoints.value.length > 0);
|
||||||
|
const heightStyle = computed(() =>
|
||||||
|
height.value === undefined ? undefined : `${height.value}px`,
|
||||||
|
);
|
||||||
|
const breakpointLabel = computed(() =>
|
||||||
|
activeBreakpoint.value === undefined
|
||||||
|
? "content"
|
||||||
|
: String(activeBreakpoint.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
let owningLayer: HTMLElement | null = null;
|
||||||
|
let pointerId = -1;
|
||||||
|
let touchId = -1;
|
||||||
|
let candidateX = 0;
|
||||||
|
let candidateY = 0;
|
||||||
|
let candidateTime = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let startHeight = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
let velocity = 0;
|
||||||
|
let moved = false;
|
||||||
|
let wheelDistance = 0;
|
||||||
|
let wheelDirection: "up" | "down" | undefined;
|
||||||
|
let wheelOwner: "content" | "sheet" | undefined;
|
||||||
|
let wheelLocked = false;
|
||||||
|
let wheelResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let contentGestureOwner: "pending" | "content" | "sheet" | undefined;
|
||||||
|
|
||||||
|
function availableHeight() {
|
||||||
|
return Math.max(1, root.value?.clientHeight ?? window.innerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyPadding() {
|
||||||
|
if (!body.value) return 0;
|
||||||
|
const style = getComputedStyle(body.value);
|
||||||
|
return (
|
||||||
|
(Number.parseFloat(style.paddingTop) || 0) +
|
||||||
|
(Number.parseFloat(style.paddingBottom) || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function naturalHeight() {
|
||||||
|
return Math.min(
|
||||||
|
availableHeight(),
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
(handle.value?.offsetHeight ?? 0) +
|
||||||
|
(content.value?.scrollHeight ?? 0) +
|
||||||
|
bodyPadding(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestedBreakpoint() {
|
||||||
|
const points = normalizedBreakpoints.value;
|
||||||
|
if (!points.length) return undefined;
|
||||||
|
return nearestSheetBreakpoint(
|
||||||
|
points,
|
||||||
|
props.modelValue ??
|
||||||
|
activeBreakpoint.value ??
|
||||||
|
props.initialBreakpoint ??
|
||||||
|
points[0]!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
const breakpoint = requestedBreakpoint();
|
||||||
|
activeBreakpoint.value = breakpoint;
|
||||||
|
height.value = breakpoint ? availableHeight() * breakpoint : naturalHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBreakpoint(breakpoint: number, notify = true) {
|
||||||
|
const nearest = nearestSheetBreakpoint(
|
||||||
|
normalizedBreakpoints.value,
|
||||||
|
breakpoint,
|
||||||
|
);
|
||||||
|
if (nearest === undefined) return measure();
|
||||||
|
activeBreakpoint.value = nearest;
|
||||||
|
height.value = availableHeight() * nearest;
|
||||||
|
if (notify) {
|
||||||
|
emit("update:modelValue", nearest);
|
||||||
|
emit("breakpoint-change", nearest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissSheet() {
|
||||||
|
if (!props.dismissible || dismissing.value) return false;
|
||||||
|
dismissing.value = true;
|
||||||
|
emit("dismiss");
|
||||||
|
const dismissed = await runtime.dismiss();
|
||||||
|
dismissing.value = false;
|
||||||
|
if (!dismissed) measure();
|
||||||
|
return dismissed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginDrag(clientY: number, timestamp: number) {
|
||||||
|
startY = lastY = clientY;
|
||||||
|
lastTime = timestamp;
|
||||||
|
startHeight = height.value ?? surface.value?.offsetHeight ?? naturalHeight();
|
||||||
|
velocity = 0;
|
||||||
|
moved = false;
|
||||||
|
dragging.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDrag(clientY: number, timestamp: number) {
|
||||||
|
const delta = clientY - startY;
|
||||||
|
moved ||= Math.abs(delta) > 3;
|
||||||
|
const minimum = usesBreakpoints.value
|
||||||
|
? availableHeight() * (normalizedBreakpoints.value[0] ?? 0.1) * 0.55
|
||||||
|
: naturalHeight() * 0.55;
|
||||||
|
height.value = Math.max(
|
||||||
|
Math.min(72, availableHeight()),
|
||||||
|
Math.min(availableHeight(), Math.max(minimum, startHeight - delta)),
|
||||||
|
);
|
||||||
|
const elapsed = Math.max(8, timestamp - lastTime);
|
||||||
|
velocity = ((clientY - lastY) * 1000) / elapsed / availableHeight();
|
||||||
|
lastY = clientY;
|
||||||
|
lastTime = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishDrag() {
|
||||||
|
dragging.value = false;
|
||||||
|
if (!moved) return measure();
|
||||||
|
|
||||||
|
const currentHeight = height.value ?? startHeight;
|
||||||
|
const currentFraction = currentHeight / availableHeight();
|
||||||
|
const points = normalizedBreakpoints.value;
|
||||||
|
const smallest = points[0];
|
||||||
|
|
||||||
|
if (!points.length) {
|
||||||
|
if (
|
||||||
|
props.dismissible &&
|
||||||
|
(currentHeight < naturalHeight() * 0.72 || velocity > 1.1)
|
||||||
|
)
|
||||||
|
return void (await dismissSheet());
|
||||||
|
return measure();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.dismissible &&
|
||||||
|
smallest !== undefined &&
|
||||||
|
currentFraction < smallest * 0.72
|
||||||
|
)
|
||||||
|
return void (await dismissSheet());
|
||||||
|
|
||||||
|
const active = activeBreakpoint.value ?? smallest!;
|
||||||
|
if (Math.abs(velocity) > 0.65) {
|
||||||
|
const direction = velocity < 0 ? "up" : "down";
|
||||||
|
const adjacent = adjacentSheetBreakpoint(points, active, direction);
|
||||||
|
if (adjacent !== undefined) return setBreakpoint(adjacent);
|
||||||
|
if (direction === "down" && props.dismissible)
|
||||||
|
return void (await dismissSheet());
|
||||||
|
}
|
||||||
|
|
||||||
|
setBreakpoint(nearestSheetBreakpoint(points, currentFraction) ?? active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDrag() {
|
||||||
|
dragging.value = false;
|
||||||
|
measure();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerDown(event: PointerEvent) {
|
||||||
|
if (!event.isPrimary || event.button !== 0 || dismissing.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
contentGestureOwner = "sheet";
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
beginDrag(event.clientY, event.timeStamp);
|
||||||
|
handle.value?.setPointerCapture?.(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerMove(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
updateDrag(event.clientY, event.timeStamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pointerUp(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
handle.value?.releasePointerCapture?.(pointerId);
|
||||||
|
pointerId = -1;
|
||||||
|
await finishDrag();
|
||||||
|
contentGestureOwner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerCancel(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
pointerId = -1;
|
||||||
|
cancelDrag();
|
||||||
|
contentGestureOwner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atTop() {
|
||||||
|
return (body.value?.scrollTop ?? 0) <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atBottom() {
|
||||||
|
const element = body.value;
|
||||||
|
if (!element) return true;
|
||||||
|
return element.scrollTop + element.clientHeight >= element.scrollHeight - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canResizeFromContent(direction: "up" | "down") {
|
||||||
|
const points = normalizedBreakpoints.value;
|
||||||
|
const active = activeBreakpoint.value;
|
||||||
|
if (direction === "up")
|
||||||
|
return Boolean(
|
||||||
|
atBottom() &&
|
||||||
|
active !== undefined &&
|
||||||
|
adjacentSheetBreakpoint(points, active, "up") !== undefined,
|
||||||
|
);
|
||||||
|
return Boolean(
|
||||||
|
atTop() &&
|
||||||
|
(props.dismissible ||
|
||||||
|
(active !== undefined &&
|
||||||
|
adjacentSheetBreakpoint(points, active, "down") !== undefined)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentGestureDirection(deltaY: number) {
|
||||||
|
return deltaY < 0 ? ("up" as const) : ("down" as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldClaimContentGesture(deltaX: number, deltaY: number) {
|
||||||
|
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
||||||
|
return false;
|
||||||
|
return canResizeFromContent(contentGestureDirection(deltaY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentPointerDown(event: PointerEvent) {
|
||||||
|
if (
|
||||||
|
event.pointerType === "touch" ||
|
||||||
|
!event.isPrimary ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
dismissing.value
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
contentGestureOwner = "pending";
|
||||||
|
candidateX = event.clientX;
|
||||||
|
candidateY = event.clientY;
|
||||||
|
candidateTime = event.timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentPointerMove(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
if (contentGestureOwner === "pending") {
|
||||||
|
const deltaX = event.clientX - candidateX;
|
||||||
|
const deltaY = event.clientY - candidateY;
|
||||||
|
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
||||||
|
return;
|
||||||
|
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
|
||||||
|
? "sheet"
|
||||||
|
: "content";
|
||||||
|
if (contentGestureOwner === "content") return;
|
||||||
|
beginDrag(candidateY, candidateTime);
|
||||||
|
body.value?.setPointerCapture?.(pointerId);
|
||||||
|
}
|
||||||
|
if (contentGestureOwner !== "sheet") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
updateDrag(event.clientY, event.timeStamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contentPointerUp(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
body.value?.releasePointerCapture?.(pointerId);
|
||||||
|
pointerId = -1;
|
||||||
|
if (dragging.value) await finishDrag();
|
||||||
|
contentGestureOwner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentPointerCancel(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
pointerId = -1;
|
||||||
|
if (dragging.value) cancelDrag();
|
||||||
|
contentGestureOwner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackedTouch(list: TouchList) {
|
||||||
|
return [...list].find((touch) => touch.identifier === touchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTouchStart(event: TouchEvent) {
|
||||||
|
if (touchId !== -1 || dismissing.value) return;
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
touchId = touch.identifier;
|
||||||
|
contentGestureOwner = "pending";
|
||||||
|
candidateX = touch.clientX;
|
||||||
|
candidateY = touch.clientY;
|
||||||
|
candidateTime = event.timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTouchMove(event: TouchEvent) {
|
||||||
|
const touch = trackedTouch(event.touches);
|
||||||
|
if (!touch) return;
|
||||||
|
if (contentGestureOwner === "pending") {
|
||||||
|
const deltaX = touch.clientX - candidateX;
|
||||||
|
const deltaY = touch.clientY - candidateY;
|
||||||
|
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
||||||
|
return;
|
||||||
|
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
|
||||||
|
? "sheet"
|
||||||
|
: "content";
|
||||||
|
if (contentGestureOwner === "content") return;
|
||||||
|
beginDrag(candidateY, candidateTime);
|
||||||
|
}
|
||||||
|
if (contentGestureOwner !== "sheet") return;
|
||||||
|
if (event.cancelable) event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
updateDrag(touch.clientY, event.timeStamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contentTouchEnd(event: TouchEvent) {
|
||||||
|
if (!trackedTouch(event.changedTouches)) return;
|
||||||
|
touchId = -1;
|
||||||
|
if (dragging.value) await finishDrag();
|
||||||
|
contentGestureOwner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTouchCancel(event: TouchEvent) {
|
||||||
|
if (!trackedTouch(event.changedTouches)) return;
|
||||||
|
touchId = -1;
|
||||||
|
if (dragging.value) cancelDrag();
|
||||||
|
contentGestureOwner = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWheelHandoff() {
|
||||||
|
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
||||||
|
wheelDistance = 0;
|
||||||
|
wheelDirection = undefined;
|
||||||
|
wheelOwner = undefined;
|
||||||
|
wheelLocked = false;
|
||||||
|
wheelResetTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentWheel(event: WheelEvent) {
|
||||||
|
const direction = event.deltaY > 0 ? ("up" as const) : ("down" as const);
|
||||||
|
if (!event.deltaY) return;
|
||||||
|
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
||||||
|
wheelResetTimer = setTimeout(resetWheelHandoff, 240);
|
||||||
|
|
||||||
|
if (!wheelOwner)
|
||||||
|
wheelOwner = canResizeFromContent(direction) ? "sheet" : "content";
|
||||||
|
if (wheelOwner === "content") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (wheelDirection !== direction) wheelDistance = 0;
|
||||||
|
wheelDirection = direction;
|
||||||
|
wheelDistance += Math.abs(event.deltaY);
|
||||||
|
if (!wheelLocked && wheelDistance >= 48) {
|
||||||
|
const active = activeBreakpoint.value;
|
||||||
|
const adjacent =
|
||||||
|
active === undefined
|
||||||
|
? undefined
|
||||||
|
: adjacentSheetBreakpoint(
|
||||||
|
normalizedBreakpoints.value,
|
||||||
|
active,
|
||||||
|
direction,
|
||||||
|
);
|
||||||
|
if (adjacent !== undefined) setBreakpoint(adjacent);
|
||||||
|
else if (direction === "down") void dismissSheet();
|
||||||
|
wheelLocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyDown(event: KeyboardEvent) {
|
||||||
|
const points = normalizedBreakpoints.value;
|
||||||
|
const current = activeBreakpoint.value;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
void dismissSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!points.length || current === undefined) return;
|
||||||
|
const direction =
|
||||||
|
event.key === "ArrowUp"
|
||||||
|
? "up"
|
||||||
|
: event.key === "ArrowDown"
|
||||||
|
? "down"
|
||||||
|
: undefined;
|
||||||
|
const target =
|
||||||
|
event.key === "Home"
|
||||||
|
? points[0]
|
||||||
|
: event.key === "End"
|
||||||
|
? points.at(-1)
|
||||||
|
: direction
|
||||||
|
? adjacentSheetBreakpoint(points, current, direction)
|
||||||
|
: undefined;
|
||||||
|
if (target !== undefined) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBreakpoint(target);
|
||||||
|
} else if (direction === "down" && props.dismissible) {
|
||||||
|
event.preventDefault();
|
||||||
|
void dismissSheet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function surfaceKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape" || event.target === handle.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void dismissSheet();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.initialBreakpoint, props.breakpoints] as const,
|
||||||
|
() => {
|
||||||
|
if (!dragging.value) void nextTick(measure);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
owningLayer = root.value?.closest<HTMLElement>(".nvr-view") ?? null;
|
||||||
|
if (owningLayer) owningLayer.dataset.nativeSheetSurface = "";
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!dragging.value) measure();
|
||||||
|
});
|
||||||
|
if (root.value) resizeObserver.observe(root.value);
|
||||||
|
if (content.value) resizeObserver.observe(content.value);
|
||||||
|
}
|
||||||
|
void nextTick(() => {
|
||||||
|
measure();
|
||||||
|
requestAnimationFrame(() => (ready.value = true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
||||||
|
if (owningLayer) delete owningLayer.dataset.nativeSheetSurface;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" v-bind="attrs" class="nvr-sheet">
|
||||||
|
<button
|
||||||
|
v-if="backdropDismiss && dismissible"
|
||||||
|
class="nvr-sheet__backdrop"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close sheet"
|
||||||
|
@click="dismissSheet"
|
||||||
|
/>
|
||||||
|
<div v-else class="nvr-sheet__backdrop" aria-hidden="true" />
|
||||||
|
<section
|
||||||
|
ref="surface"
|
||||||
|
class="nvr-sheet__surface"
|
||||||
|
:style="{ height: heightStyle }"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
data-native-sheet
|
||||||
|
:data-native-sheet-mode="usesBreakpoints ? 'breakpoints' : 'content'"
|
||||||
|
:data-native-sheet-breakpoint="breakpointLabel"
|
||||||
|
:data-native-sheet-dragging="String(dragging)"
|
||||||
|
:data-native-sheet-ready="String(ready)"
|
||||||
|
@keydown="surfaceKeyDown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showHandle"
|
||||||
|
ref="handle"
|
||||||
|
class="nvr-sheet__handle"
|
||||||
|
:role="usesBreakpoints ? 'slider' : undefined"
|
||||||
|
:tabindex="0"
|
||||||
|
aria-label="Resize or dismiss sheet"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
:aria-valuemin="usesBreakpoints ? normalizedBreakpoints[0] : undefined"
|
||||||
|
:aria-valuemax="
|
||||||
|
usesBreakpoints ? normalizedBreakpoints.at(-1) : undefined
|
||||||
|
"
|
||||||
|
:aria-valuenow="usesBreakpoints ? activeBreakpoint : undefined"
|
||||||
|
@pointerdown="pointerDown"
|
||||||
|
@pointermove="pointerMove"
|
||||||
|
@pointerup="pointerUp"
|
||||||
|
@pointercancel="pointerCancel"
|
||||||
|
@keydown="keyDown"
|
||||||
|
>
|
||||||
|
<slot name="handle">
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="body"
|
||||||
|
class="nvr-sheet__body"
|
||||||
|
@pointerdown="contentPointerDown"
|
||||||
|
@pointermove="contentPointerMove"
|
||||||
|
@pointerup="contentPointerUp"
|
||||||
|
@pointercancel="contentPointerCancel"
|
||||||
|
@touchstart="contentTouchStart"
|
||||||
|
@touchmove="contentTouchMove"
|
||||||
|
@touchend="contentTouchEnd"
|
||||||
|
@touchcancel="contentTouchCancel"
|
||||||
|
@wheel="contentWheel"
|
||||||
|
>
|
||||||
|
<div ref="content" class="nvr-sheet__content"><slot /></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,13 @@ export { default as NativeGestureLink } from "./NativeGestureLink.vue";
|
|||||||
export { default as NativeLink } from "./NativeLink.vue";
|
export { default as NativeLink } from "./NativeLink.vue";
|
||||||
export { default as NativeNavigator } from "./NativeNavigator.vue";
|
export { default as NativeNavigator } from "./NativeNavigator.vue";
|
||||||
export { default as NativeRouterView } from "./NativeRouterView.vue";
|
export { default as NativeRouterView } from "./NativeRouterView.vue";
|
||||||
|
export { default as NativeSheet } from "./NativeSheet.vue";
|
||||||
export { navigationOptionsFromElement } from "./gestures";
|
export { navigationOptionsFromElement } from "./gestures";
|
||||||
|
export {
|
||||||
|
adjacentSheetBreakpoint,
|
||||||
|
nearestSheetBreakpoint,
|
||||||
|
normalizeSheetBreakpoints,
|
||||||
|
} from "./sheet";
|
||||||
export {
|
export {
|
||||||
onNativeViewActivate,
|
onNativeViewActivate,
|
||||||
onNativeViewDeactivate,
|
onNativeViewDeactivate,
|
||||||
|
|||||||
28
packages/core/src/components/sheet.ts
Normal file
28
packages/core/src/components/sheet.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export function normalizeSheetBreakpoints(values: readonly number[]) {
|
||||||
|
return [...new Set(values.filter((value) => value > 0 && value <= 1))].sort(
|
||||||
|
(left, right) => left - right,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestSheetBreakpoint(
|
||||||
|
breakpoints: readonly number[],
|
||||||
|
value: number,
|
||||||
|
) {
|
||||||
|
return breakpoints.reduce<number | undefined>((nearest, candidate) => {
|
||||||
|
if (nearest === undefined) return candidate;
|
||||||
|
return Math.abs(candidate - value) < Math.abs(nearest - value)
|
||||||
|
? candidate
|
||||||
|
: nearest;
|
||||||
|
}, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjacentSheetBreakpoint(
|
||||||
|
breakpoints: readonly number[],
|
||||||
|
current: number,
|
||||||
|
direction: "up" | "down",
|
||||||
|
) {
|
||||||
|
const normalized = normalizeSheetBreakpoints(breakpoints);
|
||||||
|
if (direction === "up")
|
||||||
|
return normalized.find((value) => value > current + 0.001);
|
||||||
|
return [...normalized].reverse().find((value) => value < current - 0.001);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { createNativeNavigationProfiler } from "./profiler";
|
|||||||
|
|
||||||
const Page = defineComponent({ template: "<div>page</div>" });
|
const Page = defineComponent({ template: "<div>page</div>" });
|
||||||
|
|
||||||
async function harness(blockB: boolean | "redirect" = false) {
|
async function harness(blockB: boolean | "redirect" = false, maxInactive = 2) {
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -61,7 +61,7 @@ async function harness(blockB: boolean | "redirect" = false) {
|
|||||||
);
|
);
|
||||||
await router.push("/a");
|
await router.push("/a");
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
const native = createNativeRouter({ router, cache: { maxInactive: 2 } });
|
const native = createNativeRouter({ router, cache: { maxInactive } });
|
||||||
const app = createApp(Page);
|
const app = createApp(Page);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(native);
|
app.use(native);
|
||||||
@@ -433,8 +433,15 @@ describe("native router transactions", () => {
|
|||||||
it("dismisses with the presented route animation regardless of the route below it", async () => {
|
it("dismisses with the presented route animation regardless of the route below it", async () => {
|
||||||
const { native } = await harness();
|
const { native } = await harness();
|
||||||
await native.push("/b");
|
await native.push("/b");
|
||||||
|
const underlayKey = native.activeKey.value;
|
||||||
await native.present("/modal", "sheet");
|
await native.present("/modal", "sheet");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
native.entries.value.find(
|
||||||
|
(entry) => entry.key === native.activeKey.value,
|
||||||
|
),
|
||||||
|
).toMatchObject({ presentation: "sheet", underlayKey });
|
||||||
|
|
||||||
await native.beginInteractive("dismiss");
|
await native.beginInteractive("dismiss");
|
||||||
expect(native.transaction.value).toMatchObject({
|
expect(native.transaction.value).toMatchObject({
|
||||||
direction: "back",
|
direction: "back",
|
||||||
@@ -443,6 +450,19 @@ describe("native router transactions", () => {
|
|||||||
await native.cancelInteractive();
|
await native.cancelInteractive();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("protects a visible sheet underlay even when inactive caching is disabled", async () => {
|
||||||
|
const { native } = await harness(false, 0);
|
||||||
|
await native.push("/b");
|
||||||
|
const underlayKey = native.activeKey.value;
|
||||||
|
|
||||||
|
await native.present("/modal", "sheet");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
native.entries.value.find((entry) => entry.key === underlayKey),
|
||||||
|
).toMatchObject({ mounted: true, status: "inactive" });
|
||||||
|
await native.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to overlap a second transaction with an active gesture", async () => {
|
it("refuses to overlap a second transaction with an active gesture", async () => {
|
||||||
const { native } = await harness();
|
const { native } = await harness();
|
||||||
const first = await native.beginInteractive("push", "/b");
|
const first = await native.beginInteractive("push", "/b");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
isNavigationFailure,
|
isNavigationFailure,
|
||||||
loadRouteLocation,
|
loadRouteLocation,
|
||||||
|
routeLocationKey,
|
||||||
START_LOCATION,
|
START_LOCATION,
|
||||||
type NavigationFailure,
|
type NavigationFailure,
|
||||||
type RouteLocationNormalizedLoaded,
|
type RouteLocationNormalizedLoaded,
|
||||||
@@ -32,6 +33,7 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const nativeRouterKey = Symbol("native-vue-router");
|
export const nativeRouterKey = Symbol("native-vue-router");
|
||||||
|
const nativeScopedRouteProperty = "__nativeVueRouterScopedRoute";
|
||||||
|
|
||||||
let entrySequence = 0;
|
let entrySequence = 0;
|
||||||
|
|
||||||
@@ -55,6 +57,8 @@ function entryFor(
|
|||||||
return {
|
return {
|
||||||
key: `${route.fullPath}::${++entrySequence}`,
|
key: `${route.fullPath}::${++entrySequence}`,
|
||||||
route,
|
route,
|
||||||
|
presentation:
|
||||||
|
route.meta.native?.presentation ?? route.meta.native?.transition,
|
||||||
status,
|
status,
|
||||||
mounted: true,
|
mounted: true,
|
||||||
synthetic,
|
synthetic,
|
||||||
@@ -192,6 +196,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
install(app: App) {
|
install(app: App) {
|
||||||
app.provide(nativeRouterKey, this);
|
app.provide(nativeRouterKey, this);
|
||||||
app.config.globalProperties.$nativeRouter = this;
|
app.config.globalProperties.$nativeRouter = this;
|
||||||
|
// Vue Router's global `$route` getter always reads currentRoute. Preview
|
||||||
|
// trees instead provide their target through routeLocationKey, so bridge
|
||||||
|
// that injection into the Options API as well as useRoute().
|
||||||
|
app.mixin({
|
||||||
|
inject: {
|
||||||
|
[nativeScopedRouteProperty]: { from: routeLocationKey },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
$route() {
|
||||||
|
return (
|
||||||
|
this as unknown as Record<
|
||||||
|
typeof nativeScopedRouteProperty,
|
||||||
|
RouteLocationNormalizedLoaded
|
||||||
|
>
|
||||||
|
)[nativeScopedRouteProperty];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
void this.router.isReady().then(() => {
|
void this.router.isReady().then(() => {
|
||||||
if (
|
if (
|
||||||
this.mutableEntries.value.length === 0 &&
|
this.mutableEntries.value.length === 0 &&
|
||||||
@@ -379,6 +401,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
? this.siblingDirection(from.route, target.route)
|
? this.siblingDirection(from.route, target.route)
|
||||||
: "forward");
|
: "forward");
|
||||||
|
|
||||||
|
if (isBack) {
|
||||||
|
from.presentation ??= presentation;
|
||||||
|
} else {
|
||||||
|
target.presentation = presentation;
|
||||||
|
target.underlayKey = presentation === "sheet" ? from.key : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const transaction: NativeTransaction = {
|
const transaction: NativeTransaction = {
|
||||||
id: ++this.transactionSequence,
|
id: ++this.transactionSequence,
|
||||||
kind,
|
kind,
|
||||||
@@ -733,6 +762,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
to: RouteLocationNormalizedLoaded,
|
to: RouteLocationNormalizedLoaded,
|
||||||
_from: RouteLocationNormalizedLoaded,
|
_from: RouteLocationNormalizedLoaded,
|
||||||
) {
|
) {
|
||||||
|
const previousActiveKey = this.mutableActiveKey.value;
|
||||||
const transaction = this.mutableTransaction.value;
|
const transaction = this.mutableTransaction.value;
|
||||||
let target = transaction ? this.entryByKey(transaction.toKey) : undefined;
|
let target = transaction ? this.entryByKey(transaction.toKey) : undefined;
|
||||||
if (target && target.route.fullPath !== to.fullPath) target = undefined;
|
if (target && target.route.fullPath !== to.fullPath) target = undefined;
|
||||||
@@ -750,6 +780,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
this.mutableEntries.value = [...head, target];
|
this.mutableEntries.value = [...head, target];
|
||||||
} else {
|
} else {
|
||||||
target.route = to;
|
target.route = to;
|
||||||
|
target.presentation ??=
|
||||||
|
to.meta.native?.presentation ?? to.meta.native?.transition;
|
||||||
target.mounted = true;
|
target.mounted = true;
|
||||||
target.status = "active";
|
target.status = "active";
|
||||||
target.committed = true;
|
target.committed = true;
|
||||||
@@ -757,6 +789,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
target.evictionReason = undefined;
|
target.evictionReason = undefined;
|
||||||
this.touchEntries();
|
this.touchEntries();
|
||||||
}
|
}
|
||||||
|
if (target.presentation === "sheet" && target.key !== previousActiveKey)
|
||||||
|
target.underlayKey = previousActiveKey || undefined;
|
||||||
this.mutableActiveKey.value = target.key;
|
this.mutableActiveKey.value = target.key;
|
||||||
this.acceptHistory(target, transaction);
|
this.acceptHistory(target, transaction);
|
||||||
this.markStatuses();
|
this.markStatuses();
|
||||||
@@ -857,6 +891,16 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enforceCache() {
|
private enforceCache() {
|
||||||
|
const protectedUnderlayKeys = new Set<string>();
|
||||||
|
let presentedEntry = this.activeEntry();
|
||||||
|
while (
|
||||||
|
presentedEntry?.presentation === "sheet" &&
|
||||||
|
presentedEntry.underlayKey &&
|
||||||
|
!protectedUnderlayKeys.has(presentedEntry.underlayKey)
|
||||||
|
) {
|
||||||
|
protectedUnderlayKeys.add(presentedEntry.underlayKey);
|
||||||
|
presentedEntry = this.entryByKey(presentedEntry.underlayKey);
|
||||||
|
}
|
||||||
const inactive = this.mutableEntries.value
|
const inactive = this.mutableEntries.value
|
||||||
.filter(
|
.filter(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
@@ -866,6 +910,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
)
|
)
|
||||||
.sort((a, b) => b.lastUsed - a.lastUsed);
|
.sort((a, b) => b.lastUsed - a.lastUsed);
|
||||||
for (const entry of inactive) {
|
for (const entry of inactive) {
|
||||||
|
if (protectedUnderlayKeys.has(entry.key)) continue;
|
||||||
if (!this.shouldRetainInactive(entry)) {
|
if (!this.shouldRetainInactive(entry)) {
|
||||||
const reason: NativeEvictionReason =
|
const reason: NativeEvictionReason =
|
||||||
entry.route.meta.native?.cache === false
|
entry.route.meta.native?.cache === false
|
||||||
@@ -875,7 +920,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const retained = inactive.filter(
|
const retained = inactive.filter(
|
||||||
(entry) => entry.mounted && entry.route.meta.native?.cache !== "pin",
|
(entry) =>
|
||||||
|
entry.mounted &&
|
||||||
|
entry.route.meta.native?.cache !== "pin" &&
|
||||||
|
!protectedUnderlayKeys.has(entry.key),
|
||||||
);
|
);
|
||||||
for (const entry of retained.slice(this.maxInactive)) {
|
for (const entry of retained.slice(this.maxInactive)) {
|
||||||
this.evictEntry(entry, "cache-limit");
|
this.evictEntry(entry, "cache-limit");
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--nvr-duration: 360ms;
|
--nvr-duration: 360ms;
|
||||||
--nvr-scrim: rgba(0, 0, 0, 0.32);
|
--nvr-scrim: rgba(0, 0, 0, 0.32);
|
||||||
|
--nvr-safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--nvr-safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
--nvr-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--nvr-safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
--nvr-sheet-top-gap: 8px;
|
||||||
|
--nvr-sheet-radius: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -60,6 +66,18 @@ body,
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nvr-view--underlay {
|
||||||
|
z-index: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0.96);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-view--underlay::after {
|
||||||
|
opacity: 0.24;
|
||||||
|
}
|
||||||
|
|
||||||
.nvr-view--from,
|
.nvr-view--from,
|
||||||
.nvr-view--to {
|
.nvr-view--to {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
@@ -72,6 +90,17 @@ body,
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A committed sheet remains below the device status area. Retaining the
|
||||||
|
presentation on the entry keeps this geometry after the transaction ends. */
|
||||||
|
.nvr-view[data-native-view-presentation="sheet"] {
|
||||||
|
top: calc(var(--nvr-safe-top) + var(--nvr-sheet-top-gap));
|
||||||
|
border-radius: var(--nvr-sheet-radius) var(--nvr-sheet-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-view[data-native-view-presentation="sheet"][data-native-sheet-surface] {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
|
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"]
|
||||||
.nvr-view--from,
|
.nvr-view--from,
|
||||||
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
|
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"]
|
||||||
@@ -152,9 +181,16 @@ body,
|
|||||||
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
|
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
|
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
|
||||||
|
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="modal"] .nvr-view--to {
|
||||||
border-radius: calc((1 - var(--native-progress)) * 24px)
|
border-radius: calc((1 - var(--native-progress)) * 24px)
|
||||||
calc((1 - var(--native-progress)) * 24px) 0 0;
|
calc((1 - var(--native-progress)) * 24px) 0 0;
|
||||||
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
|
||||||
|
border-radius: var(--nvr-sheet-radius) var(--nvr-sheet-radius) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from,
|
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from,
|
||||||
@@ -186,6 +222,81 @@ body,
|
|||||||
touch-action: pan-x pinch-zoom;
|
touch-action: pan-x pinch-zoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nvr-sheet {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: var(--nvr-sheet-backdrop, var(--nvr-scrim));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__surface {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: min(72px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--nvr-sheet-radius) var(--nvr-sheet-radius) 0 0;
|
||||||
|
background: var(--nvr-sheet-background, var(--nvr-view-background, #fff));
|
||||||
|
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__surface[data-native-sheet-ready="true"] {
|
||||||
|
transition: height 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__surface[data-native-sheet-dragging="true"] {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__handle {
|
||||||
|
display: grid;
|
||||||
|
min-height: 32px;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
outline-offset: -3px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__handle > span {
|
||||||
|
width: 38px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__body {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-bottom: var(--nvr-safe-bottom);
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__content {
|
||||||
|
min-height: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
|
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"]
|
||||||
.nvr-view--from,
|
.nvr-view--from,
|
||||||
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
|
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
|
||||||
@@ -213,8 +324,51 @@ body,
|
|||||||
opacity: calc((1 - var(--native-progress)) * 0.24);
|
opacity: calc((1 - var(--native-progress)) * 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A NativeSheet animates its actual surface rather than translating its
|
||||||
|
transparent route-sized wrapper. This makes every sheet height enter from
|
||||||
|
the bottom immediately and keeps the backdrop continuous. */
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"]
|
||||||
|
.nvr-view--to[data-native-sheet-surface] {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"]
|
||||||
|
.nvr-view--to[data-native-sheet-surface]
|
||||||
|
.nvr-sheet__surface {
|
||||||
|
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"]
|
||||||
|
.nvr-view--to[data-native-sheet-surface]
|
||||||
|
.nvr-sheet__backdrop {
|
||||||
|
opacity: var(--native-progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
|
||||||
|
.nvr-view--from[data-native-sheet-surface] {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
|
||||||
|
.nvr-view--from[data-native-sheet-surface]
|
||||||
|
.nvr-sheet__surface {
|
||||||
|
transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"]
|
||||||
|
.nvr-view--from[data-native-sheet-surface]
|
||||||
|
.nvr-sheet__backdrop {
|
||||||
|
opacity: calc(1 - var(--native-progress));
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.nvr-view {
|
.nvr-view {
|
||||||
will-change: auto;
|
will-change: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nvr-sheet__surface {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type NativeGestureKind =
|
|||||||
"push" | "pop" | "sibling" | "present" | "dismiss";
|
"push" | "pop" | "sibling" | "present" | "dismiss";
|
||||||
export type NativeDirection = "forward" | "back" | "up" | "down";
|
export type NativeDirection = "forward" | "back" | "up" | "down";
|
||||||
export type NativeViewStatus = "active" | "inactive" | "preview" | "evicted";
|
export type NativeViewStatus = "active" | "inactive" | "preview" | "evicted";
|
||||||
export type NativeViewRole = "active" | "inactive" | "from" | "to";
|
export type NativeViewRole = "active" | "inactive" | "underlay" | "from" | "to";
|
||||||
export type NativeCachePolicy = boolean | "pin";
|
export type NativeCachePolicy = boolean | "pin";
|
||||||
export type NativeEvictionReason =
|
export type NativeEvictionReason =
|
||||||
| "cache-disabled"
|
| "cache-disabled"
|
||||||
@@ -76,6 +76,10 @@ declare module "vue-router" {
|
|||||||
export interface NativeViewEntry {
|
export interface NativeViewEntry {
|
||||||
key: string;
|
key: string;
|
||||||
route: RouteLocationNormalizedLoaded;
|
route: RouteLocationNormalizedLoaded;
|
||||||
|
/** Presentation retained after commit so persistent surfaces such as sheets keep their layout. */
|
||||||
|
presentation?: NativePresentationName;
|
||||||
|
/** View kept visually beneath a committed, non-full-screen presentation. */
|
||||||
|
underlayKey?: string;
|
||||||
status: NativeViewStatus;
|
status: NativeViewStatus;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
synthetic: boolean;
|
synthetic: boolean;
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
|
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
204
skills/integrate-native-vue-router/SKILL.md
Normal file
204
skills/integrate-native-vue-router/SKILL.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
name: integrate-native-vue-router
|
||||||
|
description: Integrate, migrate, configure, or debug Native Vue Router in new or existing Vue 3 applications. Use when adding @native-vue-router/core, converting a Vue Router app to native route surfaces, defining parent or sibling topology, adding gesture links and modal/sheet navigation, handling cached-view lifecycle, composing Vue built-ins with NativeRouterView, creating custom presentations, or wiring Capacitor and Electron adapters.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Integrate Native Vue Router
|
||||||
|
|
||||||
|
Implement Native Vue Router as a visual transaction layer around Vue Router.
|
||||||
|
Keep Vue Router authoritative for committed URLs, history, guards, redirects, and
|
||||||
|
route matching.
|
||||||
|
|
||||||
|
Read [references/integration-reference.md](references/integration-reference.md)
|
||||||
|
before changing a project. Treat it as the API baseline for version `0.1.x`. If
|
||||||
|
the installed package version or local source differs, inspect that version's
|
||||||
|
`package.json`, exported declarations, and source before editing.
|
||||||
|
|
||||||
|
## Choose the adoption path
|
||||||
|
|
||||||
|
Determine whether the target is:
|
||||||
|
|
||||||
|
- A new Vue application: establish the native route shell and topology while
|
||||||
|
creating the router.
|
||||||
|
- An existing Vue Router application: preserve its history mode, routes,
|
||||||
|
guards, redirects, nested outlets, state, and deep links while migrating the
|
||||||
|
visual root and selected navigation calls.
|
||||||
|
- A host integration: add the core first, then add Capacitor or Electron as an
|
||||||
|
adapter at the application boundary.
|
||||||
|
- A targeted enhancement: adopt imperative animated navigation first and defer
|
||||||
|
predictive gestures or sibling paging.
|
||||||
|
|
||||||
|
Do not widen the task into a framework upgrade without user authorization. If
|
||||||
|
the project is below Vue 3.5 or Vue Router 5, report the compatibility gap and
|
||||||
|
the exact upgrade it requires before changing dependencies.
|
||||||
|
|
||||||
|
## Inspect the target
|
||||||
|
|
||||||
|
Before editing, identify:
|
||||||
|
|
||||||
|
1. Package manager, workspace layout, Vue version, and Vue Router version.
|
||||||
|
2. The router creation file, history implementation, route records, guards,
|
||||||
|
redirects, scroll behavior, and lazy components.
|
||||||
|
3. The app entry and plugin installation order.
|
||||||
|
4. Every root and nested `RouterView`, plus wrappers such as `KeepAlive`,
|
||||||
|
`Transition`, and `Suspense`.
|
||||||
|
5. Calls to `router.push`, `router.replace`, `router.back`, `RouterLink`, tab
|
||||||
|
controls, modal routing, and bespoke swipe handlers.
|
||||||
|
6. Route-local side effects in setup/mount hooks, including analytics,
|
||||||
|
subscriptions, polling, media, and mutations.
|
||||||
|
7. Deep-linkable child routes, ordered peer routes, and routes whose local state
|
||||||
|
can or cannot be evicted.
|
||||||
|
8. Horizontal gesture owners such as carousels, maps, editors, and canvases.
|
||||||
|
9. Browser/PWA, Capacitor, Electron, SSR, and accessibility requirements.
|
||||||
|
|
||||||
|
Summarize the route topology before implementation when it is non-trivial.
|
||||||
|
|
||||||
|
## Install and bootstrap
|
||||||
|
|
||||||
|
Add `@native-vue-router/core` and its stylesheet. Add preset or platform packages
|
||||||
|
only when needed. Match the project's package manager and formatting style.
|
||||||
|
|
||||||
|
Create exactly one native runtime for one authoritative root route surface:
|
||||||
|
|
||||||
|
1. Create the Vue Router normally.
|
||||||
|
2. Pass that router to `createNativeRouter`.
|
||||||
|
3. Call `app.use(router)` before `app.use(nativeRouter)`.
|
||||||
|
4. Wait for `router.isReady()` when the application already does so or relies on
|
||||||
|
deterministic initial rendering.
|
||||||
|
5. Replace the root visual `RouterView` with `NativeRouterView`.
|
||||||
|
6. Wrap it in `NativeNavigator` only when predictive Back or sibling paging is
|
||||||
|
required.
|
||||||
|
|
||||||
|
Retain nested `RouterView` components inside route components. Do not add nested
|
||||||
|
independent native runtimes without an explicit ownership design.
|
||||||
|
|
||||||
|
## Model route topology
|
||||||
|
|
||||||
|
Add the smallest route metadata needed for the requested behavior:
|
||||||
|
|
||||||
|
- Use `presentation` for a route's default visual treatment.
|
||||||
|
- Add `parent` to directly addressable child or presented routes so a cold start
|
||||||
|
has a predictive Back destination.
|
||||||
|
- Add numeric `siblingOrder` to peer routes whose direction must be stable.
|
||||||
|
- Choose `siblingHistory: "replace"` for tabs and other peers that should not
|
||||||
|
grow Back history; use `"push"` only when Back should revisit peer selection.
|
||||||
|
- Use `cache: false` for routes that must be destroyed when inactive and
|
||||||
|
`cache: "pin"` only for deliberately retained views.
|
||||||
|
- Use `gesture: false` to disable navigator gesture handling for a route.
|
||||||
|
|
||||||
|
Preserve params, query, and hash in dynamic `parent` functions whenever they are
|
||||||
|
part of the logical parent location. Do not infer parentage from the mounted
|
||||||
|
cache or route registration order.
|
||||||
|
|
||||||
|
`navigator` and `siblingGroup` are reserved labels in `0.1.x`; do not claim that
|
||||||
|
they automatically create navigator ownership. Pass sibling locations to
|
||||||
|
`NativeNavigator` explicitly.
|
||||||
|
|
||||||
|
## Migrate navigation intentionally
|
||||||
|
|
||||||
|
Use native runtime methods for navigation that needs preview-driven motion:
|
||||||
|
|
||||||
|
- `push` or `NativeLink` for forward stack navigation.
|
||||||
|
- `replace` for no-growth replacement.
|
||||||
|
- `sibling` for ordered peers.
|
||||||
|
- `pop` for Back.
|
||||||
|
- `present` and `dismiss` for modal or sheet routes.
|
||||||
|
- `NativeSheet` for safe-area-contained, content-height, or snapping sheet
|
||||||
|
surfaces.
|
||||||
|
- `NativeGestureLink` for component-originated horizontal dragging.
|
||||||
|
- `NativeDismissGesture` for downward dismissal.
|
||||||
|
|
||||||
|
Leave raw Vue Router navigation in place when it is an intentional redirect,
|
||||||
|
non-animated control flow, or external integration. The runtime reconciles such
|
||||||
|
navigation, but it cannot preview it before commit.
|
||||||
|
|
||||||
|
Preserve standard link semantics. Prefer `NativeLink` when a true anchor is
|
||||||
|
needed. If using `NativeGestureLink`, select a semantic `as` element and retain
|
||||||
|
keyboard activation and accessible naming.
|
||||||
|
|
||||||
|
## Make preview mounts safe
|
||||||
|
|
||||||
|
Assume a destination can run setup and mount before its route guard approves the
|
||||||
|
navigation, and can then unmount without becoming current.
|
||||||
|
|
||||||
|
Move committed-screen side effects from unconditional setup/mount code to
|
||||||
|
`useNativeViewActiveEffect`, `onNativeViewActivate`, or explicit user actions.
|
||||||
|
Use `useNativeViewVisibleEffect` only for work needed while the route is active
|
||||||
|
or participating in a transition. Keep durable data in a store or persistence
|
||||||
|
layer because the bounded native cache may evict component instances.
|
||||||
|
|
||||||
|
Do not replace the native multi-route cache with a single Vue `KeepAlive`.
|
||||||
|
`KeepAlive`, `Transition`, `Teleport`, and `Suspense` may still be composed
|
||||||
|
inside the `NativeRouterView` slot. Gate teleported overlays on native view
|
||||||
|
visibility because teleported DOM is outside the inactive layer's `inert` and
|
||||||
|
`aria-hidden` boundary.
|
||||||
|
|
||||||
|
For partial sheets, use `NativeSheet` rather than styling a route component with
|
||||||
|
an arbitrary viewport height. Use no breakpoints for content height, or
|
||||||
|
fractional breakpoints for snap points. Keep `presentation: "sheet"` in route
|
||||||
|
metadata so direct entries retain safe-area and underlay behavior.
|
||||||
|
Preserve the built-in scroll body unless replacing its gesture arbitration:
|
||||||
|
normal content scrolling owns interior positions, while top/down and bottom/up
|
||||||
|
overscroll started at a boundary hands off to sheet resizing. Keep one owner for
|
||||||
|
the complete physical gesture; never reinterpret accumulated content-scroll
|
||||||
|
distance as sheet movement after an edge is reached or direction reverses.
|
||||||
|
|
||||||
|
Use scoped `useRoute()` or Options API `$route` inside route trees. During a
|
||||||
|
preview, do not substitute `router.currentRoute`: it intentionally remains the
|
||||||
|
committed source location until navigation succeeds.
|
||||||
|
|
||||||
|
## Add gesture ownership safely
|
||||||
|
|
||||||
|
Wrap only the intended route surface in `NativeNavigator`. Give the container a
|
||||||
|
definite height and import core CSS.
|
||||||
|
|
||||||
|
Mark nested horizontal interaction regions with
|
||||||
|
`data-native-gesture="ignore"`. Confirm vertical scrolling, controls, text
|
||||||
|
selection, RTL behavior, reduced motion, keyboard navigation, focus, and screen
|
||||||
|
reader isolation after adding gestures.
|
||||||
|
|
||||||
|
Do not promise deterministic browser-edge ownership. Recommend Capacitor when a
|
||||||
|
product requires native-level suppression of host Back gestures.
|
||||||
|
|
||||||
|
## Add host adapters at the boundary
|
||||||
|
|
||||||
|
For Capacitor, install the adapter in `createNativeRouter` and configure hardware
|
||||||
|
Back, haptics, deep-link mapping, background cache trimming, and root exit. Do
|
||||||
|
not assume Universal Links or App Links are configured by JavaScript alone.
|
||||||
|
|
||||||
|
For Electron, call `disableElectronHistoryGestures(app.commandLine)` before
|
||||||
|
`app.whenReady()` in the main process. Expose only the renderer callbacks needed
|
||||||
|
by `createElectronRendererAdapter` through a secure preload. Preserve hash or
|
||||||
|
custom-protocol history behavior used by packaged applications.
|
||||||
|
|
||||||
|
Keep host detection and host APIs out of route components unless the product
|
||||||
|
experience genuinely differs.
|
||||||
|
|
||||||
|
## Verify the integration
|
||||||
|
|
||||||
|
Run the target project's formatter, type checker, unit tests, build, and relevant
|
||||||
|
end-to-end tests. Add tests or manual verification for the changed behavior:
|
||||||
|
|
||||||
|
- Direct entry and reload on deep child URLs.
|
||||||
|
- Params, query, hash, redirects, and nested route injection.
|
||||||
|
- Both routes remain live during a held gesture while the URL stays unchanged.
|
||||||
|
- Gesture cancellation preserves the source URL and state.
|
||||||
|
- Accepted and rejected guards settle correctly.
|
||||||
|
- Browser Back/Forward and cold-start parent Back.
|
||||||
|
- Sibling direction and replace/push history behavior.
|
||||||
|
- Cached state, eviction, unload, and active/visible effects.
|
||||||
|
- Vue built-ins, provide/inject, Options API `$route`, and lifecycle hooks used
|
||||||
|
by the application.
|
||||||
|
- Teleported overlay cleanup and inactive-route focus isolation.
|
||||||
|
- Gesture conflicts, RTL, reduced motion, keyboard behavior, and target hosts.
|
||||||
|
|
||||||
|
Use `runtime.entries`, `activeKey`, `transaction`, `canGoBack`, `cacheStats`, and
|
||||||
|
`onDiagnostic` for focused diagnostics. Use the profiler only when measuring
|
||||||
|
frame behavior; it is opt-in and should be stopped/disposed after capture.
|
||||||
|
|
||||||
|
## Report the result
|
||||||
|
|
||||||
|
State which routes and navigation paths became native-aware, which raw Vue Router
|
||||||
|
paths remain intentionally unchanged, what lifecycle work moved, and what was
|
||||||
|
verified. Call out unresolved host limitations, missing parent topology, or
|
||||||
|
dependency incompatibility explicitly.
|
||||||
4
skills/integrate-native-vue-router/agents/openai.yaml
Normal file
4
skills/integrate-native-vue-router/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Integrate Native Vue Router"
|
||||||
|
short_description: "Add native navigation to Vue Router apps"
|
||||||
|
default_prompt: "Use $integrate-native-vue-router to integrate Native Vue Router into this Vue project."
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# Native Vue Router integration reference
|
||||||
|
|
||||||
|
Use this reference for `@native-vue-router/*` version `0.1.x`. Inspect installed
|
||||||
|
declarations or local source when integrating another version.
|
||||||
|
|
||||||
|
## Support and ownership
|
||||||
|
|
||||||
|
- Requires Vue `^3.5.0` and Vue Router `^5.0.0`.
|
||||||
|
- Targets client-side DOM navigation. SSR/live-stack hydration is incomplete.
|
||||||
|
- Vue Router owns committed routes, URLs, history, guards, redirects, and lazy
|
||||||
|
route matching.
|
||||||
|
- Native Vue Router owns preview surfaces, mounted-view caching, gesture
|
||||||
|
progress, presentations, and transaction state.
|
||||||
|
- Preview trees receive scoped Vue Router route injection. `useRoute()` and
|
||||||
|
Options API `$route` identify that surface; `router.currentRoute` identifies
|
||||||
|
the globally committed route.
|
||||||
|
- The URL does not change during a forward preview. Vue Router navigation and
|
||||||
|
guards run when the gesture or imperative transaction commits.
|
||||||
|
|
||||||
|
## Packages and styles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install vue@^3.5 vue-router@^5 @native-vue-router/core
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @native-vue-router/preset-native
|
||||||
|
npm install @native-vue-router/capacitor @capacitor/app @capacitor/core @capacitor/haptics
|
||||||
|
npm install @native-vue-router/electron
|
||||||
|
```
|
||||||
|
|
||||||
|
Import styles explicitly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import "@native-vue-router/core/style.css";
|
||||||
|
import "@native-vue-router/preset-native/style.css"; // when used
|
||||||
|
```
|
||||||
|
|
||||||
|
Give the route shell a definite height. The core `.nvr-navigator` and
|
||||||
|
`.nvr-router-view` elements use `height: 100%`.
|
||||||
|
|
||||||
|
## Bootstrap template
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { createNativeRouter } from "@native-vue-router/core";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "@native-vue-router/core/style.css";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nativeRouter = createNativeRouter({
|
||||||
|
router,
|
||||||
|
cache: { maxInactive: 4 },
|
||||||
|
// edgeWidth: 28,
|
||||||
|
// platform,
|
||||||
|
// presentations: [customPresentation],
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(router);
|
||||||
|
app.use(nativeRouter);
|
||||||
|
await router.isReady();
|
||||||
|
app.mount("#app");
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NativeNavigator, NativeRouterView } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const siblings = ["/inbox", "/stories", "/profile"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeNavigator :siblings="siblings" :edge-width="28">
|
||||||
|
<NativeRouterView />
|
||||||
|
</NativeNavigator>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Vue Router before the native plugin. Retain ordinary nested
|
||||||
|
`RouterView`s within route components.
|
||||||
|
|
||||||
|
## Route metadata
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface NativeRouteOptions {
|
||||||
|
navigator?: string;
|
||||||
|
presentation?:
|
||||||
|
"push" | "reveal" | "slide" | "fade" | "modal" | "sheet" | "none" | string;
|
||||||
|
transition?: string;
|
||||||
|
parent?: RouteLocationRaw | ((route) => RouteLocationRaw);
|
||||||
|
siblingGroup?: string;
|
||||||
|
siblingOrder?: number;
|
||||||
|
siblingHistory?: "push" | "replace";
|
||||||
|
cache?: boolean | "pin";
|
||||||
|
gesture?: boolean | "edge" | "full";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefer `presentation`; `transition` is a compatibility alias.
|
||||||
|
- `parent` supplies a synthetic predictive Back destination if no warm native
|
||||||
|
history predecessor exists.
|
||||||
|
- `siblingOrder` determines direction for programmatic sibling navigation.
|
||||||
|
- `siblingHistory: "replace"` is the usual tab behavior.
|
||||||
|
- `cache: false` prevents inactive retention. `cache: "pin"` avoids ordinary LRU
|
||||||
|
trimming and is excluded from default `trimCache()`.
|
||||||
|
- `gesture: false` is enforced by `NativeNavigator`. In `0.1.x`, finer
|
||||||
|
`edge`/`full` policy is mainly structural.
|
||||||
|
- `navigator` and `siblingGroup` are reserved; pass siblings to the navigator
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
Example with dynamic parent state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
path: "/chat/:id/details",
|
||||||
|
name: "chat-details",
|
||||||
|
component: () => import("./ChatDetailsView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
presentation: "push",
|
||||||
|
parent: (route) => ({
|
||||||
|
name: "chat",
|
||||||
|
params: { id: route.params.id },
|
||||||
|
query: route.query,
|
||||||
|
hash: route.hash,
|
||||||
|
}),
|
||||||
|
gesture: "edge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core components
|
||||||
|
|
||||||
|
### `NativeRouterView`
|
||||||
|
|
||||||
|
Renders every mounted native entry as a sibling layer. Its default slot exposes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
Component: Component | undefined;
|
||||||
|
route: RouteLocationNormalizedLoaded;
|
||||||
|
entry: NativeViewEntry;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without a slot, it renders the matched route component. Inactive layers are
|
||||||
|
hidden, `inert`, and `aria-hidden`.
|
||||||
|
|
||||||
|
### `NativeNavigator`
|
||||||
|
|
||||||
|
Props:
|
||||||
|
|
||||||
|
- `siblings: RouteLocationRaw[]` (default `[]`)
|
||||||
|
- `edgeWidth: number` (default `28`)
|
||||||
|
|
||||||
|
Owns leading-edge predictive Back and optional full-surface paging between the
|
||||||
|
listed siblings. The list is visual order. Route `siblingOrder` also makes
|
||||||
|
programmatic direction deterministic.
|
||||||
|
|
||||||
|
### `NativeLink`
|
||||||
|
|
||||||
|
Props:
|
||||||
|
|
||||||
|
- `to: RouteLocationRaw` (required)
|
||||||
|
- `replace: boolean`
|
||||||
|
- `presentation: NativePresentationName`
|
||||||
|
|
||||||
|
Renders an anchor with a resolved `href`. Primary unmodified clicks use native
|
||||||
|
`push` or `replace`; other attributes pass through.
|
||||||
|
|
||||||
|
### `NativeGestureLink`
|
||||||
|
|
||||||
|
Props:
|
||||||
|
|
||||||
|
- `to: RouteLocationRaw` (required)
|
||||||
|
- `presentation` (default `"reveal"`)
|
||||||
|
- `replace: boolean`
|
||||||
|
- `direction: "left" | "right" | "any"` (default `"any"`)
|
||||||
|
- `as: string` (default `"div"`)
|
||||||
|
|
||||||
|
Owns horizontal component-originated navigation. A click uses native `push`.
|
||||||
|
Choose semantic markup and add accessible keyboard behavior as needed.
|
||||||
|
|
||||||
|
### `NativeDismissGesture`
|
||||||
|
|
||||||
|
Prop `as` defaults to `div`. Owns downward vertical dismissal for a modal or
|
||||||
|
sheet surface.
|
||||||
|
|
||||||
|
### `NativeSheet`
|
||||||
|
|
||||||
|
Use inside a route with `meta.native.presentation: "sheet"`. The sheet route is
|
||||||
|
contained below `--nvr-safe-top` plus `--nvr-sheet-top-gap`, and its previous
|
||||||
|
route remains visible but inert as an underlay.
|
||||||
|
|
||||||
|
Props:
|
||||||
|
|
||||||
|
- `breakpoints: number[]` (default `[]`): normalized fractions in `(0, 1]`.
|
||||||
|
Empty means content height capped at available height.
|
||||||
|
- `initialBreakpoint?: number`: starts at the nearest declared fraction.
|
||||||
|
- `modelValue?: number`: current fraction for `v-model`.
|
||||||
|
- `dismissible: boolean` (default `true`).
|
||||||
|
- `backdropDismiss: boolean` (default `true`).
|
||||||
|
- `showHandle: boolean` (default `true`).
|
||||||
|
- `ariaLabel: string` (default `"Sheet"`).
|
||||||
|
|
||||||
|
Events are `update:modelValue`, `breakpoint-change`, and `dismiss`. The named
|
||||||
|
`handle` slot replaces only the handle visual. The built-in handle supports
|
||||||
|
pointer dragging, Escape, Arrow Up/Down, Home, and End.
|
||||||
|
|
||||||
|
The body keeps native scrolling while it has content in the requested
|
||||||
|
direction. At the top, downward overscroll shrinks or dismisses the sheet. At
|
||||||
|
the bottom, upward overscroll expands it. The handoff supports touch,
|
||||||
|
mouse/pen dragging, and thresholded wheel/trackpad input. A snapping
|
||||||
|
`NativeSheet` animates its own measured surface, while the route-sized wrapper
|
||||||
|
stays fixed and the previous route retains its underlay scale across commit.
|
||||||
|
|
||||||
|
Ownership locks after the gesture's initial directional intent. If content owns
|
||||||
|
the gesture, reaching an edge or reversing does not transfer that same gesture
|
||||||
|
to the sheet; release and start at the edge to resize. Never implement a
|
||||||
|
mid-gesture reinterpretation using distance accumulated while content was
|
||||||
|
scrolling.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { NativeSheet } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const point = ref(0.55);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeSheet
|
||||||
|
v-model="point"
|
||||||
|
:breakpoints="[0.3, 0.55, 1]"
|
||||||
|
:initial-breakpoint="0.55"
|
||||||
|
aria-label="Filters"
|
||||||
|
>
|
||||||
|
<FilterForm />
|
||||||
|
</NativeSheet>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Theme with `--nvr-sheet-background`, `--nvr-sheet-backdrop`,
|
||||||
|
`--nvr-sheet-radius`, and `--nvr-sheet-top-gap`. Prefer `NativeSheet` to
|
||||||
|
`NativeDismissGesture` for partial or snapping route sheets.
|
||||||
|
|
||||||
|
### Gesture exclusions
|
||||||
|
|
||||||
|
The recognizers ignore ordinary form/editable controls and elements within:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div data-native-gesture="ignore">...</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this for maps, carousels, canvases, code editors, and custom horizontal
|
||||||
|
controls.
|
||||||
|
|
||||||
|
## Runtime API
|
||||||
|
|
||||||
|
Create/inject:
|
||||||
|
|
||||||
|
- `createNativeRouter(options): NativeRouterRuntime`
|
||||||
|
- `useNativeRouter(): NativeRouterRuntime`
|
||||||
|
- Options API: `this.$nativeRouter`
|
||||||
|
|
||||||
|
Reactive readonly state:
|
||||||
|
|
||||||
|
- `router`
|
||||||
|
- `entries`
|
||||||
|
- `activeKey`
|
||||||
|
- `transaction`
|
||||||
|
- `canGoBack`
|
||||||
|
- `cacheStats`
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
push(to, options?): Promise<boolean>
|
||||||
|
replace(to, options?): Promise<boolean>
|
||||||
|
sibling(to, options?): Promise<boolean>
|
||||||
|
pop(): Promise<boolean>
|
||||||
|
present(to, presentation = "modal"): Promise<boolean>
|
||||||
|
dismiss(): Promise<boolean>
|
||||||
|
preload(to): Promise<RouteLocationNormalizedLoaded>
|
||||||
|
```
|
||||||
|
|
||||||
|
`NativeNavigationOptions` supports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
presentation?: NativePresentationName;
|
||||||
|
replace?: boolean;
|
||||||
|
direction?: "forward" | "back" | "up" | "down";
|
||||||
|
sourceRect?: {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Interactive driver:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
beginInteractive(kind, to?, options?): Promise<number | null>
|
||||||
|
updateInteractive(progress, velocity?): void
|
||||||
|
finishInteractive(forceCommit?): Promise<boolean>
|
||||||
|
cancelInteractive(): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
Kinds are `push`, `pop`, `sibling`, `present`, and `dismiss`. Progress is clamped
|
||||||
|
to `0...1`. Velocity is normalized route progress per second. The default commit
|
||||||
|
rule is progress at least `0.36`, or progress at least `0.08` with velocity at
|
||||||
|
least `1.1`.
|
||||||
|
|
||||||
|
Cache/diagnostics/extension:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
unload(to): number
|
||||||
|
trimCache({ includePinned?, reason? }?): void
|
||||||
|
onDiagnostic(listener): () => void
|
||||||
|
registerPresentation(definition): void
|
||||||
|
presentationFor(name): NativePresentationDefinition | undefined
|
||||||
|
dispose(): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `dispose()` if the owning application lifecycle tears the runtime down
|
||||||
|
without a page unload.
|
||||||
|
|
||||||
|
## Native view lifecycle
|
||||||
|
|
||||||
|
Available APIs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useNativeViewLifecycle();
|
||||||
|
onNativeViewActivate(hook);
|
||||||
|
onNativeViewDeactivate(hook);
|
||||||
|
onNativeViewShow(hook);
|
||||||
|
onNativeViewHide(hook);
|
||||||
|
onNativeViewEvict(hook);
|
||||||
|
useNativeViewActiveEffect(effect);
|
||||||
|
useNativeViewVisibleEffect(effect);
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle state:
|
||||||
|
|
||||||
|
- `route`: scoped route for the view.
|
||||||
|
- `status`: `active`, `inactive`, `preview`, or `evicted`.
|
||||||
|
- `role`: `active`, `inactive`, `underlay`, `from`, or `to`.
|
||||||
|
- `isActive`: authoritative committed route.
|
||||||
|
- `isVisible`: active, one side of a live transition, or a visible sheet
|
||||||
|
underlay. Underlays remain inert.
|
||||||
|
- `isPreview`: destination not yet committed.
|
||||||
|
- `isCached`: mounted inactive route.
|
||||||
|
- `evictionReason`: `cache-disabled`, `cache-limit`,
|
||||||
|
`navigation-rejected`, `popped`, `manual`, `trimmed`, or `memory-pressure`.
|
||||||
|
|
||||||
|
Effects may return cleanup functions. Active effects suit polling,
|
||||||
|
subscriptions, media, and committed-screen analytics. Visible effects suit work
|
||||||
|
needed while the surface participates in a transition.
|
||||||
|
|
||||||
|
Do not treat Vue component mount as route commitment. A preview can mount before
|
||||||
|
guards run and unmount after rejection or gesture cancellation.
|
||||||
|
|
||||||
|
## Vue compatibility
|
||||||
|
|
||||||
|
Within a native route surface:
|
||||||
|
|
||||||
|
- Vue Composition and Options API lifecycle hooks retain normal component
|
||||||
|
semantics.
|
||||||
|
- App/plugin/ancestor `provide` and `inject` work normally.
|
||||||
|
- `useRoute`, `useRouter`, and Options API `$route` are supported.
|
||||||
|
- Params, query, hash, metadata, and matched records are scoped to each preview
|
||||||
|
or committed surface.
|
||||||
|
- Nested `RouterView` is supported.
|
||||||
|
- `KeepAlive`, `Transition`, `Teleport`, and `Suspense` may be used inside the
|
||||||
|
`NativeRouterView` slot.
|
||||||
|
|
||||||
|
Important distinctions:
|
||||||
|
|
||||||
|
- Native route caching is separate from `KeepAlive`. A cached native route stays
|
||||||
|
mounted, so native inactivity alone does not trigger Vue `onDeactivated`.
|
||||||
|
- Native presentations animate route layers; a Vue `Transition` handles changes
|
||||||
|
within one layer.
|
||||||
|
- Teleported DOM escapes the route layer's `inert`/`aria-hidden` isolation. Gate
|
||||||
|
it with `isVisible` and close it when the owning view hides where appropriate.
|
||||||
|
- Async route components are loaded before preview. A `Suspense` inside the route
|
||||||
|
can still show fallback UI for async descendants.
|
||||||
|
|
||||||
|
Slot composition example:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NativeRouterView v-slot="{ Component, route }">
|
||||||
|
<Suspense>
|
||||||
|
<Transition name="content" mode="out-in">
|
||||||
|
<KeepAlive :max="3">
|
||||||
|
<component :is="Component" :key="route.fullPath" />
|
||||||
|
</KeepAlive>
|
||||||
|
</Transition>
|
||||||
|
<template #fallback><RouteSkeleton /></template>
|
||||||
|
</Suspense>
|
||||||
|
</NativeRouterView>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom presentation
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePresentation } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const scaleFade = definePresentation({
|
||||||
|
name: "scale-fade",
|
||||||
|
axis: "x",
|
||||||
|
layerStyle({ role, progress, direction, sourceRect }) {
|
||||||
|
return role === "to"
|
||||||
|
? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
|
||||||
|
: { opacity: 1 - progress * 0.25 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Register through `createNativeRouter({ presentations: [...] })` or
|
||||||
|
`runtime.registerPresentation()`. Keep layer styles compositor-friendly and do
|
||||||
|
not perform application or history mutations in a style function.
|
||||||
|
|
||||||
|
## Preset-native API
|
||||||
|
|
||||||
|
- `NativeBackButton` prop: `label` (default `"Back"`). Calls `native.pop()`.
|
||||||
|
- `NativeTabBar` prop: required `items: NativeTabItem[]`. It uses replace-style
|
||||||
|
native sibling navigation.
|
||||||
|
- `NativeTabItem`: `{ label, to, icon?, activeWhen? }`.
|
||||||
|
- `detectNativePlatform()`: `"ios" | "android" | "desktop"`.
|
||||||
|
- `nativeMotionTokens`: edge widths and commit thresholds for those three
|
||||||
|
platform labels. Core physics are not route-configurable from these tokens in
|
||||||
|
`0.1.x`.
|
||||||
|
|
||||||
|
## Capacitor adapter
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createCapacitorAdapter({
|
||||||
|
exitAtRoot?: boolean; // default true behavior
|
||||||
|
haptics?: boolean; // default enabled on native
|
||||||
|
trimCacheOnPause?: boolean; // default true
|
||||||
|
deepLinkPath?: (url: URL) => string;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
It handles Android hardware Back, App URL open/launch URLs, interactive
|
||||||
|
cancellation on pause, optional inactive-cache trimming, root exit, and haptics.
|
||||||
|
Native link association/configuration remains an application responsibility.
|
||||||
|
|
||||||
|
## Electron adapter
|
||||||
|
|
||||||
|
Main process, before `app.whenReady()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
disableElectronHistoryGestures(app.commandLine);
|
||||||
|
```
|
||||||
|
|
||||||
|
Renderer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createNativeRouter({
|
||||||
|
router,
|
||||||
|
platform: createElectronRendererAdapter(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The preload may expose:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
window.nativeVueHost = {
|
||||||
|
onBack(callback): () => void,
|
||||||
|
onForward?(callback): () => void,
|
||||||
|
onMemoryPressure?(callback): () => void,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a secure context bridge; do not enable Node integration merely for the
|
||||||
|
adapter. Packaged `file:` apps commonly require hash history.
|
||||||
|
|
||||||
|
## Verification matrix
|
||||||
|
|
||||||
|
At minimum verify:
|
||||||
|
|
||||||
|
| Area | Checks |
|
||||||
|
| ----------- | --------------------------------------------------------------------------------- |
|
||||||
|
| Boot | Initial route renders after `router.isReady`; CSS and height are correct |
|
||||||
|
| Route state | Params, query, hash, `useRoute`, `$route`, nested routes, provide/inject |
|
||||||
|
| Preview | Source and destination coexist; URL stays on source; cancellation removes preview |
|
||||||
|
| Guards | Acceptance, rejection, and redirect reconcile to Vue Router's result |
|
||||||
|
| Back | Warm Back, browser Back/Forward, direct-entry parent, no unrelated cached target |
|
||||||
|
| Siblings | Direction follows order; chosen push/replace history behavior is correct |
|
||||||
|
| Cache | Local warm state, `cache: false`, pin, LRU, unload, trim, eviction cleanup |
|
||||||
|
| Lifecycle | Active/visible effects stop and resume at the correct boundaries |
|
||||||
|
| Vue | KeepAlive, Transition, Teleport, Suspense, component hook ordering |
|
||||||
|
| Input | Edge Back, component drag, vertical scroll, ignored regions, rapid interruption |
|
||||||
|
| A11y | Inactive focus isolation, semantic controls, keyboard access, reduced motion |
|
||||||
|
| Hosts | PWA limits, Electron commands/history, Capacitor Back/deep links/pause |
|
||||||
|
|
||||||
|
## Known boundaries
|
||||||
|
|
||||||
|
- One runtime coordinates one authoritative visual transaction at a time.
|
||||||
|
- Preview setup can run before navigation approval.
|
||||||
|
- The mounted cache is bounded; route-local state can be lost after eviction.
|
||||||
|
- Cold-start predictive Back requires explicit parent topology.
|
||||||
|
- Browser content cannot guarantee ownership of operating-system/browser edge
|
||||||
|
gestures.
|
||||||
|
- Independent nested navigation controllers and SSR stack hydration need an
|
||||||
|
explicit design beyond the current `0.1.x` implementation.
|
||||||
809
usage.md
Normal file
809
usage.md
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
# Native Vue Router usage guide
|
||||||
|
|
||||||
|
Native Vue Router adds gesture-driven, interruptible navigation to Vue 3 while
|
||||||
|
leaving Vue Router responsible for route matching, URLs, redirects, guards, and
|
||||||
|
browser history. It renders a provisional destination beside the current route,
|
||||||
|
lets a pointer gesture control the transition, and commits the Vue Router
|
||||||
|
navigation only when the gesture completes.
|
||||||
|
|
||||||
|
The current support contract is Vue 3.5+, Vue Router 5, and client-side DOM
|
||||||
|
rendering. SSR hydration of a live native view stack is not yet a complete
|
||||||
|
feature.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `@native-vue-router/core` | Runtime, route surfaces, gestures, caching, lifecycle APIs, and profiler |
|
||||||
|
| `@native-vue-router/preset-native` | Native-looking back and tab controls, safe-area CSS, and motion tokens |
|
||||||
|
| `@native-vue-router/capacitor` | Hardware Back, deep links, app lifecycle, root exit, and haptics |
|
||||||
|
| `@native-vue-router/electron` | Host back/forward integration and Chromium history-gesture suppression |
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install the core package alongside its peer dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install vue@^3.5 vue-router@^5 @native-vue-router/core
|
||||||
|
```
|
||||||
|
|
||||||
|
Add optional packages only when the application uses them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @native-vue-router/preset-native
|
||||||
|
npm install @native-vue-router/capacitor @capacitor/app @capacitor/core @capacitor/haptics
|
||||||
|
npm install @native-vue-router/electron
|
||||||
|
```
|
||||||
|
|
||||||
|
When consuming this repository directly, build the packages first and install
|
||||||
|
the required package directories or packed tarballs into the target project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:packages
|
||||||
|
npm pack --workspace @native-vue-router/core
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the core stylesheet once from the application entry point. Import the
|
||||||
|
preset stylesheet as well when using its controls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import "@native-vue-router/core/style.css";
|
||||||
|
import "@native-vue-router/preset-native/style.css"; // optional
|
||||||
|
```
|
||||||
|
|
||||||
|
The elements containing the navigator must have a definite height. A typical
|
||||||
|
full-screen application uses:
|
||||||
|
|
||||||
|
```css
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal setup
|
||||||
|
|
||||||
|
Create the Vue Router first, then create and install the native runtime. Install
|
||||||
|
Vue Router before Native Vue Router so route injection and Options API `$route`
|
||||||
|
scoping are configured correctly.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/main.ts
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { createNativeRouter } from "@native-vue-router/core";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import HomeView from "./views/HomeView.vue";
|
||||||
|
import ProductView from "./views/ProductView.vue";
|
||||||
|
import "@native-vue-router/core/style.css";
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", name: "home", component: HomeView },
|
||||||
|
{
|
||||||
|
path: "/products/:id",
|
||||||
|
name: "product",
|
||||||
|
component: ProductView,
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
presentation: "push",
|
||||||
|
parent: "/",
|
||||||
|
gesture: "edge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nativeRouter = createNativeRouter({
|
||||||
|
router,
|
||||||
|
cache: { maxInactive: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(router);
|
||||||
|
app.use(nativeRouter);
|
||||||
|
|
||||||
|
await router.isReady();
|
||||||
|
app.mount("#app");
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the application-level `<RouterView>` with `<NativeRouterView>` and wrap
|
||||||
|
the navigation surface in `<NativeNavigator>` to enable predictive Back.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- src/App.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NativeNavigator, NativeRouterView } from "@native-vue-router/core";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeNavigator>
|
||||||
|
<NativeRouterView />
|
||||||
|
</NativeNavigator>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Nested `<RouterView>` components inside route components continue to work. Use
|
||||||
|
one application-level `NativeRouterView` for one native runtime; independent
|
||||||
|
nested native navigators are not currently a complete feature.
|
||||||
|
|
||||||
|
## Route metadata
|
||||||
|
|
||||||
|
Declare presentation, topology, history, cache, and gesture policy next to each
|
||||||
|
route:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/inbox",
|
||||||
|
name: "inbox",
|
||||||
|
component: () => import("./views/InboxView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
siblingGroup: "primary",
|
||||||
|
siblingOrder: 0,
|
||||||
|
siblingHistory: "replace",
|
||||||
|
cache: "pin",
|
||||||
|
gesture: "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/chat/:id",
|
||||||
|
name: "chat",
|
||||||
|
component: () => import("./views/ChatView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
presentation: "push",
|
||||||
|
parent: "/inbox",
|
||||||
|
gesture: "edge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/chat/:id/details",
|
||||||
|
name: "chat-details",
|
||||||
|
component: () => import("./views/ChatDetailsView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
parent: (route) => ({
|
||||||
|
name: "chat",
|
||||||
|
params: { id: route.params.id },
|
||||||
|
query: route.query,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Meaning |
|
||||||
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `presentation` | `push`, `reveal`, `slide`, `fade`, `modal`, `sheet`, `none`, or a registered custom name |
|
||||||
|
| `transition` | Compatibility alias for `presentation`; prefer `presentation` in new code |
|
||||||
|
| `parent` | Logical Back target for a cold-start/deep-linked route; may be a location or a function of the current route |
|
||||||
|
| `siblingOrder` | Numeric visual order used to derive sibling direction |
|
||||||
|
| `siblingHistory` | `replace` keeps peer selections out of Back history; `push` makes them Back destinations |
|
||||||
|
| `cache` | `false` unmounts when inactive, `true` uses normal retention, and `pin` exempts the view from ordinary LRU trimming |
|
||||||
|
| `gesture` | `false` disables navigator gestures for the route; `edge` and `full` describe intended policy |
|
||||||
|
| `navigator`, `siblingGroup` | Reserved topology labels; the current navigator still receives its sibling list explicitly |
|
||||||
|
|
||||||
|
Declare `parent` for detail, settings, modal, and other routes that should have a
|
||||||
|
predictive destination when opened directly. It is a logical product
|
||||||
|
relationship, not proof that a matching browser history entry exists.
|
||||||
|
|
||||||
|
## Navigate
|
||||||
|
|
||||||
|
Use `useNativeRouter()` inside `setup()`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useNativeRouter } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const native = useNativeRouter();
|
||||||
|
|
||||||
|
async function openProduct(id: string) {
|
||||||
|
const committed = await native.push({
|
||||||
|
name: "product",
|
||||||
|
params: { id },
|
||||||
|
query: { source: "featured" },
|
||||||
|
hash: "#summary",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (committed) {
|
||||||
|
// Vue Router accepted the navigation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The same runtime is available as `this.$nativeRouter` in Options API
|
||||||
|
components.
|
||||||
|
|
||||||
|
| Method | Use |
|
||||||
|
| ---------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `push(to, options?)` | Add an animated history entry |
|
||||||
|
| `replace(to, options?)` | Replace the current history entry |
|
||||||
|
| `sibling(to, options?)` | Move between ordered peer routes |
|
||||||
|
| `pop()` | Navigate to the previous native history entry or declared parent |
|
||||||
|
| `present(to, presentation?)` | Present a route, using `modal` by default |
|
||||||
|
| `dismiss()` | Return from a presented route |
|
||||||
|
| `preload(to)` | Resolve and load a lazy route without mounting or committing it |
|
||||||
|
| `unload(to)` | Unmount inactive instances of one location and return the count |
|
||||||
|
| `trimCache(options?)` | Unmount inactive cached views while retaining lightweight descriptors |
|
||||||
|
|
||||||
|
Navigation options can override `presentation`, `replace`, `direction`, and
|
||||||
|
`sourceRect`. The navigation methods return `true` when Vue Router accepts the
|
||||||
|
commit and `false` for a no-op, cancellation, or rejected navigation.
|
||||||
|
|
||||||
|
Use normal `router.push()` for redirects or flows that intentionally do not need
|
||||||
|
a native preview. The runtime reconciles external Vue Router navigations, but
|
||||||
|
they do not receive the same preview-driven transition.
|
||||||
|
|
||||||
|
## Links and gesture components
|
||||||
|
|
||||||
|
### NativeLink
|
||||||
|
|
||||||
|
`NativeLink` renders a real anchor, resolves its `href`, preserves modified-click
|
||||||
|
behavior, and routes an ordinary primary click through the native runtime.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NativeLink } from "@native-vue-router/core";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeLink
|
||||||
|
:to="{ name: 'product', params: { id: '42' } }"
|
||||||
|
presentation="push"
|
||||||
|
class="product-link"
|
||||||
|
>
|
||||||
|
Product 42
|
||||||
|
</NativeLink>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Its navigation props are `to`, `replace`, and `presentation`; other attributes
|
||||||
|
are passed to the anchor.
|
||||||
|
|
||||||
|
### NativeGestureLink
|
||||||
|
|
||||||
|
`NativeGestureLink` lets a horizontal drag on a component reveal its destination.
|
||||||
|
It accepts `to`, `presentation` (default `reveal`), `replace`, `direction`
|
||||||
|
(`left`, `right`, or `any`), and `as` (default `div`).
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NativeGestureLink
|
||||||
|
as="article"
|
||||||
|
:to="{ name: 'product', params: { id: product.id } }"
|
||||||
|
presentation="reveal"
|
||||||
|
direction="left"
|
||||||
|
>
|
||||||
|
<ProductCard :product="product" />
|
||||||
|
</NativeGestureLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose a semantic `as` element and provide keyboard behavior when the result is
|
||||||
|
interactive. A normal click also invokes native `push()`.
|
||||||
|
|
||||||
|
### NativeNavigator
|
||||||
|
|
||||||
|
Pass ordered peer locations to enable full-surface horizontal sibling paging.
|
||||||
|
The leading edge remains reserved for Back when `canGoBack` is true.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NativeNavigator
|
||||||
|
:siblings="['/inbox', '/stories', '/profile']"
|
||||||
|
:edge-width="28"
|
||||||
|
>
|
||||||
|
<NativeRouterView />
|
||||||
|
</NativeNavigator>
|
||||||
|
```
|
||||||
|
|
||||||
|
Inputs, editable content, links, buttons, and elements carrying
|
||||||
|
`data-native-gesture="ignore"` are excluded from automatic gesture recognition.
|
||||||
|
Use the explicit attribute for carousels, maps, editors, canvases, or other
|
||||||
|
regions that own horizontal input.
|
||||||
|
|
||||||
|
### NativeDismissGesture
|
||||||
|
|
||||||
|
Wrap a custom full-height modal surface to make a downward drag dismiss it:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NativeDismissGesture, useNativeRouter } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const native = useNativeRouter();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeDismissGesture as="main" class="sheet">
|
||||||
|
<button type="button" @click="native.dismiss()">Close</button>
|
||||||
|
<!-- sheet content -->
|
||||||
|
</NativeDismissGesture>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### NativeSheet
|
||||||
|
|
||||||
|
Use `NativeSheet` inside a route whose presentation is `sheet`. It keeps the
|
||||||
|
surface below the device's safe top inset, leaves the previous route visible but
|
||||||
|
inert beneath a backdrop, and includes a drag handle.
|
||||||
|
|
||||||
|
With no breakpoints, the surface grows to its content and is capped at the
|
||||||
|
available device height:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NativeSheet } from "@native-vue-router/core";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeSheet aria-label="Filters">
|
||||||
|
<FilterForm />
|
||||||
|
</NativeSheet>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Supply fractional breakpoints to create snap points. Fractions are measured
|
||||||
|
against the route height after the safe top inset has been reserved:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { NativeSheet } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const breakpoint = ref(0.55);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NativeSheet
|
||||||
|
v-model="breakpoint"
|
||||||
|
:breakpoints="[0.3, 0.55, 1]"
|
||||||
|
:initial-breakpoint="0.55"
|
||||||
|
aria-label="Choose a location"
|
||||||
|
@breakpoint-change="savePreferredSheetSize"
|
||||||
|
>
|
||||||
|
<LocationPicker />
|
||||||
|
</NativeSheet>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Users drag the handle or use Arrow Up/Down, Home, and End while it is focused.
|
||||||
|
Dragging below the smallest point dismisses the route. The backdrop and Escape
|
||||||
|
also dismiss by default.
|
||||||
|
|
||||||
|
The scrollable sheet body participates in the same gesture automatically. At an
|
||||||
|
interior scroll position, the content scrolls normally. When the content is at
|
||||||
|
the top, pulling down hands the gesture to the sheet so it can move to a lower
|
||||||
|
point or dismiss. When the content is at the bottom, pushing upward grows the
|
||||||
|
sheet to its next point. Mouse/pen dragging, touch input, and thresholded
|
||||||
|
trackpad/wheel overscroll follow the same boundary rules.
|
||||||
|
|
||||||
|
Gesture ownership is chosen from the initial directional intent and remains
|
||||||
|
locked until release. A gesture that starts while content exists in that
|
||||||
|
direction stays a content gesture even if it reaches an edge or reverses. Lift
|
||||||
|
and begin a new gesture at the edge to resize the sheet. This prevents content
|
||||||
|
and sheet movement from overlapping and prevents previously scrolled distance
|
||||||
|
from becoming a sheet-height jump.
|
||||||
|
|
||||||
|
| Prop | Meaning |
|
||||||
|
| ------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| `breakpoints` | Unique fractions greater than `0` and at most `1`; an empty list enables content height |
|
||||||
|
| `initialBreakpoint` | Initial fraction, snapped to the nearest declared point |
|
||||||
|
| `modelValue` | Current fractional point for `v-model` |
|
||||||
|
| `dismissible` | Enables sheet-triggered drag and keyboard dismissal; default `true` |
|
||||||
|
| `backdropDismiss` | Lets a backdrop click dismiss; default `true` |
|
||||||
|
| `showHandle` | Renders the built-in drag/keyboard handle; default `true` |
|
||||||
|
| `ariaLabel` | Accessible dialog label; default `Sheet` |
|
||||||
|
|
||||||
|
Use the `handle` slot to replace the visual handle without replacing its input
|
||||||
|
behavior. Theme the surface with `--nvr-sheet-background`,
|
||||||
|
`--nvr-sheet-backdrop`, `--nvr-sheet-radius`, and `--nvr-sheet-top-gap`.
|
||||||
|
|
||||||
|
For a route opened with `native.present(to, "sheet")`, the runtime remembers the
|
||||||
|
sheet presentation after commit. Defining `meta.native.presentation: "sheet"`
|
||||||
|
as well makes direct URL entry and raw Vue Router navigation use the same
|
||||||
|
contained layout.
|
||||||
|
|
||||||
|
During presentation and dismissal, `NativeSheet` moves its actual surface by
|
||||||
|
that surface's height rather than translating a transparent viewport-sized
|
||||||
|
route wrapper. The source route remains continuously scaled as the sheet's
|
||||||
|
underlay, avoiding a geometry jump when the route transaction commits.
|
||||||
|
|
||||||
|
## Tabs and back controls
|
||||||
|
|
||||||
|
The optional native preset supplies a Back button and tab bar:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
NativeBackButton,
|
||||||
|
NativeTabBar,
|
||||||
|
type NativeTabItem,
|
||||||
|
} from "@native-vue-router/preset-native";
|
||||||
|
|
||||||
|
const tabs: NativeTabItem[] = [
|
||||||
|
{ label: "Inbox", to: "/inbox", icon: "◉" },
|
||||||
|
{ label: "Stories", to: "/stories", icon: "◎" },
|
||||||
|
{
|
||||||
|
label: "Profile",
|
||||||
|
to: "/profile",
|
||||||
|
icon: "◇",
|
||||||
|
activeWhen: (route) => route.path.startsWith("/profile"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header><NativeBackButton label="Back" /></header>
|
||||||
|
<NativeTabBar :items="tabs" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
The preset tab bar uses replace-style sibling navigation. Build a product-specific
|
||||||
|
control with `native.sibling()` when tabs need different history semantics.
|
||||||
|
|
||||||
|
## Route params, query, hash, and injected route state
|
||||||
|
|
||||||
|
Pass any normal `RouteLocationRaw` to native navigation methods and components.
|
||||||
|
`useRoute()`, `useRouter()`, and Options API `this.$route` work within active and
|
||||||
|
preview route trees. During a held gesture, the destination subtree sees its own
|
||||||
|
params, query, hash, matched records, and metadata even though
|
||||||
|
`router.currentRoute` still points at the committed source route.
|
||||||
|
|
||||||
|
This distinction is intentional:
|
||||||
|
|
||||||
|
- Read `useRoute()` or `$route` inside a route component for that surface's
|
||||||
|
scoped route.
|
||||||
|
- Read `router.currentRoute` only when the application needs the globally
|
||||||
|
committed route.
|
||||||
|
- Expect the two values to differ while a preview is visible.
|
||||||
|
|
||||||
|
Normal Vue `provide()` and `inject()` work across the route surface. App-level
|
||||||
|
provides, plugin provides, and values provided by route components remain
|
||||||
|
available to descendants.
|
||||||
|
|
||||||
|
## Lifecycle and cached views
|
||||||
|
|
||||||
|
Native Vue Router can keep inactive route component trees mounted. Vue's normal
|
||||||
|
mount, update, and unmount hooks continue to describe component lifetime, but
|
||||||
|
being mounted does not mean the route is the current screen.
|
||||||
|
|
||||||
|
Use the native lifecycle for route visibility and activity:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
onNativeViewActivate,
|
||||||
|
onNativeViewDeactivate,
|
||||||
|
onNativeViewEvict,
|
||||||
|
useNativeViewActiveEffect,
|
||||||
|
useNativeViewLifecycle,
|
||||||
|
useNativeViewVisibleEffect,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const view = useNativeViewLifecycle();
|
||||||
|
|
||||||
|
useNativeViewActiveEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
startPolling({ signal: controller.signal });
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
useNativeViewVisibleEffect(() => {
|
||||||
|
const stop = startAnimationNeededDuringTransitions();
|
||||||
|
return stop;
|
||||||
|
});
|
||||||
|
|
||||||
|
onNativeViewActivate(() => resumeMedia());
|
||||||
|
onNativeViewDeactivate(() => pauseMedia());
|
||||||
|
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
`isActive` means Vue Router has made the route authoritative. `isVisible` is
|
||||||
|
also true for either side of an interactive transition and for a sheet's visual
|
||||||
|
underlay. An underlay remains inert and is not active. `isCached` identifies a
|
||||||
|
mounted inactive view; `isPreview` identifies an uncommitted destination.
|
||||||
|
|
||||||
|
Use active effects for polling, subscriptions, media, analytics, and work that
|
||||||
|
should run only on the current route. Use visible effects for rendering work
|
||||||
|
needed while the route is on screen during a transition. Put durable state in a
|
||||||
|
store or persistence layer because cache eviction unmounts component-local
|
||||||
|
state.
|
||||||
|
|
||||||
|
The default cache limit is four inactive, non-pinned views. History descriptors
|
||||||
|
remain after a component tree is evicted and are remounted if navigation reaches
|
||||||
|
them later.
|
||||||
|
|
||||||
|
## Vue built-in components
|
||||||
|
|
||||||
|
`NativeRouterView` exposes `Component`, `route`, and `entry` through its default
|
||||||
|
slot, so normal Vue wrappers can be used inside each native route layer:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NativeRouterView v-slot="{ Component, route }">
|
||||||
|
<Suspense>
|
||||||
|
<Transition name="route-content" mode="out-in">
|
||||||
|
<KeepAlive :max="3">
|
||||||
|
<component :is="Component" :key="route.fullPath" />
|
||||||
|
</KeepAlive>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<RouteSkeleton />
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</NativeRouterView>
|
||||||
|
```
|
||||||
|
|
||||||
|
These components retain their normal Vue meaning:
|
||||||
|
|
||||||
|
- `<KeepAlive>` caches components selected within that route layer. It is not a
|
||||||
|
replacement for the native multi-route cache, and native route changes alone
|
||||||
|
do not imply Vue `onActivated()` or `onDeactivated()`.
|
||||||
|
- `<Transition>` animates changes inside a layer. Native presentations animate
|
||||||
|
the route layers themselves.
|
||||||
|
- `<Suspense>` may show a fallback while an async preview component resolves.
|
||||||
|
- `<Teleport>` can move DOM outside the layer. Because teleported DOM is outside
|
||||||
|
the layer's `inert` and `aria-hidden` boundary, close or hide overlays whenever
|
||||||
|
the owning native view is not visible.
|
||||||
|
|
||||||
|
A visibility-safe teleported overlay looks like this:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useNativeViewLifecycle } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const view = useNativeViewLifecycle();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button type="button" @click="open = true">Open overlay</button>
|
||||||
|
<Teleport to="body">
|
||||||
|
<MyOverlay v-if="open && view.isVisible.value" @close="open = false" />
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Options API lifecycle hooks (`beforeCreate`, `created`, `beforeMount`,
|
||||||
|
`mounted`, `beforeUpdate`, `updated`, `beforeUnmount`, and `unmounted`) and the
|
||||||
|
corresponding Composition API hooks keep their standard Vue behavior.
|
||||||
|
|
||||||
|
## Guards, redirects, and preview side effects
|
||||||
|
|
||||||
|
Forward navigation resolves and loads the route component before commit so the
|
||||||
|
user can drag a live destination. Vue Router guards run when the runtime commits
|
||||||
|
the real `push()`, `replace()`, or Back operation. A rejected guard removes the
|
||||||
|
preview and restores the source route; a redirect is reconciled to the route Vue
|
||||||
|
Router accepts.
|
||||||
|
|
||||||
|
Consequently, a preview component may execute `setup()` and mount before a guard
|
||||||
|
allows entry, then unmount without ever becoming active. Avoid irreversible work
|
||||||
|
such as analytics events, mutations, purchases, or permanent subscriptions in
|
||||||
|
unconditional setup/mount code. Tie committed-screen behavior to
|
||||||
|
`useNativeViewActiveEffect()` or an explicit committed application action.
|
||||||
|
|
||||||
|
## Custom presentations
|
||||||
|
|
||||||
|
Register a presentation at runtime or pass it in `createNativeRouter()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createNativeRouter,
|
||||||
|
definePresentation,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const scaleFade = definePresentation({
|
||||||
|
name: "scale-fade",
|
||||||
|
axis: "x",
|
||||||
|
layerStyle({ role, progress }) {
|
||||||
|
return role === "to"
|
||||||
|
? {
|
||||||
|
opacity: progress,
|
||||||
|
transform: `scale(${0.94 + progress * 0.06})`,
|
||||||
|
}
|
||||||
|
: { opacity: 1 - progress * 0.25 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nativeRouter = createNativeRouter({
|
||||||
|
router,
|
||||||
|
presentations: [scaleFade],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is also valid later:
|
||||||
|
nativeRouter.registerPresentation(scaleFade);
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference the registered name from route metadata or a navigation option.
|
||||||
|
Presentation functions should derive compositor-friendly styles from progress;
|
||||||
|
they must not mutate history or application state.
|
||||||
|
|
||||||
|
Advanced interactions can call `beginInteractive()`, `updateInteractive()`,
|
||||||
|
`finishInteractive()`, and `cancelInteractive()` directly. Progress is normalized
|
||||||
|
from `0` to `1`, and velocity is normalized route progress per second. Cancel the
|
||||||
|
transaction when the owning component unmounts and ignore stale async results by
|
||||||
|
checking the returned transaction ID.
|
||||||
|
|
||||||
|
## Platform adapters
|
||||||
|
|
||||||
|
### Capacitor
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
|
||||||
|
|
||||||
|
const nativeRouter = createNativeRouter({
|
||||||
|
router,
|
||||||
|
platform: createCapacitorAdapter({
|
||||||
|
haptics: true,
|
||||||
|
exitAtRoot: true,
|
||||||
|
trimCacheOnPause: true,
|
||||||
|
deepLinkPath: (url) => `${url.pathname}${url.search}${url.hash}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter handles hardware Back, launch/app URLs, pause cancellation, cache
|
||||||
|
trimming, optional haptics, and optional exit at the root. Configure Universal
|
||||||
|
Links/App Links in the native project separately.
|
||||||
|
|
||||||
|
### Electron
|
||||||
|
|
||||||
|
Disable Chromium's competing overscroll navigation in the main process before
|
||||||
|
`app.whenReady()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { app } from "electron";
|
||||||
|
import { disableElectronHistoryGestures } from "@native-vue-router/electron";
|
||||||
|
|
||||||
|
disableElectronHistoryGestures(app.commandLine);
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the renderer adapter after exposing the documented `window.nativeVueHost`
|
||||||
|
back/forward/memory-pressure bridge from a secure preload:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createElectronRendererAdapter } from "@native-vue-router/electron";
|
||||||
|
|
||||||
|
const nativeRouter = createNativeRouter({
|
||||||
|
router,
|
||||||
|
platform: createElectronRendererAdapter(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use hash history for packaged `file:` applications unless the Electron host
|
||||||
|
serves navigation URLs through an application protocol.
|
||||||
|
|
||||||
|
### Browser and PWA
|
||||||
|
|
||||||
|
The core works in normal browser tabs, but a browser may reserve an edge gesture
|
||||||
|
before page JavaScript can claim it. An installed iOS PWA can improve gesture
|
||||||
|
ownership with an early non-passive edge guard, but web content cannot change
|
||||||
|
`WKWebView.allowsBackForwardNavigationGestures`. Use Capacitor when deterministic
|
||||||
|
native-level ownership is required.
|
||||||
|
|
||||||
|
## Add to an existing Vue Router application
|
||||||
|
|
||||||
|
Adopt the library incrementally:
|
||||||
|
|
||||||
|
1. Confirm Vue 3.5+ and Vue Router 5, then install core and its CSS.
|
||||||
|
2. Create the native runtime from the existing router and install it after
|
||||||
|
`app.use(router)`.
|
||||||
|
3. Replace only the root visual outlet with `NativeRouterView`; leave nested
|
||||||
|
router views in route components intact.
|
||||||
|
4. Change navigation that needs native motion from `router.push()`/`RouterLink`
|
||||||
|
to runtime methods or `NativeLink`. Keep ordinary Vue Router calls where no
|
||||||
|
native transition is wanted.
|
||||||
|
5. Add `parent` metadata to deep-linkable child routes.
|
||||||
|
6. Identify peer routes such as tabs, assign `siblingOrder`, choose
|
||||||
|
`siblingHistory`, and pass their locations to `NativeNavigator`.
|
||||||
|
7. Move active-screen side effects from unconditional mount hooks into native
|
||||||
|
active or visible effects.
|
||||||
|
8. Mark nested horizontal controls with `data-native-gesture="ignore"` and add
|
||||||
|
component gesture links only where the product intends them.
|
||||||
|
9. Choose cache policy per route and move durable state out of component-local
|
||||||
|
memory.
|
||||||
|
10. Exercise URLs, redirects, rejected guards, direct deep links, browser Back
|
||||||
|
and Forward, held/cancelled gestures, reduced motion, and keyboard/focus
|
||||||
|
behavior before broad rollout.
|
||||||
|
|
||||||
|
For a lower-risk migration, start with button-driven `push()`, `pop()`, and
|
||||||
|
`present()`. Add predictive Back, siblings, and component-originated gestures
|
||||||
|
after the route topology and lifecycle behavior are verified.
|
||||||
|
|
||||||
|
## Diagnostics and profiling
|
||||||
|
|
||||||
|
Inspect the runtime's reactive state while integrating:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const native = useNativeRouter();
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
console.table(native.cacheStats.value);
|
||||||
|
console.log(native.transaction.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = native.onDiagnostic((event) => {
|
||||||
|
console.debug("native-navigation", event);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The opt-in profiler records frame cadence and timing-safe navigation events:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createNativeNavigationProfiler } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const profiler = createNativeNavigationProfiler(nativeRouter, {
|
||||||
|
metadata: { build: import.meta.env.VITE_BUILD_ID },
|
||||||
|
});
|
||||||
|
|
||||||
|
profiler.start();
|
||||||
|
// Reproduce the navigation.
|
||||||
|
const report = profiler.stop();
|
||||||
|
const json = profiler.toJSON(report);
|
||||||
|
profiler.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
Route params, query values, and application state are omitted from profiler
|
||||||
|
route labels.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**The route surface is blank or has zero height.** Give `html`, `body`, `#app`,
|
||||||
|
and the application shell a definite height, and import the core stylesheet.
|
||||||
|
|
||||||
|
**`useNativeRouter()` says the plugin is not installed.** Create one runtime and
|
||||||
|
call `app.use(nativeRouter)` before mounting the app.
|
||||||
|
|
||||||
|
**`useNativeViewLifecycle()` throws.** Call it only from a component rendered
|
||||||
|
inside `NativeRouterView`.
|
||||||
|
|
||||||
|
**A route mounts even though a guard rejects it.** This is preview behavior, not
|
||||||
|
a committed navigation. Move irreversible work to an active effect.
|
||||||
|
|
||||||
|
**Back has no visual destination after a direct deep link.** Add a `parent`
|
||||||
|
location or parent function to that route's native metadata.
|
||||||
|
|
||||||
|
**A tab appears in browser Back history.** Set `siblingHistory: "replace"` and
|
||||||
|
use `native.sibling()` or the preset tab bar.
|
||||||
|
|
||||||
|
**A carousel or editor fights the route gesture.** Put
|
||||||
|
`data-native-gesture="ignore"` on the region that owns the input.
|
||||||
|
|
||||||
|
**A teleported dialog remains interactive from an inactive cached route.** Gate
|
||||||
|
the teleport content on `useNativeViewLifecycle().isVisible` and close it on
|
||||||
|
hide/deactivate when appropriate.
|
||||||
|
|
||||||
|
**Local state disappears.** The cache is bounded and can be trimmed by a host.
|
||||||
|
Use `cache: "pin"` sparingly or store durable state outside the route component.
|
||||||
|
|
||||||
|
## Further reading
|
||||||
|
|
||||||
|
- [How the preview and commit model works](docs/how-it-works.md)
|
||||||
|
- [Architecture reference](docs/architecture.md)
|
||||||
|
- [Engineering constraints and trade-offs](docs/challenges-and-tradeoffs.md)
|
||||||
|
- [Scalability and extension points](docs/principles-and-scalability.md)
|
||||||
|
- [Platform integration](docs/platforms.md)
|
||||||
|
- [Interactive demo](apps/demo)
|
||||||
|
|
||||||
|
## AI agent skill
|
||||||
|
|
||||||
|
This repository includes a portable integration skill at
|
||||||
|
[`skills/integrate-native-vue-router`](skills/integrate-native-vue-router).
|
||||||
|
Copy that complete directory into the skills location recognized by the agent
|
||||||
|
(for Codex, normally `~/.codex/skills/`) and invoke it as
|
||||||
|
`$integrate-native-vue-router`. Keep `SKILL.md`, `agents/openai.yaml`, and the
|
||||||
|
`references` directory together so the integration workflow retains its API and
|
||||||
|
verification reference.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Vitest 3 carries Vite 6 types while this workspace builds with Vite 8.
|
||||||
|
// The plugin is runtime-compatible; remove this cast when Vitest is upgraded.
|
||||||
|
plugins: [vue() as never],
|
||||||
test: {
|
test: {
|
||||||
environment: "happy-dom",
|
environment: "happy-dom",
|
||||||
include: ["packages/**/*.test.ts"],
|
include: ["packages/**/*.test.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user