Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.

This commit is contained in:
2026-07-22 07:04:39 +00:00
parent bfe364c57d
commit 6aed7606ad
48 changed files with 5454 additions and 84 deletions

View File

@@ -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<string>();
while (
presentedEntry?.presentation === "sheet" &&
presentedEntry.underlayKey &&
!seen.has(presentedEntry.underlayKey)
) {
if (presentedEntry.underlayKey === entryKey) return true;
seen.add(presentedEntry.underlayKey);
presentedEntry = runtime.entries.value.find(
(candidate) => candidate.key === presentedEntry?.underlayKey,
);
}
return false;
}
const role = computed<NativeViewRole>(() => {
const 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";

View File

@@ -0,0 +1,748 @@
import {
KeepAlive,
Suspense,
Teleport,
Transition,
computed,
createApp,
defineComponent,
h,
inject,
nextTick,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onMounted,
onUnmounted,
onUpdated,
provide,
reactive,
type App,
type Component,
type ComputedRef,
type InjectionKey,
type PropType,
type VNode,
} from "vue";
import {
RouterView,
createMemoryHistory,
createRouter,
useRoute,
useRouter,
type RouteLocationNormalizedLoaded,
type RouteRecordRaw,
type Router,
} from "vue-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNativeRouter } from "../runtime";
import type { NativeRouterRuntime } from "../types";
import {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
onNativeViewHide,
onNativeViewShow,
useNativeViewLifecycle,
} from "./lifecycle";
import NativeRouterView from "./NativeRouterView.vue";
const appValueKey: InjectionKey<string> = Symbol("compatibility-app-value");
const pageValueKey: InjectionKey<ComputedRef<string>> = Symbol(
"compatibility-page-value",
);
interface CompatibilityState {
revision: number;
kept: "a" | "b";
transitionVisible: boolean;
teleportVisible: boolean;
suspenseVisible: boolean;
}
interface CompatibilityFixtures {
state: CompatibilityState;
events: string[];
routes: RouteRecordRaw[];
resolveSuspense(): void;
}
interface MountedHarness {
app: App;
container: HTMLElement;
native: NativeRouterRuntime;
router: Router;
teleportTarget: HTMLElement;
}
const mountedApps: App[] = [];
beforeEach(() => {
vi.spyOn(window, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
vi.restoreAllMocks();
});
async function flushVue() {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
async function mountHarness(
routes: RouteRecordRaw[],
initialRoute: string,
rootComponent?: Component,
): Promise<MountedHarness> {
const router = createRouter({ history: createMemoryHistory(), routes });
await router.push(initialRoute);
await router.isReady();
const native = createNativeRouter({ router, cache: { maxInactive: 8 } });
const Root = defineComponent({
name: "CompatibilityTestRoot",
setup: () => () => h(NativeRouterView),
});
const app = createApp(rootComponent ?? Root);
app.use(router);
app.use(native);
app.provide(appValueKey, "provided-by-app");
const teleportTarget = document.createElement("div");
teleportTarget.id = "compatibility-teleport";
document.body.append(teleportTarget);
const container = document.createElement("div");
document.body.append(container);
app.mount(container);
mountedApps.push(app);
await flushVue();
return { app, container, native, router, teleportTarget };
}
function expectHookOrder(events: string[], prefix: string, hooks: string[]) {
let previous = -1;
for (const hook of hooks) {
const index = events.indexOf(`${prefix}${hook}`);
expect(index, `${prefix}${hook} should have fired`).toBeGreaterThan(
previous,
);
previous = index;
}
}
function createCompatibilityFixtures(): CompatibilityFixtures {
const state = reactive<CompatibilityState>({
revision: 0,
kept: "a",
transitionVisible: true,
teleportVisible: true,
suspenseVisible: false,
});
const events: string[] = [];
let resolveSuspense!: () => void;
const suspenseReady = new Promise<void>((resolve) => {
resolveSuspense = resolve;
});
const OptionsLifecycleProbe = defineComponent({
name: "OptionsLifecycleProbe",
props: {
label: { type: String, required: true },
revision: { type: Number, required: true },
},
beforeCreate() {
events.push(`options:${this.label}:beforeCreate`);
},
created() {
events.push(`options:${this.label}:created`);
},
beforeMount() {
events.push(`options:${this.label}:beforeMount`);
},
mounted() {
events.push(`options:${this.label}:mounted`);
},
beforeUpdate() {
events.push(`options:${this.label}:beforeUpdate`);
},
updated() {
events.push(`options:${this.label}:updated`);
},
beforeUnmount() {
events.push(`options:${this.label}:beforeUnmount`);
},
unmounted() {
events.push(`options:${this.label}:unmounted`);
},
render() {
return h(
"span",
{ "data-testid": "options-revision" },
String(this.revision),
);
},
});
const CompositionLifecycleProbe = defineComponent({
name: "CompositionLifecycleProbe",
props: {
label: { type: String, required: true },
revision: { type: Number, required: true },
},
setup(props) {
const event = (hook: string) =>
events.push(`composition:${props.label}:${hook}`);
onBeforeMount(() => event("beforeMount"));
onMounted(() => event("mounted"));
onBeforeUpdate(() => event("beforeUpdate"));
onUpdated(() => event("updated"));
onBeforeUnmount(() => event("beforeUnmount"));
onUnmounted(() => event("unmounted"));
return () =>
h(
"span",
{ "data-testid": "composition-revision" },
String(props.revision),
);
},
});
const KeepAliveProbe = defineComponent({
name: "KeepAliveProbe",
props: {
label: { type: String, required: true },
variant: { type: String as PropType<"a" | "b">, required: true },
},
setup(props) {
const event = (hook: string) =>
events.push(`keep:${props.label}:${props.variant}:${hook}`);
onBeforeMount(() => event("beforeMount"));
onMounted(() => event("mounted"));
onActivated(() => event("activated"));
onDeactivated(() => event("deactivated"));
onBeforeUnmount(() => event("beforeUnmount"));
onUnmounted(() => event("unmounted"));
return () =>
h(
"span",
{ "data-testid": `keep-alive-${props.variant}` },
props.variant,
);
},
});
const NativeLifecycleProbe = defineComponent({
name: "NativeLifecycleProbe",
props: { label: { type: String, required: true } },
setup(props) {
const lifecycle = useNativeViewLifecycle();
const event = (hook: string) =>
events.push(`native:${props.label}:${hook}`);
onNativeViewActivate(() => event("activate"));
onNativeViewDeactivate(() => event("deactivate"));
onNativeViewShow(() => event("show"));
onNativeViewHide(() => event("hide"));
onNativeViewEvict((reason) => event(`evict:${String(reason)}`));
return () =>
h("span", {
"data-testid": "native-lifecycle",
"data-active": String(lifecycle.isActive.value),
"data-visible": String(lifecycle.isVisible.value),
});
},
});
const TeleportedProbe = defineComponent({
name: "TeleportedProbe",
setup() {
const route = useRoute();
const appValue = inject(appValueKey);
const pageValue = inject(pageValueKey);
return () =>
h(
"div",
{
"data-testid": "teleported-probe",
"data-route": route.fullPath,
"data-app-value": appValue,
"data-page-value": pageValue?.value,
},
`teleport revision ${state.revision}`,
);
},
});
const OptionsRouteProbe = defineComponent({
name: "OptionsRouteProbe",
render() {
const route = (
this as unknown as { $route: RouteLocationNormalizedLoaded }
).$route;
return h("span", { "data-testid": "options-route" }, route.fullPath);
},
});
const AsyncProbe = defineComponent({
name: "AsyncProbe",
async setup() {
await suspenseReady;
return () => h("span", { "data-testid": "suspense-ready" }, "ready");
},
});
const CompatibilityPage = defineComponent({
name: "CompatibilityPage",
props: { id: String },
setup(props) {
const route = useRoute();
const router = useRouter();
const appValue = inject(appValueKey);
provide(
pageValueKey,
computed(() => `page:${route.fullPath}`),
);
const transitionEvent = (hook: string) =>
events.push(`transition:${route.fullPath}:${hook}`);
return () => {
const label = route.fullPath;
return h("article", { "data-testid": "compatibility-page" }, [
h("span", { "data-testid": "route-full-path" }, route.fullPath),
h("span", { "data-testid": "route-name" }, String(route.name)),
h("span", { "data-testid": "route-param" }, String(route.params.id)),
h("span", { "data-testid": "route-prop" }, String(props.id)),
h("span", { "data-testid": "route-query" }, String(route.query.tab)),
h("span", { "data-testid": "route-hash" }, route.hash),
h(
"span",
{ "data-testid": "route-meta" },
String(route.meta.section),
),
h(
"span",
{ "data-testid": "route-matched" },
route.matched.map((record) => String(record.name)).join(","),
),
h(
"span",
{ "data-testid": "router-current-route" },
router.currentRoute.value.fullPath,
),
h("span", { "data-testid": "injected-app-value" }, appValue),
h(OptionsRouteProbe),
h(OptionsLifecycleProbe, { label, revision: state.revision }),
h(CompositionLifecycleProbe, { label, revision: state.revision }),
h(KeepAlive, null, {
default: () =>
h(KeepAliveProbe, {
key: state.kept,
label,
variant: state.kept,
}),
}),
h(
Transition,
{
css: false,
onBeforeEnter: () => transitionEvent("beforeEnter"),
onEnter: (_element: Element, done: () => void) => {
transitionEvent("enter");
done();
},
onAfterEnter: () => transitionEvent("afterEnter"),
onBeforeLeave: () => transitionEvent("beforeLeave"),
onLeave: (_element: Element, done: () => void) => {
transitionEvent("leave");
done();
},
onAfterLeave: () => transitionEvent("afterLeave"),
},
{
default: () =>
state.transitionVisible
? h(
"div",
{ "data-testid": "transition-child" },
String(state.revision),
)
: null,
},
),
h(NativeLifecycleProbe, { label }),
state.teleportVisible
? h(Teleport, { to: "#compatibility-teleport" }, h(TeleportedProbe))
: null,
state.suspenseVisible
? h(
Suspense,
{ timeout: 0 },
{
default: () => h(AsyncProbe),
fallback: () =>
h(
"span",
{ "data-testid": "suspense-fallback" },
"loading",
),
},
)
: null,
]);
};
},
});
const UsersLayout = defineComponent({
name: "UsersLayout",
setup: () => () =>
h("div", { "data-testid": "users-layout" }, h(RouterView)),
});
const routes: RouteRecordRaw[] = [
{
path: "/users",
name: "users",
component: UsersLayout,
children: [
{
path: ":id",
name: "user",
component: CompatibilityPage,
props: true,
meta: { section: "people" },
},
],
},
{
path: "/other",
name: "other",
component: CompatibilityPage,
meta: { section: "other" },
},
];
return { state, events, routes, resolveSuspense };
}
function textWithin(root: ParentNode, testId: string) {
return root.querySelector(`[data-testid="${testId}"]`)?.textContent;
}
describe("NativeRouterView Vue compatibility", () => {
it("scopes params, query, hash, metadata, route props, and nested matches to a preview", async () => {
const fixtures = createCompatibilityFixtures();
const { container, native, router, teleportTarget } = await mountHarness(
fixtures.routes,
"/users/one?tab=summary#top",
);
const active = container.querySelector('[data-native-role="active"]')!;
expect(textWithin(active, "route-full-path")).toBe(
"/users/one?tab=summary#top",
);
expect(textWithin(active, "route-name")).toBe("user");
expect(textWithin(active, "route-param")).toBe("one");
expect(textWithin(active, "route-prop")).toBe("one");
expect(textWithin(active, "route-query")).toBe("summary");
expect(textWithin(active, "route-hash")).toBe("#top");
expect(textWithin(active, "route-meta")).toBe("people");
expect(textWithin(active, "route-matched")).toBe("users,user");
expect(textWithin(active, "options-route")).toBe(
"/users/one?tab=summary#top",
);
await native.beginInteractive("push", "/users/two?tab=activity#details");
await flushVue();
expect(router.currentRoute.value.fullPath).toBe(
"/users/one?tab=summary#top",
);
const preview = container.querySelector('[data-native-role="to"]')!;
expect(textWithin(preview, "route-full-path")).toBe(
"/users/two?tab=activity#details",
);
expect(textWithin(preview, "route-param")).toBe("two");
expect(textWithin(preview, "route-prop")).toBe("two");
expect(textWithin(preview, "route-query")).toBe("activity");
expect(textWithin(preview, "route-hash")).toBe("#details");
expect(textWithin(preview, "options-route")).toBe(
"/users/two?tab=activity#details",
);
expect(textWithin(preview, "router-current-route")).toBe(
"/users/one?tab=summary#top",
);
expect(
teleportTarget.querySelector(
'[data-route="/users/two?tab=activity#details"]',
),
).toMatchObject({
dataset: {
appValue: "provided-by-app",
pageValue: "page:/users/two?tab=activity#details",
},
});
await native.cancelInteractive();
await flushVue();
expect(container.querySelector('[data-native-role="to"]')).toBeNull();
expect(
teleportTarget.querySelector(
'[data-route="/users/two?tab=activity#details"]',
),
).toBeNull();
await native.push("/users/two?tab=activity#details", {
presentation: "none",
});
await flushVue();
const committed = container.querySelector('[data-native-role="active"]')!;
expect(textWithin(committed, "route-full-path")).toBe(
"/users/two?tab=activity#details",
);
expect(textWithin(committed, "options-route")).toBe(
"/users/two?tab=activity#details",
);
});
it("runs Options API and Composition API lifecycle hooks through updates and eviction", async () => {
const fixtures = createCompatibilityFixtures();
const { native } = await mountHarness(
fixtures.routes,
"/users/one?tab=summary",
);
const optionsPrefix = "options:/users/one?tab=summary:";
const compositionPrefix = "composition:/users/one?tab=summary:";
expectHookOrder(fixtures.events, optionsPrefix, [
"beforeCreate",
"created",
"beforeMount",
"mounted",
]);
expectHookOrder(fixtures.events, compositionPrefix, [
"beforeMount",
"mounted",
]);
expect(fixtures.events).toContain("native:/users/one?tab=summary:activate");
expect(fixtures.events).toContain("native:/users/one?tab=summary:show");
fixtures.state.revision += 1;
await flushVue();
expectHookOrder(fixtures.events, optionsPrefix, [
"beforeUpdate",
"updated",
]);
expectHookOrder(fixtures.events, compositionPrefix, [
"beforeUpdate",
"updated",
]);
await native.push("/other", { presentation: "none" });
await flushVue();
expect(fixtures.events).not.toContain(`${optionsPrefix}beforeUnmount`);
expect(fixtures.events).toContain(
"native:/users/one?tab=summary:deactivate",
);
expect(fixtures.events).toContain("native:/users/one?tab=summary:hide");
expect(fixtures.events).toContain("native:/other:activate");
expect(fixtures.events).toContain("native:/other:show");
native.trimCache({ includePinned: true });
await flushVue();
expectHookOrder(fixtures.events, optionsPrefix, [
"beforeUnmount",
"unmounted",
]);
expectHookOrder(fixtures.events, compositionPrefix, [
"beforeUnmount",
"unmounted",
]);
expect(fixtures.events).toContain(
"native:/users/one?tab=summary:evict:trimmed",
);
});
it("preserves Vue KeepAlive activation and deactivation semantics", async () => {
const fixtures = createCompatibilityFixtures();
await mountHarness(fixtures.routes, "/users/one");
expectHookOrder(fixtures.events, "keep:/users/one:a:", [
"beforeMount",
"mounted",
"activated",
]);
fixtures.state.kept = "b";
await flushVue();
expect(fixtures.events).toContain("keep:/users/one:a:deactivated");
expectHookOrder(fixtures.events, "keep:/users/one:b:", [
"beforeMount",
"mounted",
"activated",
]);
fixtures.state.kept = "a";
await flushVue();
expect(fixtures.events).toContain("keep:/users/one:b:deactivated");
expect(
fixtures.events.filter(
(event) => event === "keep:/users/one:a:activated",
),
).toHaveLength(2);
expect(
fixtures.events.filter((event) => event === "keep:/users/one:a:mounted"),
).toHaveLength(1);
});
it("supports the standard RouterView Transition and KeepAlive scoped-slot pattern", async () => {
const fixtures = createCompatibilityFixtures();
const WrappedRouterView = defineComponent({
name: "WrappedRouterView",
setup() {
return () =>
h(NativeRouterView, null, {
default: ({
Component: RoutedComponent,
}: {
Component: VNode | null;
}) =>
h(
Transition,
{ css: false },
{
default: () =>
h(KeepAlive, null, {
default: () => RoutedComponent,
}),
},
),
});
},
});
const { container, native } = await mountHarness(
fixtures.routes,
"/users/one?tab=summary",
WrappedRouterView,
);
expect(
textWithin(
container.querySelector('[data-native-role="active"]')!,
"options-route",
),
).toBe("/users/one?tab=summary");
await native.beginInteractive("push", "/users/two?tab=activity");
await flushVue();
const preview = container.querySelector('[data-native-role="to"]')!;
expect(textWithin(preview, "route-full-path")).toBe(
"/users/two?tab=activity",
);
expect(textWithin(preview, "options-route")).toBe(
"/users/two?tab=activity",
);
await native.cancelInteractive();
});
it("runs JavaScript Transition enter and leave hooks", async () => {
const fixtures = createCompatibilityFixtures();
const { container } = await mountHarness(fixtures.routes, "/users/one");
expect(
container.querySelector('[data-testid="transition-child"]'),
).not.toBeNull();
fixtures.state.transitionVisible = false;
await flushVue();
expect(
container.querySelector('[data-testid="transition-child"]'),
).toBeNull();
expectHookOrder(fixtures.events, "transition:/users/one:", [
"beforeLeave",
"leave",
"afterLeave",
]);
fixtures.state.transitionVisible = true;
await flushVue();
expect(
container.querySelector('[data-testid="transition-child"]'),
).not.toBeNull();
expectHookOrder(fixtures.events, "transition:/users/one:", [
"beforeEnter",
"enter",
"afterEnter",
]);
});
it("keeps Teleport reactive, preserves injection, and removes teleported content on eviction", async () => {
const fixtures = createCompatibilityFixtures();
const { native, teleportTarget } = await mountHarness(
fixtures.routes,
"/users/one",
);
const initial = teleportTarget.querySelector<HTMLElement>(
'[data-route="/users/one"]',
)!;
expect(initial.textContent).toBe("teleport revision 0");
expect(initial.dataset.appValue).toBe("provided-by-app");
expect(initial.dataset.pageValue).toBe("page:/users/one");
fixtures.state.revision += 1;
await flushVue();
expect(initial.textContent).toBe("teleport revision 1");
await native.push("/other", { presentation: "none" });
await flushVue();
expect(
teleportTarget.querySelector('[data-route="/users/one"]'),
).not.toBeNull();
expect(
teleportTarget.querySelector('[data-route="/other"]'),
).not.toBeNull();
native.trimCache({ includePinned: true });
await flushVue();
expect(
teleportTarget.querySelector('[data-route="/users/one"]'),
).toBeNull();
expect(
teleportTarget.querySelector('[data-route="/other"]'),
).not.toBeNull();
});
it("renders Suspense fallback and resolved async content inside a native view", async () => {
const fixtures = createCompatibilityFixtures();
const { container } = await mountHarness(fixtures.routes, "/users/one");
fixtures.state.suspenseVisible = true;
await flushVue();
expect(
container.querySelector('[data-testid="suspense-fallback"]')?.textContent,
).toBe("loading");
expect(
container.querySelector('[data-testid="suspense-ready"]'),
).toBeNull();
fixtures.resolveSuspense();
await flushVue();
expect(
container.querySelector('[data-testid="suspense-fallback"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="suspense-ready"]')?.textContent,
).toBe("ready");
});
});

View File

@@ -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<string>();
while (
presentedEntry?.presentation === "sheet" &&
presentedEntry.underlayKey &&
!seen.has(presentedEntry.underlayKey)
) {
if (presentedEntry.underlayKey === entry.key) return true;
seen.add(presentedEntry.underlayKey);
presentedEntry = nativeRuntime.entries.value.find(
(candidate) => candidate.key === presentedEntry?.underlayKey,
);
}
return false;
}
function layerStyleFor(entry: NativeViewEntry) {
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
"
>
<RouterView v-slot="{ Component, route }" :route="entry.route">
<slot
v-if="slots.default"
:Component="Component"
:route="route"
:entry="entry"
/>
<NativeRouteScope
v-else-if="Component"
:route="route"
:entry-key="entry.key"
>
<component :is="Component" />
<NativeRouteScope :route="route" :entry-key="entry.key">
<slot
v-if="slots.default"
:Component="Component"
:route="route"
:entry="entry"
/>
<component v-else-if="Component" :is="Component" />
</NativeRouteScope>
</RouterView>
</section>

View File

@@ -0,0 +1,182 @@
import { createApp, defineComponent, h, nextTick } from "vue";
import {
createMemoryHistory,
createRouter,
type RouteRecordRaw,
} from "vue-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNativeRouter } from "../runtime";
import NativeRouterView from "./NativeRouterView.vue";
import NativeSheet from "./NativeSheet.vue";
import {
adjacentSheetBreakpoint,
nearestSheetBreakpoint,
normalizeSheetBreakpoints,
} from "./sheet";
const mountedApps: ReturnType<typeof createApp>[] = [];
beforeEach(() => {
vi.spyOn(window, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
vi.restoreAllMocks();
});
async function flushVue() {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
describe("sheet breakpoints", () => {
it("normalizes, finds, and moves between fractional breakpoints", () => {
expect(normalizeSheetBreakpoints([1, 0.5, 0.5, -1, 1.2, 0.25])).toEqual([
0.25, 0.5, 1,
]);
expect(nearestSheetBreakpoint([0.25, 0.5, 1], 0.62)).toBe(0.5);
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "up")).toBe(1);
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "down")).toBe(0.25);
});
});
describe("NativeSheet", () => {
it("retains a visible inert underlay and exposes keyboard snap points", async () => {
const BasePage = defineComponent({
name: "BasePage",
setup: () => () => h("main", { "data-testid": "base" }, "Base"),
});
const SheetPage = defineComponent({
name: "SheetPage",
setup: () => () =>
h(
NativeSheet,
{ breakpoints: [0.5, 1], initialBreakpoint: 0.5 },
{ default: () => h("p", "Sheet content") },
),
});
const routes: RouteRecordRaw[] = [
{ path: "/", component: BasePage },
{
path: "/sheet",
component: SheetPage,
meta: { native: { presentation: "sheet", parent: "/" } },
},
];
const router = createRouter({ history: createMemoryHistory(), routes });
await router.push("/");
await router.isReady();
const native = createNativeRouter({ router });
const Root = defineComponent({
setup: () => () => h(NativeRouterView),
});
const app = createApp(Root);
app.use(router);
app.use(native);
const container = document.createElement("div");
document.body.append(container);
app.mount(container);
mountedApps.push(app);
await flushVue();
expect(await native.present("/sheet", "sheet")).toBe(true);
await flushVue();
const active = container.querySelector('[data-native-role="active"]');
const underlay = container.querySelector('[data-native-role="underlay"]');
const surface = container.querySelector<HTMLElement>("[data-native-sheet]");
const handle = container.querySelector<HTMLElement>(".nvr-sheet__handle");
const root = container.querySelector<HTMLElement>(".nvr-sheet");
const body = container.querySelector<HTMLElement>(".nvr-sheet__body");
expect(active?.getAttribute("data-native-view-presentation")).toBe("sheet");
expect(active?.hasAttribute("data-native-sheet-surface")).toBe(true);
expect(underlay?.hasAttribute("inert")).toBe(true);
expect(underlay?.getAttribute("aria-hidden")).toBe("true");
expect(surface?.dataset.nativeSheetMode).toBe("breakpoints");
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
handle?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
if (!root || !body) throw new Error("Sheet body did not render");
Object.defineProperty(root, "clientHeight", {
configurable: true,
value: 1_000,
});
Object.defineProperties(body, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 600 },
});
handle?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
const dispatchContentPointer = (
type: "pointerdown" | "pointermove" | "pointerup",
pointerId: number,
clientY: number,
) =>
body.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
clientX: 20,
clientY,
isPrimary: true,
pointerId,
pointerType: "mouse",
}),
);
// Once content wins a gesture, reaching an edge later must not reinterpret
// all of the gesture's accumulated distance as sheet movement.
body.scrollTop = 100;
dispatchContentPointer("pointerdown", 2, 220);
dispatchContentPointer("pointermove", 2, 170);
body.scrollTop = 300;
dispatchContentPointer("pointermove", 2, 120);
dispatchContentPointer("pointerup", 2, 120);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
// Reversing a content-owned gesture at the other boundary keeps content
// ownership until release instead of moving both systems.
body.scrollTop = 100;
dispatchContentPointer("pointerdown", 3, 180);
dispatchContentPointer("pointermove", 3, 120);
body.scrollTop = 0;
dispatchContentPointer("pointermove", 3, 260);
dispatchContentPointer("pointerup", 3, 260);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
body.scrollTop = 300;
dispatchContentPointer("pointerdown", 4, 220);
dispatchContentPointer("pointermove", 4, 120);
dispatchContentPointer("pointerup", 4, 120);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
body.scrollTop = 0;
dispatchContentPointer("pointerdown", 5, 120);
dispatchContentPointer("pointermove", 5, 220);
dispatchContentPointer("pointerup", 5, 220);
await flushVue();
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
expect(await native.dismiss()).toBe(true);
await flushVue();
expect(router.currentRoute.value.path).toBe("/");
});
});

