From 6aed7606ad0981734225fd5313d1a06b95decd9b Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Wed, 22 Jul 2026 07:04:39 +0000 Subject: [PATCH] Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode. --- .gitignore | 2 + README.md | 3 +- apps/demo/e2e/navigation.spec.ts | 279 +++++- apps/demo/src/compatibility-lab.ts | 44 + .../components/CompatibilityAsyncProbe.vue | 39 + .../CompatibilityCompositionProbe.vue | 41 + .../components/CompatibilityInjectProbe.vue | 29 + .../CompatibilityKeepAliveProbe.vue | 36 + .../components/CompatibilityOptionsProbe.vue | 56 ++ apps/demo/src/main.ts | 2 + apps/demo/src/router.ts | 25 + apps/demo/src/style.css | 272 +++++- apps/demo/src/views/ComposeView.vue | 85 +- apps/demo/src/views/ProfileView.vue | 44 +- .../src/views/VueCompatibilityAwayView.vue | 103 +++ apps/demo/src/views/VueCompatibilityView.vue | 427 +++++++++ docs/architecture.md | 2 +- docs/how-it-works.md | 6 +- docs/platforms.md | 2 +- experiments/router-guard-history/README.md | 38 + experiments/router-guard-history/index.html | 12 + experiments/router-guard-history/package.json | 18 + experiments/router-guard-history/src/App.vue | 138 +++ .../router-guard-history/src/experiment.ts | 78 ++ experiments/router-guard-history/src/main.ts | 13 + .../router-guard-history/src/router.ts | 71 ++ .../router-guard-history/src/style.css | 256 ++++++ .../src/views/ExperimentPage.vue | 13 + .../router-guard-history/tsconfig.json | 7 + .../router-guard-history/vite.config.ts | 6 + .../core/src/components/NativeRouteScope.vue | 24 +- .../src/components/NativeRouterView.test.ts | 748 ++++++++++++++++ .../core/src/components/NativeRouterView.vue | 59 +- .../core/src/components/NativeSheet.test.ts | 182 ++++ packages/core/src/components/NativeSheet.vue | 562 ++++++++++++ packages/core/src/components/index.ts | 6 + packages/core/src/components/sheet.ts | 28 + packages/core/src/runtime.test.ts | 24 +- packages/core/src/runtime.ts | 50 +- packages/core/src/style.css | 156 +++- packages/core/src/types.ts | 6 +- packages/core/tsconfig.json | 2 +- packages/core/vite.config.ts | 2 + skills/integrate-native-vue-router/SKILL.md | 204 +++++ .../agents/openai.yaml | 4 + .../references/integration-reference.md | 521 +++++++++++ usage.md | 809 ++++++++++++++++++ vitest.config.ts | 4 + 48 files changed, 5454 insertions(+), 84 deletions(-) create mode 100644 apps/demo/src/compatibility-lab.ts create mode 100644 apps/demo/src/components/CompatibilityAsyncProbe.vue create mode 100644 apps/demo/src/components/CompatibilityCompositionProbe.vue create mode 100644 apps/demo/src/components/CompatibilityInjectProbe.vue create mode 100644 apps/demo/src/components/CompatibilityKeepAliveProbe.vue create mode 100644 apps/demo/src/components/CompatibilityOptionsProbe.vue create mode 100644 apps/demo/src/views/VueCompatibilityAwayView.vue create mode 100644 apps/demo/src/views/VueCompatibilityView.vue create mode 100644 experiments/router-guard-history/README.md create mode 100644 experiments/router-guard-history/index.html create mode 100644 experiments/router-guard-history/package.json create mode 100644 experiments/router-guard-history/src/App.vue create mode 100644 experiments/router-guard-history/src/experiment.ts create mode 100644 experiments/router-guard-history/src/main.ts create mode 100644 experiments/router-guard-history/src/router.ts create mode 100644 experiments/router-guard-history/src/style.css create mode 100644 experiments/router-guard-history/src/views/ExperimentPage.vue create mode 100644 experiments/router-guard-history/tsconfig.json create mode 100644 experiments/router-guard-history/vite.config.ts create mode 100644 packages/core/src/components/NativeRouterView.test.ts create mode 100644 packages/core/src/components/NativeSheet.test.ts create mode 100644 packages/core/src/components/NativeSheet.vue create mode 100644 packages/core/src/components/sheet.ts create mode 100644 skills/integrate-native-vue-router/SKILL.md create mode 100644 skills/integrate-native-vue-router/agents/openai.yaml create mode 100644 skills/integrate-native-vue-router/references/integration-reference.md create mode 100644 usage.md diff --git a/.gitignore b/.gitignore index c74b11c..15f7dde 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ apps/demo/dev-dist *.njsproj *.sln *.sw? + +docker-compose.yml \ No newline at end of file diff --git a/README.md b/README.md index 0c630cf..fd57f44 100644 --- a/README.md +++ b/README.md @@ -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. - Ordered horizontal route paging with replace-by-default history. - Component-originated route dragging with a live target route. -- Interactive push, adjacent-page sibling slide, modal, sheet, fade, and application-defined presentations. +- Interactive push, adjacent-page sibling slide, modal, safe-area-contained content/snap-point sheets with scroll-boundary handoff, fade, and application-defined presentations. - Concurrent `from` and `to` routes using only public Vue Router 5 APIs. - Guarded commits: previews do not alter the URL, and rejected navigation springs back. - Cold-start predictive back through declared parent routes. @@ -127,6 +127,7 @@ No rAF loop or browser performance observer runs before `start()`, and `stop()` Design and engineering documentation: +- [Complete installation and usage guide](usage.md) - [How it works and why the pattern is uncommon](docs/how-it-works.md) - [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md) - [Core principles, scalability, and flexibility](docs/principles-and-scalability.md) diff --git a/apps/demo/e2e/navigation.spec.ts b/apps/demo/e2e/navigation.spec.ts index 2d1347c..ddb6fa9 100644 --- a/apps/demo/e2e/navigation.spec.ts +++ b/apps/demo/e2e/navigation.spec.ts @@ -389,6 +389,69 @@ test("manually unloads an inactive route through the public API demo", async ({ await expect(page.getByTestId("stories-view")).toHaveCount(0); }); +test("exercises Vue built-ins, lifecycle hooks, injection, and scoped route state", async ({ + page, +}) => { + await page.goto("/profile"); + await page.getByRole("link", { name: /Vue compatibility lab/ }).click(); + await expect(page).toHaveURL( + /\/profile\/vue-lab\/alpha\?mode=manual#route-state$/, + ); + + const lab = page.getByTestId("vue-compatibility-view"); + const firstInstance = await lab.getAttribute("data-instance-id"); + await expect(page.getByTestId("compat-param")).toHaveText("alpha"); + await expect(page.getByTestId("compat-query")).toContainText("manual"); + await expect(page.getByTestId("compat-hash")).toHaveText("#route-state"); + await expect(page.getByTestId("options-lifecycle-probe")).toContainText( + "/profile/vue-lab/alpha?mode=manual#route-state", + ); + await expect(page.getByTestId("inject-probe-route-tree")).toContainText( + "Injected from the demo application root", + ); + + await page.getByRole("button", { name: "Increment A" }).click(); + await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1"); + await page.getByTestId("compat-switch-keepalive").click(); + await expect(page.getByTestId("keep-alive-B")).toBeVisible(); + await page.getByTestId("compat-switch-keepalive").click(); + await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1"); + + await page.getByTestId("compat-update-probes").click(); + await expect(page.getByTestId("compat-event-log")).toContainText("onUpdated"); + await page.getByTestId("compat-toggle-transition").click(); + await expect(page.getByTestId("compat-transition-card")).toHaveCount(0); + await expect(page.getByTestId("compat-event-log")).toContainText( + "after-leave", + ); + + await page.getByTestId("compat-open-teleport").click(); + const teleport = page.getByTestId("compat-teleport-overlay"); + await expect(teleport).toBeVisible(); + await expect(page.getByTestId("inject-probe-teleport")).toContainText( + "Injected from the demo application root", + ); + await page.getByRole("button", { name: "Close teleported overlay" }).click(); + + await page.getByTestId("compat-reload-suspense").click(); + await expect(page.getByTestId("compat-suspense-fallback")).toBeVisible(); + await expect(page.getByTestId("compat-suspense-ready")).toBeVisible({ + timeout: 2_000, + }); + + await page.getByTestId("compat-open-away").click(); + await expect(page.getByTestId("vue-compatibility-away")).toBeVisible(); + await page.getByTestId("compat-unload-return").click(); + await expect(page.getByTestId("vue-compatibility-view")).toBeVisible(); + await expect(page.getByTestId("vue-compatibility-view")).not.toHaveAttribute( + "data-instance-id", + firstInstance!, + ); + await expect(page.getByTestId("compat-event-log")).toContainText( + "onUnmounted", + ); +}); + test("records a navigation frame profile across route changes", async ({ page, }) => { @@ -421,10 +484,213 @@ test("opens and dismisses the compose sheet", async ({ page }) => { await expect( page.getByRole("heading", { name: "New message" }), ).toBeVisible(); + await waitForTransition(page); + await expect(page.locator("[data-native-sheet]")).toHaveAttribute( + "data-native-sheet-breakpoint", + "0.62", + ); + await expect(page.locator('[data-native-role="underlay"]')).toHaveAttribute( + "aria-hidden", + "true", + ); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible(); }); +test("contains a sheet below the safe top and supports snap and content sizes", async ({ + page, +}) => { + await page.goto("/inbox"); + await page.getByRole("button", { name: "Compose" }).click(); + await expect(page).toHaveURL(/\/compose$/); + await waitForTransition(page); + + const routerView = page.locator(".nvr-router-view"); + const activeLayer = page.locator('[data-native-role="active"]'); + const frame = await routerView.boundingBox(); + const layer = await activeLayer.boundingBox(); + if (!frame || !layer) throw new Error("Sheet route did not render"); + expect(layer.y).toBeGreaterThanOrEqual(frame.y + 7); + expect(layer.y + layer.height).toBeLessThanOrEqual( + frame.y + frame.height + 1, + ); + + const handle = page.getByRole("slider", { + name: "Resize or dismiss sheet", + }); + await handle.press("ArrowUp"); + await expect(page.getByTestId("sheet-size")).toHaveText("100%"); + await expect(page.locator("[data-native-sheet]")).toHaveAttribute( + "data-native-sheet-breakpoint", + "1", + ); + + await page.getByRole("button", { name: "Fit content" }).click(); + await expect(page.getByTestId("sheet-size")).toHaveText("Auto"); + await expect(page.locator("[data-native-sheet]")).toHaveAttribute( + "data-native-sheet-mode", + "content", + ); +}); + +test("animates the partial surface immediately and preserves underlay geometry", async ({ + page, +}) => { + await page.goto("/inbox"); + await page.getByRole("button", { name: "Compose" }).dispatchEvent("click"); + + const opening = await page.evaluate(async () => { + for (let frame = 0; frame < 60; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + const routerView = document.querySelector( + ".nvr-router-view--interactive", + ); + const surface = document.querySelector( + '[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( + '.nvr-router-view--interactive[data-native-direction="back"]', + ); + const destination = document.querySelector( + '[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( + '.nvr-router-view--interactive[data-native-presentation="sheet"]', + ); + const surface = document.querySelector( + '[data-native-role="to"] [data-native-sheet]', + ); + return Boolean( + routerView && + surface && + surface.getBoundingClientRect().top < + routerView.getBoundingClientRect().bottom, + ); + }); + await waitForTransition(page); + const repeatedUnderlayScale = await page + .locator('[data-native-role="underlay"]') + .evaluate( + (element) => new DOMMatrix(getComputedStyle(element).transform).a, + ); + expect(repeatedUnderlayScale).toBeCloseTo(0.96, 2); +}); + +test("hands content overscroll to adjacent sheet breakpoints", async ({ + page, +}) => { + await page.goto("/inbox"); + await page.getByRole("button", { name: "Compose" }).click(); + await expect(page).toHaveURL(/\/compose$/); + await waitForTransition(page); + + const surface = page.locator("[data-native-sheet]"); + const body = page.locator(".nvr-sheet__body"); + const box = await body.boundingBox(); + if (!box) throw new Error("Sheet scroll body did not render"); + + await body.evaluate((element) => { + element.scrollTop = Math.min( + 40, + Math.max(0, element.scrollHeight - element.clientHeight - 10), + ); + }); + await page.mouse.move(box.x + 100, box.y + box.height * 0.5); + await page.mouse.wheel(0, 1_000); + await page.mouse.wheel(0, 80); + await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62"); + await page.waitForTimeout(280); + + await body.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await page.mouse.move(box.x + 100, box.y + box.height * 0.7); + await page.mouse.down(); + await page.mouse.move(box.x + 100, box.y + box.height * 0.35, { steps: 10 }); + await page.mouse.up(); + await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "1"); + + await body.evaluate((element) => { + element.scrollTop = 0; + }); + const expandedBox = await body.boundingBox(); + if (!expandedBox) throw new Error("Expanded sheet body did not render"); + await page.mouse.move( + expandedBox.x + 100, + expandedBox.y + expandedBox.height * 0.35, + ); + await page.mouse.down(); + await page.mouse.move( + expandedBox.x + 100, + expandedBox.y + expandedBox.height * 0.55, + { steps: 10 }, + ); + await page.mouse.up(); + await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62"); +}); + test("keeps the target live during a held component drag", async ({ page }) => { await page.goto("/inbox"); const row = page.locator(".conversation-row").first(); @@ -528,13 +794,16 @@ test("always opens compose as a vertical sheet after prior navigation", async ({ test("drags a sheet down to dismiss it", async ({ page }) => { await page.goto("/inbox"); await page.getByRole("button", { name: "Compose" }).click(); - const sheet = await page.locator(".sheet-screen").boundingBox(); - if (!sheet) throw new Error("Sheet did not render"); - await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12); + await expect(page).toHaveURL(/\/compose$/); + await waitForTransition(page); + const sheet = await page.locator("[data-native-sheet]").boundingBox(); + const handle = await page.locator(".nvr-sheet__handle").boundingBox(); + if (!sheet || !handle) throw new Error("Sheet did not render"); + await page.mouse.move(handle.x + handle.width / 2, handle.y + 12); await page.mouse.down(); await page.mouse.move( - sheet.x + sheet.width / 2, - sheet.y + sheet.height * 0.55, + handle.x + handle.width / 2, + handle.y + sheet.height * 0.75, { steps: 14 }, ); await page.mouse.up(); diff --git a/apps/demo/src/compatibility-lab.ts b/apps/demo/src/compatibility-lab.ts new file mode 100644 index 0000000..99d339e --- /dev/null +++ b/apps/demo/src/compatibility-lab.ts @@ -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>; +} + +export const demoAppValueKey: InjectionKey = Symbol("demo-app-value"); +export const compatibilityLabContextKey: InjectionKey = + 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); +} diff --git a/apps/demo/src/components/CompatibilityAsyncProbe.vue b/apps/demo/src/components/CompatibilityAsyncProbe.vue new file mode 100644 index 0000000..7caf304 --- /dev/null +++ b/apps/demo/src/components/CompatibilityAsyncProbe.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/demo/src/components/CompatibilityCompositionProbe.vue b/apps/demo/src/components/CompatibilityCompositionProbe.vue new file mode 100644 index 0000000..e7a63f8 --- /dev/null +++ b/apps/demo/src/components/CompatibilityCompositionProbe.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/demo/src/components/CompatibilityInjectProbe.vue b/apps/demo/src/components/CompatibilityInjectProbe.vue new file mode 100644 index 0000000..655a0e7 --- /dev/null +++ b/apps/demo/src/components/CompatibilityInjectProbe.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/demo/src/components/CompatibilityKeepAliveProbe.vue b/apps/demo/src/components/CompatibilityKeepAliveProbe.vue new file mode 100644 index 0000000..41f8021 --- /dev/null +++ b/apps/demo/src/components/CompatibilityKeepAliveProbe.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/demo/src/components/CompatibilityOptionsProbe.vue b/apps/demo/src/components/CompatibilityOptionsProbe.vue new file mode 100644 index 0000000..8c6aa7f --- /dev/null +++ b/apps/demo/src/components/CompatibilityOptionsProbe.vue @@ -0,0 +1,56 @@ + + + diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 377226b..e0f166f 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -3,6 +3,7 @@ import { createNativeRouter } from "@native-vue-router/core"; import { createCapacitorAdapter } from "@native-vue-router/capacitor"; import { createElectronRendererAdapter } from "@native-vue-router/electron"; import App from "./App.vue"; +import { demoAppValueKey } from "./compatibility-lab"; import { createPwaAdapter } from "./pwa"; import { router } from "./router"; import "./style.css"; @@ -27,6 +28,7 @@ const nativeRouter = createNativeRouter({ const app = createApp(App); app.use(router); app.use(nativeRouter); +app.provide(demoAppValueKey, "Injected from the demo application root"); await router.isReady(); app.mount("#app"); diff --git a/apps/demo/src/router.ts b/apps/demo/src/router.ts index f05c062..12415c8 100644 --- a/apps/demo/src/router.ts +++ b/apps/demo/src/router.ts @@ -104,6 +104,31 @@ const routes: RouteRecordRaw[] = [ native: { presentation: "push", parent: "/profile", gesture: "edge" }, }, }, + { + path: "/profile/vue-lab/:sample", + name: "vue-compatibility", + component: () => import("./views/VueCompatibilityView.vue"), + meta: { + native: { presentation: "push", parent: "/profile", gesture: "edge" }, + }, + }, + { + path: "/profile/vue-lab/:sample/away", + name: "vue-compatibility-away", + component: () => import("./views/VueCompatibilityAwayView.vue"), + meta: { + native: { + presentation: "push", + parent: (route) => ({ + name: "vue-compatibility", + params: { sample: route.params.sample }, + query: route.query, + hash: route.hash, + }), + gesture: "edge", + }, + }, + }, ]; export const router = createRouter({ diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index c5aacb7..e83971a 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -28,7 +28,8 @@ html, body, #app { width: 100%; - height: 100%; + /* Commenting this out for now. Its causing a black bar at the bottom of the screen on iOS */ + /* height: 100%; */ margin: 0; overflow: hidden; overscroll-behavior: none; @@ -65,7 +66,7 @@ html[data-pwa-edge-guard="active"] body { .app-frame { position: relative; width: 100%; - height: 100dvh; + height: 100vh; max-width: 740px; margin: 0 auto; overflow: hidden; @@ -662,18 +663,19 @@ html[data-pwa-edge-guard="active"] body { font-size: 21px; } -.sheet-screen { - border-radius: 22px 22px 0 0; +.compose-sheet { + --nvr-sheet-background: #0b0d12; + --nvr-sheet-backdrop: rgba(0, 0, 0, 0.42); } -.sheet-handle { - position: sticky; - z-index: 12; - top: 8px; - width: 38px; - height: 5px; - margin: 8px auto 0; - border-radius: 4px; - background: #4e5360; +.compose-sheet-content { + min-height: 0; + background: + radial-gradient( + circle at 88% 4%, + rgba(124, 92, 255, 0.09), + transparent 24% + ), + #0b0d12; } .sheet-header { display: grid; @@ -685,12 +687,23 @@ html[data-pwa-edge-guard="active"] body { margin: 0; font-size: 16px; } +.sheet-header small { + display: block; + margin-top: 2px; + color: var(--muted); + text-align: center; + font-size: 10px; +} .sheet-header button { justify-self: start; border: 0; color: #9b88ff; background: transparent; } +.sheet-header button:last-child { + justify-self: end; + font-size: 11px; +} .compose-search { margin: 0 16px 14px; } @@ -884,6 +897,226 @@ html[data-pwa-edge-guard="active"] body { background: #ff6c76; box-shadow: 0 0 9px #ff6c76; } + +.compatibility-screen { + padding-bottom: calc(34px + env(safe-area-inset-bottom)); +} +.compat-header-state { + padding: 5px 8px; + border: 1px solid rgba(255, 194, 92, 0.28); + border-radius: 999px; + color: #ffc86c; + background: rgba(255, 194, 92, 0.08); + font-size: 9px; + font-weight: 800; + text-transform: uppercase; +} +.compat-header-state.active { + border-color: rgba(61, 217, 170, 0.3); + color: #6ce5bf; + background: rgba(61, 217, 170, 0.08); +} +.compat-intro > span { + width: 64px; + font-size: 15px; +} +.compat-route-state code { + max-width: 52%; + overflow-wrap: anywhere; + color: #b9adff; + font-size: 11px; + text-align: right; +} +.compat-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0 16px 18px; +} +.compat-controls--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.compat-controls button, +.compat-inline-actions button, +.compat-probe button, +.compat-event-heading button { + min-height: 42px; + border: 1px solid rgba(124, 92, 255, 0.25); + border-radius: 12px; + color: #c7beff; + background: rgba(124, 92, 255, 0.1); + font-size: 11px; + font-weight: 750; +} +.settings-group > .compat-inline-actions { + min-height: auto; + justify-content: flex-start; +} +.compat-inline-actions button { + flex: 1; +} +.settings-group > .compat-probe-grid { + display: grid; + min-height: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 10px; + padding: 12px; +} +.compat-probe { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + margin: 10px 12px; + padding: 13px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 15px; + background: rgba(255, 255, 255, 0.025); +} +.compat-probe-grid .compat-probe { + margin: 0; +} +.compat-probe__badge { + color: #a998ff; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.compat-probe strong { + font-size: 13px; +} +.compat-probe p { + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.compat-probe code { + color: #b9adff; +} +.compat-probe button { + width: 100%; + margin-top: auto; +} +.compat-probe--keepalive { + border-color: rgba(61, 217, 170, 0.16); + background: rgba(61, 217, 170, 0.045); +} +.compat-probe--resolved { + border-color: rgba(61, 217, 170, 0.2); +} +.compat-probe--loading { + min-height: 96px; + flex-direction: row; + align-items: center; +} +.compat-transition-card { + margin: 10px 12px; + padding: 22px 14px; + border: 1px solid rgba(255, 194, 92, 0.22); + border-radius: 15px; + color: #ffd28a; + background: rgba(255, 194, 92, 0.07); + font-size: 12px; + font-weight: 750; + text-align: center; +} +.compat-fade-enter-active, +.compat-fade-leave-active { + transition: + opacity 220ms ease, + transform 220ms ease; +} +.compat-fade-enter-from, +.compat-fade-leave-to { + opacity: 0; + transform: translateY(8px) scale(0.98); +} +.compat-teleport { + position: fixed; + z-index: 1000; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(2, 3, 6, 0.74); + backdrop-filter: blur(12px); +} +.compat-teleport > section { + position: relative; + width: min(440px, 100%); + padding: 20px; + border: 1px solid rgba(169, 152, 255, 0.32); + border-radius: 22px; + background: #141720; + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58); +} +.compat-teleport > section > button { + position: absolute; + top: 10px; + right: 10px; + width: 34px; + height: 34px; + border: 0; + border-radius: 50%; + color: #fff; + background: rgba(255, 255, 255, 0.08); + font-size: 20px; +} +.compat-teleport h2 { + margin: 0 44px 15px 0; + font-size: 18px; +} +.compat-teleport .compat-probe { + margin: 0; +} +.settings-group > .compat-event-heading { + min-height: 66px; +} +.compat-event-heading button { + min-height: 34px; + padding: 0 12px; +} +.compat-event-log { + max-height: 360px; + margin: 0; + padding: 0; + overflow-y: auto; + border-top: 1px solid var(--line); + list-style: none; +} +.compat-event-log li { + display: grid; + grid-template-columns: 58px minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + padding: 9px 13px; + border-bottom: 1px solid rgba(255, 255, 255, 0.045); +} +.compat-event-log time, +.compat-event-log small { + color: #686e7b; + font-size: 9px; +} +.compat-event-log span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} +.compat-event-log strong { + overflow: hidden; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.compat-event-log code { + color: #afa1ff; + font-size: 10px; +} .empty-state { display: grid; place-items: center; @@ -926,6 +1159,19 @@ html[data-pwa-edge-guard="active"] body { } } +@media (max-width: 520px) { + .compat-controls--three, + .settings-group > .compat-probe-grid { + grid-template-columns: 1fr; + } + .compat-event-log li { + grid-template-columns: 52px minmax(0, 1fr); + } + .compat-event-log li > small { + display: none; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/apps/demo/src/views/ComposeView.vue b/apps/demo/src/views/ComposeView.vue index c65a605..d38e0fd 100644 --- a/apps/demo/src/views/ComposeView.vue +++ b/apps/demo/src/views/ComposeView.vue @@ -1,37 +1,66 @@ diff --git a/apps/demo/src/views/ProfileView.vue b/apps/demo/src/views/ProfileView.vue index b867c1b..d3953a8 100644 --- a/apps/demo/src/views/ProfileView.vue +++ b/apps/demo/src/views/ProfileView.vue @@ -1,12 +1,12 @@ @@ -24,13 +24,35 @@ function toggleStoryGuard() {
- Runtime stress lab - + ⚙︎Navigation lab + VueVue compatibility lab + Project source + - ⚙︎Navigation lab - Project source -
diff --git a/apps/demo/src/views/VueCompatibilityAwayView.vue b/apps/demo/src/views/VueCompatibilityAwayView.vue new file mode 100644 index 0000000..7b911fd --- /dev/null +++ b/apps/demo/src/views/VueCompatibilityAwayView.vue @@ -0,0 +1,103 @@ + + + diff --git a/apps/demo/src/views/VueCompatibilityView.vue b/apps/demo/src/views/VueCompatibilityView.vue new file mode 100644 index 0000000..2d9ae3c --- /dev/null +++ b/apps/demo/src/views/VueCompatibilityView.vue @@ -0,0 +1,427 @@ + + + diff --git a/docs/architecture.md b/docs/architecture.md index 6032884..c59ddfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,7 +87,7 @@ useNativeViewActiveEffect(() => { onNativeViewEvict((reason) => saveDraft(view.route.value, reason)); ``` -`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation. Application data that must survive eviction belongs in an application store. +`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition and the visible, inert route beneath a partial sheet. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation or while painted beneath a sheet. Application data that must survive eviction belongs in an application store. ## Optional performance profiler diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 2056e54..a884688 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -70,9 +70,9 @@ Transactions are interruptible. Starting a new navigation while a spring is sett ## Rendering and presentation -`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is visible and interactive. During a transaction, exactly the `from` and `to` entries receive active roles. +`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is interactive. A partial sheet also keeps its prior route visible but inert as an underlay. During a transaction, exactly the `from` and `to` entries receive interactive roles. -Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface. +Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sheets retain their presentation after commit so they remain below the device safe top and can use content height or developer-defined snap points while the inert route beneath stays visible. Partial sheets animate their measured surface instead of a transparent viewport-sized wrapper, preserving motion from the first frame and keeping the underlay scale continuous across commit. Their scroll body hands top/down and bottom/up overscroll to sheet resizing while retaining ordinary content scrolling at interior positions. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface. The runtime publishes progress as a CSS custom property. Built-in motion is mostly expressed through transforms and opacity, keeping per-frame JavaScript work constant. Applications can register presentations whose layer styles are functions of progress, role, direction, and optional source geometry. @@ -88,6 +88,8 @@ Ownership is explicit: - A component gesture link owns drags that begin on that component away from the back edge. - A sheet dismissal surface owns downward vertical drags. +Scrollable sheets choose content or sheet ownership from the gesture's initial directional intent and keep that owner until release. A content-owned gesture does not become a sheet gesture merely because it later reaches an edge or reverses; the next gesture can begin at that edge and resize the sheet. This prevents concurrent scrolling and avoids applying distance accumulated by content to sheet geometry. + Pointer capture keeps delivery stable after recognition. Recognizer state is detached synchronously at pointer release, before route loading or animation promises are awaited. This is essential: a delayed callback from one gesture must never erase the state of a newer gesture. ## History and cache are intentionally separate diff --git a/docs/platforms.md b/docs/platforms.md index a5d53b5..a8c3b75 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -26,4 +26,4 @@ The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics ## Accessibility -Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants. +Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. A partial sheet's visible underlay is also inert and `aria-hidden`; it remains painted only to provide visual context beneath the sheet backdrop. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants. diff --git a/experiments/router-guard-history/README.md b/experiments/router-guard-history/README.md new file mode 100644 index 0000000..d5ed5a0 --- /dev/null +++ b/experiments/router-guard-history/README.md @@ -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. diff --git a/experiments/router-guard-history/index.html b/experiments/router-guard-history/index.html new file mode 100644 index 0000000..700a228 --- /dev/null +++ b/experiments/router-guard-history/index.html @@ -0,0 +1,12 @@ + + + + + + Vue Router Guard History Experiment + + +
+ + + diff --git a/experiments/router-guard-history/package.json b/experiments/router-guard-history/package.json new file mode 100644 index 0000000..005b981 --- /dev/null +++ b/experiments/router-guard-history/package.json @@ -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" + } +} diff --git a/experiments/router-guard-history/src/App.vue b/experiments/router-guard-history/src/App.vue new file mode 100644 index 0000000..6b3409e --- /dev/null +++ b/experiments/router-guard-history/src/App.vue @@ -0,0 +1,138 @@ + + + diff --git a/experiments/router-guard-history/src/experiment.ts b/experiments/router-guard-history/src/experiment.ts new file mode 100644 index 0000000..49388fe --- /dev/null +++ b/experiments/router-guard-history/src/experiment.ts @@ -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((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"; +} diff --git a/experiments/router-guard-history/src/main.ts b/experiments/router-guard-history/src/main.ts new file mode 100644 index 0000000..c762a3c --- /dev/null +++ b/experiments/router-guard-history/src/main.ts @@ -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); +}); diff --git a/experiments/router-guard-history/src/router.ts b/experiments/router-guard-history/src/router.ts new file mode 100644 index 0000000..a75216a --- /dev/null +++ b/experiments/router-guard-history/src/router.ts @@ -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), + }); +}); diff --git a/experiments/router-guard-history/src/style.css b/experiments/router-guard-history/src/style.css new file mode 100644 index 0000000..0ebabff --- /dev/null +++ b/experiments/router-guard-history/src/style.css @@ -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; + } +} diff --git a/experiments/router-guard-history/src/views/ExperimentPage.vue b/experiments/router-guard-history/src/views/ExperimentPage.vue new file mode 100644 index 0000000..83a5eac --- /dev/null +++ b/experiments/router-guard-history/src/views/ExperimentPage.vue @@ -0,0 +1,13 @@ + + + diff --git a/experiments/router-guard-history/tsconfig.json b/experiments/router-guard-history/tsconfig.json new file mode 100644 index 0000000..4e79c55 --- /dev/null +++ b/experiments/router-guard-history/tsconfig.json @@ -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"] +} diff --git a/experiments/router-guard-history/vite.config.ts b/experiments/router-guard-history/vite.config.ts new file mode 100644 index 0000000..307689b --- /dev/null +++ b/experiments/router-guard-history/vite.config.ts @@ -0,0 +1,6 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [vue()], +}); diff --git a/packages/core/src/components/NativeRouteScope.vue b/packages/core/src/components/NativeRouteScope.vue index 82ffc78..ed83d92 100755 --- a/packages/core/src/components/NativeRouteScope.vue +++ b/packages/core/src/components/NativeRouteScope.vue @@ -30,11 +30,33 @@ provide(routeLocationKey, scopedRoute); const entry = computed(() => 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(); + 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(() => { const transaction = runtime.transaction.value; 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"; + } if (entry.value.key === transaction.fromKey) return "from"; if (entry.value.key === transaction.toKey) return "to"; return "inactive"; diff --git a/packages/core/src/components/NativeRouterView.test.ts b/packages/core/src/components/NativeRouterView.test.ts new file mode 100644 index 0000000..bf87ca3 --- /dev/null +++ b/packages/core/src/components/NativeRouterView.test.ts @@ -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 = Symbol("compatibility-app-value"); +const pageValueKey: InjectionKey> = 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 { + 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({ + revision: 0, + kept: "a", + transitionVisible: true, + teleportVisible: true, + suspenseVisible: false, + }); + const events: string[] = []; + let resolveSuspense!: () => void; + const suspenseReady = new Promise((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( + '[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"); + }); +}); diff --git a/packages/core/src/components/NativeRouterView.vue b/packages/core/src/components/NativeRouterView.vue index db232bb..8cf2c00 100755 --- a/packages/core/src/components/NativeRouterView.vue +++ b/packages/core/src/components/NativeRouterView.vue @@ -22,6 +22,28 @@ function roleFor(entry: NativeViewEntry) { 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(); + 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) { const currentTransaction = transaction.value; if (!currentTransaction) return undefined; @@ -42,8 +64,10 @@ function interactiveRole( nativeRuntime: NativeRouterRuntime, ): NativeViewRole { const currentTransaction = nativeRuntime.transaction.value; - if (!currentTransaction) + if (!currentTransaction) { + if (isUnderlay(entry, nativeRuntime)) return "underlay"; return entry.key === nativeRuntime.activeKey.value ? "active" : "inactive"; + } if (entry.key === currentTransaction.fromKey) return "from"; if (entry.key === currentTransaction.toKey) return "to"; return "inactive"; @@ -72,24 +96,29 @@ function interactiveRole( :style="layerStyleFor(entry)" :data-native-role="roleFor(entry)" :data-native-route="entry.route.fullPath" + :data-native-view-presentation="entry.presentation" :data-native-presentation="transaction?.presentation" :data-native-direction="transaction?.direction" - :inert="roleFor(entry) === 'inactive' ? true : undefined" - :aria-hidden="roleFor(entry) === 'inactive' ? 'true' : undefined" + :inert=" + roleFor(entry) === 'inactive' || roleFor(entry) === 'underlay' + ? true + : undefined + " + :aria-hidden=" + roleFor(entry) === 'inactive' || roleFor(entry) === 'underlay' + ? 'true' + : undefined + " > - - - + + + diff --git a/packages/core/src/components/NativeSheet.test.ts b/packages/core/src/components/NativeSheet.test.ts new file mode 100644 index 0000000..bfbea92 --- /dev/null +++ b/packages/core/src/components/NativeSheet.test.ts @@ -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[] = []; + +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("[data-native-sheet]"); + const handle = container.querySelector(".nvr-sheet__handle"); + const root = container.querySelector(".nvr-sheet"); + const body = container.querySelector(".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("/"); + }); +}); diff --git a/packages/core/src/components/NativeSheet.vue b/packages/core/src/components/NativeSheet.vue new file mode 100644 index 0000000..5b66b86 --- /dev/null +++ b/packages/core/src/components/NativeSheet.vue @@ -0,0 +1,562 @@ + + + diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index ed00970..491b9f5 100755 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -3,7 +3,13 @@ export { default as NativeGestureLink } from "./NativeGestureLink.vue"; export { default as NativeLink } from "./NativeLink.vue"; export { default as NativeNavigator } from "./NativeNavigator.vue"; export { default as NativeRouterView } from "./NativeRouterView.vue"; +export { default as NativeSheet } from "./NativeSheet.vue"; export { navigationOptionsFromElement } from "./gestures"; +export { + adjacentSheetBreakpoint, + nearestSheetBreakpoint, + normalizeSheetBreakpoints, +} from "./sheet"; export { onNativeViewActivate, onNativeViewDeactivate, diff --git a/packages/core/src/components/sheet.ts b/packages/core/src/components/sheet.ts new file mode 100644 index 0000000..9a3f1d5 --- /dev/null +++ b/packages/core/src/components/sheet.ts @@ -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((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); +} diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index 998eb7c..c097053 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -11,7 +11,7 @@ import { createNativeNavigationProfiler } from "./profiler"; const Page = defineComponent({ template: "
page
" }); -async function harness(blockB: boolean | "redirect" = false) { +async function harness(blockB: boolean | "redirect" = false, maxInactive = 2) { const router = createRouter({ history: createMemoryHistory(), routes: [ @@ -61,7 +61,7 @@ async function harness(blockB: boolean | "redirect" = false) { ); await router.push("/a"); await router.isReady(); - const native = createNativeRouter({ router, cache: { maxInactive: 2 } }); + const native = createNativeRouter({ router, cache: { maxInactive } }); const app = createApp(Page); app.use(router); 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 () => { const { native } = await harness(); await native.push("/b"); + const underlayKey = native.activeKey.value; await native.present("/modal", "sheet"); + expect( + native.entries.value.find( + (entry) => entry.key === native.activeKey.value, + ), + ).toMatchObject({ presentation: "sheet", underlayKey }); + await native.beginInteractive("dismiss"); expect(native.transaction.value).toMatchObject({ direction: "back", @@ -443,6 +450,19 @@ describe("native router transactions", () => { 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 () => { const { native } = await harness(); const first = await native.beginInteractive("push", "/b"); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 67a9544..dc3e79b 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -9,6 +9,7 @@ import { import { isNavigationFailure, loadRouteLocation, + routeLocationKey, START_LOCATION, type NavigationFailure, type RouteLocationNormalizedLoaded, @@ -32,6 +33,7 @@ import type { } from "./types"; export const nativeRouterKey = Symbol("native-vue-router"); +const nativeScopedRouteProperty = "__nativeVueRouterScopedRoute"; let entrySequence = 0; @@ -55,6 +57,8 @@ function entryFor( return { key: `${route.fullPath}::${++entrySequence}`, route, + presentation: + route.meta.native?.presentation ?? route.meta.native?.transition, status, mounted: true, synthetic, @@ -192,6 +196,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { install(app: App) { app.provide(nativeRouterKey, 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(() => { if ( this.mutableEntries.value.length === 0 && @@ -379,6 +401,13 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { ? this.siblingDirection(from.route, target.route) : "forward"); + if (isBack) { + from.presentation ??= presentation; + } else { + target.presentation = presentation; + target.underlayKey = presentation === "sheet" ? from.key : undefined; + } + const transaction: NativeTransaction = { id: ++this.transactionSequence, kind, @@ -733,6 +762,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { to: RouteLocationNormalizedLoaded, _from: RouteLocationNormalizedLoaded, ) { + const previousActiveKey = this.mutableActiveKey.value; const transaction = this.mutableTransaction.value; let target = transaction ? this.entryByKey(transaction.toKey) : undefined; if (target && target.route.fullPath !== to.fullPath) target = undefined; @@ -750,6 +780,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { this.mutableEntries.value = [...head, target]; } else { target.route = to; + target.presentation ??= + to.meta.native?.presentation ?? to.meta.native?.transition; target.mounted = true; target.status = "active"; target.committed = true; @@ -757,6 +789,8 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { target.evictionReason = undefined; this.touchEntries(); } + if (target.presentation === "sheet" && target.key !== previousActiveKey) + target.underlayKey = previousActiveKey || undefined; this.mutableActiveKey.value = target.key; this.acceptHistory(target, transaction); this.markStatuses(); @@ -857,6 +891,16 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } private enforceCache() { + const protectedUnderlayKeys = new Set(); + 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 .filter( (entry) => @@ -866,6 +910,7 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { ) .sort((a, b) => b.lastUsed - a.lastUsed); for (const entry of inactive) { + if (protectedUnderlayKeys.has(entry.key)) continue; if (!this.shouldRetainInactive(entry)) { const reason: NativeEvictionReason = entry.route.meta.native?.cache === false @@ -875,7 +920,10 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime { } } 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)) { this.evictEntry(entry, "cache-limit"); diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 80b8737..069cd76 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -1,6 +1,12 @@ :root { --nvr-duration: 360ms; --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, @@ -60,6 +66,18 @@ body, 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--to { visibility: visible; @@ -72,6 +90,17 @@ body, 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-view--from, .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 { z-index: 4; 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) 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, @@ -186,6 +222,81 @@ body, 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-view--from, .nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] @@ -213,8 +324,51 @@ body, 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) { .nvr-view { will-change: auto; } + + .nvr-sheet__surface { + transition: none; + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3b3a73..f285bfa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -19,7 +19,7 @@ export type NativeGestureKind = "push" | "pop" | "sibling" | "present" | "dismiss"; export type NativeDirection = "forward" | "back" | "up" | "down"; 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 NativeEvictionReason = | "cache-disabled" @@ -76,6 +76,10 @@ declare module "vue-router" { export interface NativeViewEntry { key: string; 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; mounted: boolean; synthetic: boolean; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 28fe892..c61b907 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -9,6 +9,6 @@ "outDir": "dist", "tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.vue"], "exclude": ["src/**/*.test.ts"] } diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index aee02b4..ea1376d 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from "vite"; import { resolve } from "node:path"; +import vue from "@vitejs/plugin-vue"; export default defineConfig({ + plugins: [vue()], build: { outDir: "dist", emptyOutDir: true, diff --git a/skills/integrate-native-vue-router/SKILL.md b/skills/integrate-native-vue-router/SKILL.md new file mode 100644 index 0000000..4d50810 --- /dev/null +++ b/skills/integrate-native-vue-router/SKILL.md @@ -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. diff --git a/skills/integrate-native-vue-router/agents/openai.yaml b/skills/integrate-native-vue-router/agents/openai.yaml new file mode 100644 index 0000000..4c1e07c --- /dev/null +++ b/skills/integrate-native-vue-router/agents/openai.yaml @@ -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." diff --git a/skills/integrate-native-vue-router/references/integration-reference.md b/skills/integrate-native-vue-router/references/integration-reference.md new file mode 100644 index 0000000..bdb1183 --- /dev/null +++ b/skills/integrate-native-vue-router/references/integration-reference.md @@ -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 + + + +``` + +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 + + + +``` + +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 +
...
+``` + +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 +replace(to, options?): Promise +sibling(to, options?): Promise +pop(): Promise +present(to, presentation = "modal"): Promise +dismiss(): Promise +preload(to): Promise +``` + +`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 +updateInteractive(progress, velocity?): void +finishInteractive(forceCommit?): Promise +cancelInteractive(): Promise +``` + +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 + + + + + + + + + + +``` + +## 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. diff --git a/usage.md b/usage.md new file mode 100644 index 0000000..3d77301 --- /dev/null +++ b/usage.md @@ -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 `` with `` and wrap +the navigation surface in `` to enable predictive Back. + +```vue + + + + +``` + +Nested `` 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 + +``` + +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 + + + +``` + +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 + + + +``` + +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 + + + +``` + +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 + + + +``` + +### 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 + + + +``` + +Supply fractional breakpoints to create snap points. Fractions are measured +against the route height after the safe top inset has been reserved: + +```vue + + + +``` + +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 + + + +``` + +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 + +``` + +`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 + + + + + + + + + + + +``` + +These components retain their normal Vue meaning: + +- `` 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()`. +- `` animates changes inside a layer. Native presentations animate + the route layers themselves. +- `` may show a fallback while an async preview component resolves. +- `` 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 + + + +``` + +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. diff --git a/vitest.config.ts b/vitest.config.ts index 6a4149e..f3fc81f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,10 @@ +import vue from "@vitejs/plugin-vue"; import { defineConfig } from "vitest/config"; 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: { environment: "happy-dom", include: ["packages/**/*.test.ts"],