View File

@@ -0,0 +1,562 @@
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
watch,
type PropType,
} from "vue";
import { useNativeRouter } from "./lifecycle";
import {
adjacentSheetBreakpoint,
nearestSheetBreakpoint,
normalizeSheetBreakpoints,
} from "./sheet";
defineOptions({ name: "NativeSheet", inheritAttrs: false });
const props = defineProps({
/** Fractions of the available, safe-area-contained route height. */
breakpoints: {
type: Array as PropType<number[]>,
default: () => [],
},
/** Initial fraction. The nearest declared breakpoint is used. */
initialBreakpoint: Number,
/** Current fraction for v-model. */
modelValue: Number,
dismissible: { type: Boolean, default: true },
backdropDismiss: { type: Boolean, default: true },
showHandle: { type: Boolean, default: true },
ariaLabel: { type: String, default: "Sheet" },
});
const emit = defineEmits<{
"update:modelValue": [value: number];
"breakpoint-change": [value: number];
dismiss: [];
}>();
const attrs = useAttrs();
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
const surface = ref<HTMLElement | null>(null);
const handle = ref<HTMLElement | null>(null);
const body = ref<HTMLElement | null>(null);
const content = ref<HTMLElement | null>(null);
const height = ref<number>();
const activeBreakpoint = ref<number>();
const dragging = ref(false);
const ready = ref(false);
const dismissing = ref(false);
const normalizedBreakpoints = computed(() =>
normalizeSheetBreakpoints(props.breakpoints),
);
const usesBreakpoints = computed(() => normalizedBreakpoints.value.length > 0);
const heightStyle = computed(() =>
height.value === undefined ? undefined : `${height.value}px`,
);
const breakpointLabel = computed(() =>
activeBreakpoint.value === undefined
? "content"
: String(activeBreakpoint.value),
);
let resizeObserver: ResizeObserver | undefined;
let owningLayer: HTMLElement | null = null;
let pointerId = -1;
let touchId = -1;
let candidateX = 0;
let candidateY = 0;
let candidateTime = 0;
let startY = 0;
let startHeight = 0;
let lastY = 0;
let lastTime = 0;
let velocity = 0;
let moved = false;
let wheelDistance = 0;
let wheelDirection: "up" | "down" | undefined;
let wheelOwner: "content" | "sheet" | undefined;
let wheelLocked = false;
let wheelResetTimer: ReturnType<typeof setTimeout> | undefined;
let contentGestureOwner: "pending" | "content" | "sheet" | undefined;
function availableHeight() {
return Math.max(1, root.value?.clientHeight ?? window.innerHeight);
}
function bodyPadding() {
if (!body.value) return 0;
const style = getComputedStyle(body.value);
return (
(Number.parseFloat(style.paddingTop) || 0) +
(Number.parseFloat(style.paddingBottom) || 0)
);
}
function naturalHeight() {
return Math.min(
availableHeight(),
Math.max(
1,
(handle.value?.offsetHeight ?? 0) +
(content.value?.scrollHeight ?? 0) +
bodyPadding(),
),
);
}
function requestedBreakpoint() {
const points = normalizedBreakpoints.value;
if (!points.length) return undefined;
return nearestSheetBreakpoint(
points,
props.modelValue ??
activeBreakpoint.value ??
props.initialBreakpoint ??
points[0]!,
);
}
function measure() {
const breakpoint = requestedBreakpoint();
activeBreakpoint.value = breakpoint;
height.value = breakpoint ? availableHeight() * breakpoint : naturalHeight();
}
function setBreakpoint(breakpoint: number, notify = true) {
const nearest = nearestSheetBreakpoint(
normalizedBreakpoints.value,
breakpoint,
);
if (nearest === undefined) return measure();
activeBreakpoint.value = nearest;
height.value = availableHeight() * nearest;
if (notify) {
emit("update:modelValue", nearest);
emit("breakpoint-change", nearest);
}
}
async function dismissSheet() {
if (!props.dismissible || dismissing.value) return false;
dismissing.value = true;
emit("dismiss");
const dismissed = await runtime.dismiss();
dismissing.value = false;
if (!dismissed) measure();
return dismissed;
}
function beginDrag(clientY: number, timestamp: number) {
startY = lastY = clientY;
lastTime = timestamp;
startHeight = height.value ?? surface.value?.offsetHeight ?? naturalHeight();
velocity = 0;
moved = false;
dragging.value = true;
}
function updateDrag(clientY: number, timestamp: number) {
const delta = clientY - startY;
moved ||= Math.abs(delta) > 3;
const minimum = usesBreakpoints.value
? availableHeight() * (normalizedBreakpoints.value[0] ?? 0.1) * 0.55
: naturalHeight() * 0.55;
height.value = Math.max(
Math.min(72, availableHeight()),
Math.min(availableHeight(), Math.max(minimum, startHeight - delta)),
);
const elapsed = Math.max(8, timestamp - lastTime);
velocity = ((clientY - lastY) * 1000) / elapsed / availableHeight();
lastY = clientY;
lastTime = timestamp;
}
async function finishDrag() {
dragging.value = false;
if (!moved) return measure();
const currentHeight = height.value ?? startHeight;
const currentFraction = currentHeight / availableHeight();
const points = normalizedBreakpoints.value;
const smallest = points[0];
if (!points.length) {
if (
props.dismissible &&
(currentHeight < naturalHeight() * 0.72 || velocity > 1.1)
)
return void (await dismissSheet());
return measure();
}
if (
props.dismissible &&
smallest !== undefined &&
currentFraction < smallest * 0.72
)
return void (await dismissSheet());
const active = activeBreakpoint.value ?? smallest!;
if (Math.abs(velocity) > 0.65) {
const direction = velocity < 0 ? "up" : "down";
const adjacent = adjacentSheetBreakpoint(points, active, direction);
if (adjacent !== undefined) return setBreakpoint(adjacent);
if (direction === "down" && props.dismissible)
return void (await dismissSheet());
}
setBreakpoint(nearestSheetBreakpoint(points, currentFraction) ?? active);
}
function cancelDrag() {
dragging.value = false;
measure();
}
function pointerDown(event: PointerEvent) {
if (!event.isPrimary || event.button !== 0 || dismissing.value) return;
event.preventDefault();
event.stopPropagation();
contentGestureOwner = "sheet";
pointerId = event.pointerId;
beginDrag(event.clientY, event.timeStamp);
handle.value?.setPointerCapture?.(pointerId);
}
function pointerMove(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientY, event.timeStamp);
}
async function pointerUp(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
handle.value?.releasePointerCapture?.(pointerId);
pointerId = -1;
await finishDrag();
contentGestureOwner = undefined;
}
function pointerCancel(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
pointerId = -1;
cancelDrag();
contentGestureOwner = undefined;
}
function atTop() {
return (body.value?.scrollTop ?? 0) <= 1;
}
function atBottom() {
const element = body.value;
if (!element) return true;
return element.scrollTop + element.clientHeight >= element.scrollHeight - 1;
}
function canResizeFromContent(direction: "up" | "down") {
const points = normalizedBreakpoints.value;
const active = activeBreakpoint.value;
if (direction === "up")
return Boolean(
atBottom() &&
active !== undefined &&
adjacentSheetBreakpoint(points, active, "up") !== undefined,
);
return Boolean(
atTop() &&
(props.dismissible ||
(active !== undefined &&
adjacentSheetBreakpoint(points, active, "down") !== undefined)),
);
}
function contentGestureDirection(deltaY: number) {
return deltaY < 0 ? ("up" as const) : ("down" as const);
}
function shouldClaimContentGesture(deltaX: number, deltaY: number) {
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
return false;
return canResizeFromContent(contentGestureDirection(deltaY));
}
function contentPointerDown(event: PointerEvent) {
if (
event.pointerType === "touch" ||
!event.isPrimary ||
event.button !== 0 ||
dismissing.value
)
return;
pointerId = event.pointerId;
contentGestureOwner = "pending";
candidateX = event.clientX;
candidateY = event.clientY;
candidateTime = event.timeStamp;
}
function contentPointerMove(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
if (contentGestureOwner === "pending") {
const deltaX = event.clientX - candidateX;
const deltaY = event.clientY - candidateY;
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
return;
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
? "sheet"
: "content";
if (contentGestureOwner === "content") return;
beginDrag(candidateY, candidateTime);
body.value?.setPointerCapture?.(pointerId);
}
if (contentGestureOwner !== "sheet") return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientY, event.timeStamp);
}
async function contentPointerUp(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
body.value?.releasePointerCapture?.(pointerId);
pointerId = -1;
if (dragging.value) await finishDrag();
contentGestureOwner = undefined;
}
function contentPointerCancel(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
pointerId = -1;
if (dragging.value) cancelDrag();
contentGestureOwner = undefined;
}
function trackedTouch(list: TouchList) {
return [...list].find((touch) => touch.identifier === touchId);
}
function contentTouchStart(event: TouchEvent) {
if (touchId !== -1 || dismissing.value) return;
const touch = event.changedTouches[0];
if (!touch) return;
touchId = touch.identifier;
contentGestureOwner = "pending";
candidateX = touch.clientX;
candidateY = touch.clientY;
candidateTime = event.timeStamp;
}
function contentTouchMove(event: TouchEvent) {
const touch = trackedTouch(event.touches);
if (!touch) return;
if (contentGestureOwner === "pending") {
const deltaX = touch.clientX - candidateX;
const deltaY = touch.clientY - candidateY;
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
return;
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
? "sheet"
: "content";
if (contentGestureOwner === "content") return;
beginDrag(candidateY, candidateTime);
}
if (contentGestureOwner !== "sheet") return;
if (event.cancelable) event.preventDefault();
event.stopPropagation();
updateDrag(touch.clientY, event.timeStamp);
}
async function contentTouchEnd(event: TouchEvent) {
if (!trackedTouch(event.changedTouches)) return;
touchId = -1;
if (dragging.value) await finishDrag();
contentGestureOwner = undefined;
}
function contentTouchCancel(event: TouchEvent) {
if (!trackedTouch(event.changedTouches)) return;
touchId = -1;
if (dragging.value) cancelDrag();
contentGestureOwner = undefined;
}
function resetWheelHandoff() {
if (wheelResetTimer) clearTimeout(wheelResetTimer);
wheelDistance = 0;
wheelDirection = undefined;
wheelOwner = undefined;
wheelLocked = false;
wheelResetTimer = undefined;
}
function contentWheel(event: WheelEvent) {
const direction = event.deltaY > 0 ? ("up" as const) : ("down" as const);
if (!event.deltaY) return;
if (wheelResetTimer) clearTimeout(wheelResetTimer);
wheelResetTimer = setTimeout(resetWheelHandoff, 240);
if (!wheelOwner)
wheelOwner = canResizeFromContent(direction) ? "sheet" : "content";
if (wheelOwner === "content") return;
event.preventDefault();
event.stopPropagation();
if (wheelDirection !== direction) wheelDistance = 0;
wheelDirection = direction;
wheelDistance += Math.abs(event.deltaY);
if (!wheelLocked && wheelDistance >= 48) {
const active = activeBreakpoint.value;
const adjacent =
active === undefined
? undefined
: adjacentSheetBreakpoint(
normalizedBreakpoints.value,
active,
direction,
);
if (adjacent !== undefined) setBreakpoint(adjacent);
else if (direction === "down") void dismissSheet();
wheelLocked = true;
}
}
function keyDown(event: KeyboardEvent) {
const points = normalizedBreakpoints.value;
const current = activeBreakpoint.value;
if (event.key === "Escape") {
event.preventDefault();
void dismissSheet();
return;
}
if (!points.length || current === undefined) return;
const direction =
event.key === "ArrowUp"
? "up"
: event.key === "ArrowDown"
? "down"
: undefined;
const target =
event.key === "Home"
? points[0]
: event.key === "End"
? points.at(-1)
: direction
? adjacentSheetBreakpoint(points, current, direction)
: undefined;
if (target !== undefined) {
event.preventDefault();
setBreakpoint(target);
} else if (direction === "down" && props.dismissible) {
event.preventDefault();
void dismissSheet();
}
}
function surfaceKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || event.target === handle.value) return;
event.preventDefault();
void dismissSheet();
}
watch(
() => [props.modelValue, props.initialBreakpoint, props.breakpoints] as const,
() => {
if (!dragging.value) void nextTick(measure);
},
{ deep: true },
);
onMounted(() => {
owningLayer = root.value?.closest<HTMLElement>(".nvr-view") ?? null;
if (owningLayer) owningLayer.dataset.nativeSheetSurface = "";
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
if (!dragging.value) measure();
});
if (root.value) resizeObserver.observe(root.value);
if (content.value) resizeObserver.observe(content.value);
}
void nextTick(() => {
measure();
requestAnimationFrame(() => (ready.value = true));
});
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
if (wheelResetTimer) clearTimeout(wheelResetTimer);
if (owningLayer) delete owningLayer.dataset.nativeSheetSurface;
});
</script>
<template>
<div ref="root" v-bind="attrs" class="nvr-sheet">
<button
v-if="backdropDismiss && dismissible"
class="nvr-sheet__backdrop"
type="button"
aria-label="Close sheet"
@click="dismissSheet"
/>
<div v-else class="nvr-sheet__backdrop" aria-hidden="true" />
<section
ref="surface"
class="nvr-sheet__surface"
:style="{ height: heightStyle }"
role="dialog"
aria-modal="true"
:aria-label="ariaLabel"
data-native-sheet
:data-native-sheet-mode="usesBreakpoints ? 'breakpoints' : 'content'"
:data-native-sheet-breakpoint="breakpointLabel"
:data-native-sheet-dragging="String(dragging)"
:data-native-sheet-ready="String(ready)"
@keydown="surfaceKeyDown"
>
<div
v-if="showHandle"
ref="handle"
class="nvr-sheet__handle"
:role="usesBreakpoints ? 'slider' : undefined"
:tabindex="0"
aria-label="Resize or dismiss sheet"
aria-orientation="vertical"
:aria-valuemin="usesBreakpoints ? normalizedBreakpoints[0] : undefined"
:aria-valuemax="
usesBreakpoints ? normalizedBreakpoints.at(-1) : undefined
"
:aria-valuenow="usesBreakpoints ? activeBreakpoint : undefined"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="pointerUp"
@pointercancel="pointerCancel"
@keydown="keyDown"
>
<slot name="handle">
<span aria-hidden="true" />
</slot>
</div>
<div
ref="body"
class="nvr-sheet__body"
@pointerdown="contentPointerDown"
@pointermove="contentPointerMove"
@pointerup="contentPointerUp"
@pointercancel="contentPointerCancel"
@touchstart="contentTouchStart"
@touchmove="contentTouchMove"
@touchend="contentTouchEnd"
@touchcancel="contentTouchCancel"
@wheel="contentWheel"
>
<div ref="content" class="nvr-sheet__content"><slot /></div>
</div>
</section>
</div>
</template>

View File

@@ -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,

View File

@@ -0,0 +1,28 @@
export function normalizeSheetBreakpoints(values: readonly number[]) {
return [...new Set(values.filter((value) => value > 0 && value <= 1))].sort(
(left, right) => left - right,
);
}
export function nearestSheetBreakpoint(
breakpoints: readonly number[],
value: number,
) {
return breakpoints.reduce<number | undefined>((nearest, candidate) => {
if (nearest === undefined) return candidate;
return Math.abs(candidate - value) < Math.abs(nearest - value)
? candidate
: nearest;
}, undefined);
}
export function adjacentSheetBreakpoint(
breakpoints: readonly number[],
current: number,
direction: "up" | "down",
) {
const normalized = normalizeSheetBreakpoints(breakpoints);
if (direction === "up")
return normalized.find((value) => value > current + 0.001);
return [...normalized].reverse().find((value) => value < current - 0.001);
}

View File

@@ -11,7 +11,7 @@ import { createNativeNavigationProfiler } from "./profiler";
const Page = defineComponent({ template: "<div>page</div>" });
async function harness(blockB: boolean | "redirect" = false) {
async function harness(blockB: boolean | "redirect" = false, maxInactive = 2) {
const router = createRouter({
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");

View File

@@ -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<string>();
let presentedEntry = this.activeEntry();
while (
presentedEntry?.presentation === "sheet" &&
presentedEntry.underlayKey &&
!protectedUnderlayKeys.has(presentedEntry.underlayKey)
) {
protectedUnderlayKeys.add(presentedEntry.underlayKey);
presentedEntry = this.entryByKey(presentedEntry.underlayKey);
}
const inactive = this.mutableEntries.value
.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");

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -9,6 +9,6 @@
"outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -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,