Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.
This commit is contained in:
@@ -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";
|
||||
|
||||
748
packages/core/src/components/NativeRouterView.test.ts
Normal file
748
packages/core/src/components/NativeRouterView.test.ts
Normal file
@@ -0,0 +1,748 @@
|
||||
import {
|
||||
KeepAlive,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Transition,
|
||||
computed,
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
provide,
|
||||
reactive,
|
||||
type App,
|
||||
type Component,
|
||||
type ComputedRef,
|
||||
type InjectionKey,
|
||||
type PropType,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
import {
|
||||
RouterView,
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
useRoute,
|
||||
useRouter,
|
||||
type RouteLocationNormalizedLoaded,
|
||||
type RouteRecordRaw,
|
||||
type Router,
|
||||
} from "vue-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNativeRouter } from "../runtime";
|
||||
import type { NativeRouterRuntime } from "../types";
|
||||
import {
|
||||
onNativeViewActivate,
|
||||
onNativeViewDeactivate,
|
||||
onNativeViewEvict,
|
||||
onNativeViewHide,
|
||||
onNativeViewShow,
|
||||
useNativeViewLifecycle,
|
||||
} from "./lifecycle";
|
||||
import NativeRouterView from "./NativeRouterView.vue";
|
||||
|
||||
const appValueKey: InjectionKey<string> = Symbol("compatibility-app-value");
|
||||
const pageValueKey: InjectionKey<ComputedRef<string>> = Symbol(
|
||||
"compatibility-page-value",
|
||||
);
|
||||
|
||||
interface CompatibilityState {
|
||||
revision: number;
|
||||
kept: "a" | "b";
|
||||
transitionVisible: boolean;
|
||||
teleportVisible: boolean;
|
||||
suspenseVisible: boolean;
|
||||
}
|
||||
|
||||
interface CompatibilityFixtures {
|
||||
state: CompatibilityState;
|
||||
events: string[];
|
||||
routes: RouteRecordRaw[];
|
||||
resolveSuspense(): void;
|
||||
}
|
||||
|
||||
interface MountedHarness {
|
||||
app: App;
|
||||
container: HTMLElement;
|
||||
native: NativeRouterRuntime;
|
||||
router: Router;
|
||||
teleportTarget: HTMLElement;
|
||||
}
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, "matchMedia").mockReturnValue({
|
||||
matches: true,
|
||||
} as MediaQueryList);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function flushVue() {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function mountHarness(
|
||||
routes: RouteRecordRaw[],
|
||||
initialRoute: string,
|
||||
rootComponent?: Component,
|
||||
): Promise<MountedHarness> {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||
await router.push(initialRoute);
|
||||
await router.isReady();
|
||||
|
||||
const native = createNativeRouter({ router, cache: { maxInactive: 8 } });
|
||||
const Root = defineComponent({
|
||||
name: "CompatibilityTestRoot",
|
||||
setup: () => () => h(NativeRouterView),
|
||||
});
|
||||
const app = createApp(rootComponent ?? Root);
|
||||
app.use(router);
|
||||
app.use(native);
|
||||
app.provide(appValueKey, "provided-by-app");
|
||||
|
||||
const teleportTarget = document.createElement("div");
|
||||
teleportTarget.id = "compatibility-teleport";
|
||||
document.body.append(teleportTarget);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.mount(container);
|
||||
mountedApps.push(app);
|
||||
await flushVue();
|
||||
|
||||
return { app, container, native, router, teleportTarget };
|
||||
}
|
||||
|
||||
function expectHookOrder(events: string[], prefix: string, hooks: string[]) {
|
||||
let previous = -1;
|
||||
for (const hook of hooks) {
|
||||
const index = events.indexOf(`${prefix}${hook}`);
|
||||
expect(index, `${prefix}${hook} should have fired`).toBeGreaterThan(
|
||||
previous,
|
||||
);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
function createCompatibilityFixtures(): CompatibilityFixtures {
|
||||
const state = reactive<CompatibilityState>({
|
||||
revision: 0,
|
||||
kept: "a",
|
||||
transitionVisible: true,
|
||||
teleportVisible: true,
|
||||
suspenseVisible: false,
|
||||
});
|
||||
const events: string[] = [];
|
||||
let resolveSuspense!: () => void;
|
||||
const suspenseReady = new Promise<void>((resolve) => {
|
||||
resolveSuspense = resolve;
|
||||
});
|
||||
|
||||
const OptionsLifecycleProbe = defineComponent({
|
||||
name: "OptionsLifecycleProbe",
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
revision: { type: Number, required: true },
|
||||
},
|
||||
beforeCreate() {
|
||||
events.push(`options:${this.label}:beforeCreate`);
|
||||
},
|
||||
created() {
|
||||
events.push(`options:${this.label}:created`);
|
||||
},
|
||||
beforeMount() {
|
||||
events.push(`options:${this.label}:beforeMount`);
|
||||
},
|
||||
mounted() {
|
||||
events.push(`options:${this.label}:mounted`);
|
||||
},
|
||||
beforeUpdate() {
|
||||
events.push(`options:${this.label}:beforeUpdate`);
|
||||
},
|
||||
updated() {
|
||||
events.push(`options:${this.label}:updated`);
|
||||
},
|
||||
beforeUnmount() {
|
||||
events.push(`options:${this.label}:beforeUnmount`);
|
||||
},
|
||||
unmounted() {
|
||||
events.push(`options:${this.label}:unmounted`);
|
||||
},
|
||||
render() {
|
||||
return h(
|
||||
"span",
|
||||
{ "data-testid": "options-revision" },
|
||||
String(this.revision),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const CompositionLifecycleProbe = defineComponent({
|
||||
name: "CompositionLifecycleProbe",
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
revision: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const event = (hook: string) =>
|
||||
events.push(`composition:${props.label}:${hook}`);
|
||||
onBeforeMount(() => event("beforeMount"));
|
||||
onMounted(() => event("mounted"));
|
||||
onBeforeUpdate(() => event("beforeUpdate"));
|
||||
onUpdated(() => event("updated"));
|
||||
onBeforeUnmount(() => event("beforeUnmount"));
|
||||
onUnmounted(() => event("unmounted"));
|
||||
return () =>
|
||||
h(
|
||||
"span",
|
||||
{ "data-testid": "composition-revision" },
|
||||
String(props.revision),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const KeepAliveProbe = defineComponent({
|
||||
name: "KeepAliveProbe",
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
variant: { type: String as PropType<"a" | "b">, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const event = (hook: string) =>
|
||||
events.push(`keep:${props.label}:${props.variant}:${hook}`);
|
||||
onBeforeMount(() => event("beforeMount"));
|
||||
onMounted(() => event("mounted"));
|
||||
onActivated(() => event("activated"));
|
||||
onDeactivated(() => event("deactivated"));
|
||||
onBeforeUnmount(() => event("beforeUnmount"));
|
||||
onUnmounted(() => event("unmounted"));
|
||||
return () =>
|
||||
h(
|
||||
"span",
|
||||
{ "data-testid": `keep-alive-${props.variant}` },
|
||||
props.variant,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const NativeLifecycleProbe = defineComponent({
|
||||
name: "NativeLifecycleProbe",
|
||||
props: { label: { type: String, required: true } },
|
||||
setup(props) {
|
||||
const lifecycle = useNativeViewLifecycle();
|
||||
const event = (hook: string) =>
|
||||
events.push(`native:${props.label}:${hook}`);
|
||||
onNativeViewActivate(() => event("activate"));
|
||||
onNativeViewDeactivate(() => event("deactivate"));
|
||||
onNativeViewShow(() => event("show"));
|
||||
onNativeViewHide(() => event("hide"));
|
||||
onNativeViewEvict((reason) => event(`evict:${String(reason)}`));
|
||||
return () =>
|
||||
h("span", {
|
||||
"data-testid": "native-lifecycle",
|
||||
"data-active": String(lifecycle.isActive.value),
|
||||
"data-visible": String(lifecycle.isVisible.value),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const TeleportedProbe = defineComponent({
|
||||
name: "TeleportedProbe",
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const appValue = inject(appValueKey);
|
||||
const pageValue = inject(pageValueKey);
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-testid": "teleported-probe",
|
||||
"data-route": route.fullPath,
|
||||
"data-app-value": appValue,
|
||||
"data-page-value": pageValue?.value,
|
||||
},
|
||||
`teleport revision ${state.revision}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const OptionsRouteProbe = defineComponent({
|
||||
name: "OptionsRouteProbe",
|
||||
render() {
|
||||
const route = (
|
||||
this as unknown as { $route: RouteLocationNormalizedLoaded }
|
||||
).$route;
|
||||
return h("span", { "data-testid": "options-route" }, route.fullPath);
|
||||
},
|
||||
});
|
||||
|
||||
const AsyncProbe = defineComponent({
|
||||
name: "AsyncProbe",
|
||||
async setup() {
|
||||
await suspenseReady;
|
||||
return () => h("span", { "data-testid": "suspense-ready" }, "ready");
|
||||
},
|
||||
});
|
||||
|
||||
const CompatibilityPage = defineComponent({
|
||||
name: "CompatibilityPage",
|
||||
props: { id: String },
|
||||
setup(props) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appValue = inject(appValueKey);
|
||||
provide(
|
||||
pageValueKey,
|
||||
computed(() => `page:${route.fullPath}`),
|
||||
);
|
||||
|
||||
const transitionEvent = (hook: string) =>
|
||||
events.push(`transition:${route.fullPath}:${hook}`);
|
||||
|
||||
return () => {
|
||||
const label = route.fullPath;
|
||||
return h("article", { "data-testid": "compatibility-page" }, [
|
||||
h("span", { "data-testid": "route-full-path" }, route.fullPath),
|
||||
h("span", { "data-testid": "route-name" }, String(route.name)),
|
||||
h("span", { "data-testid": "route-param" }, String(route.params.id)),
|
||||
h("span", { "data-testid": "route-prop" }, String(props.id)),
|
||||
h("span", { "data-testid": "route-query" }, String(route.query.tab)),
|
||||
h("span", { "data-testid": "route-hash" }, route.hash),
|
||||
h(
|
||||
"span",
|
||||
{ "data-testid": "route-meta" },
|
||||
String(route.meta.section),
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ "data-testid": "route-matched" },
|
||||
route.matched.map((record) => String(record.name)).join(","),
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ "data-testid": "router-current-route" },
|
||||
router.currentRoute.value.fullPath,
|
||||
),
|
||||
h("span", { "data-testid": "injected-app-value" }, appValue),
|
||||
h(OptionsRouteProbe),
|
||||
h(OptionsLifecycleProbe, { label, revision: state.revision }),
|
||||
h(CompositionLifecycleProbe, { label, revision: state.revision }),
|
||||
h(KeepAlive, null, {
|
||||
default: () =>
|
||||
h(KeepAliveProbe, {
|
||||
key: state.kept,
|
||||
label,
|
||||
variant: state.kept,
|
||||
}),
|
||||
}),
|
||||
h(
|
||||
Transition,
|
||||
{
|
||||
css: false,
|
||||
onBeforeEnter: () => transitionEvent("beforeEnter"),
|
||||
onEnter: (_element: Element, done: () => void) => {
|
||||
transitionEvent("enter");
|
||||
done();
|
||||
},
|
||||
onAfterEnter: () => transitionEvent("afterEnter"),
|
||||
onBeforeLeave: () => transitionEvent("beforeLeave"),
|
||||
onLeave: (_element: Element, done: () => void) => {
|
||||
transitionEvent("leave");
|
||||
done();
|
||||
},
|
||||
onAfterLeave: () => transitionEvent("afterLeave"),
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
state.transitionVisible
|
||||
? h(
|
||||
"div",
|
||||
{ "data-testid": "transition-child" },
|
||||
String(state.revision),
|
||||
)
|
||||
: null,
|
||||
},
|
||||
),
|
||||
h(NativeLifecycleProbe, { label }),
|
||||
state.teleportVisible
|
||||
? h(Teleport, { to: "#compatibility-teleport" }, h(TeleportedProbe))
|
||||
: null,
|
||||
state.suspenseVisible
|
||||
? h(
|
||||
Suspense,
|
||||
{ timeout: 0 },
|
||||
{
|
||||
default: () => h(AsyncProbe),
|
||||
fallback: () =>
|
||||
h(
|
||||
"span",
|
||||
{ "data-testid": "suspense-fallback" },
|
||||
"loading",
|
||||
),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const UsersLayout = defineComponent({
|
||||
name: "UsersLayout",
|
||||
setup: () => () =>
|
||||
h("div", { "data-testid": "users-layout" }, h(RouterView)),
|
||||
});
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/users",
|
||||
name: "users",
|
||||
component: UsersLayout,
|
||||
children: [
|
||||
{
|
||||
path: ":id",
|
||||
name: "user",
|
||||
component: CompatibilityPage,
|
||||
props: true,
|
||||
meta: { section: "people" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/other",
|
||||
name: "other",
|
||||
component: CompatibilityPage,
|
||||
meta: { section: "other" },
|
||||
},
|
||||
];
|
||||
|
||||
return { state, events, routes, resolveSuspense };
|
||||
}
|
||||
|
||||
function textWithin(root: ParentNode, testId: string) {
|
||||
return root.querySelector(`[data-testid="${testId}"]`)?.textContent;
|
||||
}
|
||||
|
||||
describe("NativeRouterView Vue compatibility", () => {
|
||||
it("scopes params, query, hash, metadata, route props, and nested matches to a preview", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
const { container, native, router, teleportTarget } = await mountHarness(
|
||||
fixtures.routes,
|
||||
"/users/one?tab=summary#top",
|
||||
);
|
||||
|
||||
const active = container.querySelector('[data-native-role="active"]')!;
|
||||
expect(textWithin(active, "route-full-path")).toBe(
|
||||
"/users/one?tab=summary#top",
|
||||
);
|
||||
expect(textWithin(active, "route-name")).toBe("user");
|
||||
expect(textWithin(active, "route-param")).toBe("one");
|
||||
expect(textWithin(active, "route-prop")).toBe("one");
|
||||
expect(textWithin(active, "route-query")).toBe("summary");
|
||||
expect(textWithin(active, "route-hash")).toBe("#top");
|
||||
expect(textWithin(active, "route-meta")).toBe("people");
|
||||
expect(textWithin(active, "route-matched")).toBe("users,user");
|
||||
expect(textWithin(active, "options-route")).toBe(
|
||||
"/users/one?tab=summary#top",
|
||||
);
|
||||
|
||||
await native.beginInteractive("push", "/users/two?tab=activity#details");
|
||||
await flushVue();
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
"/users/one?tab=summary#top",
|
||||
);
|
||||
const preview = container.querySelector('[data-native-role="to"]')!;
|
||||
expect(textWithin(preview, "route-full-path")).toBe(
|
||||
"/users/two?tab=activity#details",
|
||||
);
|
||||
expect(textWithin(preview, "route-param")).toBe("two");
|
||||
expect(textWithin(preview, "route-prop")).toBe("two");
|
||||
expect(textWithin(preview, "route-query")).toBe("activity");
|
||||
expect(textWithin(preview, "route-hash")).toBe("#details");
|
||||
expect(textWithin(preview, "options-route")).toBe(
|
||||
"/users/two?tab=activity#details",
|
||||
);
|
||||
expect(textWithin(preview, "router-current-route")).toBe(
|
||||
"/users/one?tab=summary#top",
|
||||
);
|
||||
expect(
|
||||
teleportTarget.querySelector(
|
||||
'[data-route="/users/two?tab=activity#details"]',
|
||||
),
|
||||
).toMatchObject({
|
||||
dataset: {
|
||||
appValue: "provided-by-app",
|
||||
pageValue: "page:/users/two?tab=activity#details",
|
||||
},
|
||||
});
|
||||
|
||||
await native.cancelInteractive();
|
||||
await flushVue();
|
||||
expect(container.querySelector('[data-native-role="to"]')).toBeNull();
|
||||
expect(
|
||||
teleportTarget.querySelector(
|
||||
'[data-route="/users/two?tab=activity#details"]',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
await native.push("/users/two?tab=activity#details", {
|
||||
presentation: "none",
|
||||
});
|
||||
await flushVue();
|
||||
const committed = container.querySelector('[data-native-role="active"]')!;
|
||||
expect(textWithin(committed, "route-full-path")).toBe(
|
||||
"/users/two?tab=activity#details",
|
||||
);
|
||||
expect(textWithin(committed, "options-route")).toBe(
|
||||
"/users/two?tab=activity#details",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs Options API and Composition API lifecycle hooks through updates and eviction", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
const { native } = await mountHarness(
|
||||
fixtures.routes,
|
||||
"/users/one?tab=summary",
|
||||
);
|
||||
const optionsPrefix = "options:/users/one?tab=summary:";
|
||||
const compositionPrefix = "composition:/users/one?tab=summary:";
|
||||
|
||||
expectHookOrder(fixtures.events, optionsPrefix, [
|
||||
"beforeCreate",
|
||||
"created",
|
||||
"beforeMount",
|
||||
"mounted",
|
||||
]);
|
||||
expectHookOrder(fixtures.events, compositionPrefix, [
|
||||
"beforeMount",
|
||||
"mounted",
|
||||
]);
|
||||
expect(fixtures.events).toContain("native:/users/one?tab=summary:activate");
|
||||
expect(fixtures.events).toContain("native:/users/one?tab=summary:show");
|
||||
|
||||
fixtures.state.revision += 1;
|
||||
await flushVue();
|
||||
expectHookOrder(fixtures.events, optionsPrefix, [
|
||||
"beforeUpdate",
|
||||
"updated",
|
||||
]);
|
||||
expectHookOrder(fixtures.events, compositionPrefix, [
|
||||
"beforeUpdate",
|
||||
"updated",
|
||||
]);
|
||||
|
||||
await native.push("/other", { presentation: "none" });
|
||||
await flushVue();
|
||||
expect(fixtures.events).not.toContain(`${optionsPrefix}beforeUnmount`);
|
||||
expect(fixtures.events).toContain(
|
||||
"native:/users/one?tab=summary:deactivate",
|
||||
);
|
||||
expect(fixtures.events).toContain("native:/users/one?tab=summary:hide");
|
||||
expect(fixtures.events).toContain("native:/other:activate");
|
||||
expect(fixtures.events).toContain("native:/other:show");
|
||||
|
||||
native.trimCache({ includePinned: true });
|
||||
await flushVue();
|
||||
expectHookOrder(fixtures.events, optionsPrefix, [
|
||||
"beforeUnmount",
|
||||
"unmounted",
|
||||
]);
|
||||
expectHookOrder(fixtures.events, compositionPrefix, [
|
||||
"beforeUnmount",
|
||||
"unmounted",
|
||||
]);
|
||||
expect(fixtures.events).toContain(
|
||||
"native:/users/one?tab=summary:evict:trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves Vue KeepAlive activation and deactivation semantics", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
await mountHarness(fixtures.routes, "/users/one");
|
||||
|
||||
expectHookOrder(fixtures.events, "keep:/users/one:a:", [
|
||||
"beforeMount",
|
||||
"mounted",
|
||||
"activated",
|
||||
]);
|
||||
|
||||
fixtures.state.kept = "b";
|
||||
await flushVue();
|
||||
expect(fixtures.events).toContain("keep:/users/one:a:deactivated");
|
||||
expectHookOrder(fixtures.events, "keep:/users/one:b:", [
|
||||
"beforeMount",
|
||||
"mounted",
|
||||
"activated",
|
||||
]);
|
||||
|
||||
fixtures.state.kept = "a";
|
||||
await flushVue();
|
||||
expect(fixtures.events).toContain("keep:/users/one:b:deactivated");
|
||||
expect(
|
||||
fixtures.events.filter(
|
||||
(event) => event === "keep:/users/one:a:activated",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
fixtures.events.filter((event) => event === "keep:/users/one:a:mounted"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports the standard RouterView Transition and KeepAlive scoped-slot pattern", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
const WrappedRouterView = defineComponent({
|
||||
name: "WrappedRouterView",
|
||||
setup() {
|
||||
return () =>
|
||||
h(NativeRouterView, null, {
|
||||
default: ({
|
||||
Component: RoutedComponent,
|
||||
}: {
|
||||
Component: VNode | null;
|
||||
}) =>
|
||||
h(
|
||||
Transition,
|
||||
{ css: false },
|
||||
{
|
||||
default: () =>
|
||||
h(KeepAlive, null, {
|
||||
default: () => RoutedComponent,
|
||||
}),
|
||||
},
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
const { container, native } = await mountHarness(
|
||||
fixtures.routes,
|
||||
"/users/one?tab=summary",
|
||||
WrappedRouterView,
|
||||
);
|
||||
|
||||
expect(
|
||||
textWithin(
|
||||
container.querySelector('[data-native-role="active"]')!,
|
||||
"options-route",
|
||||
),
|
||||
).toBe("/users/one?tab=summary");
|
||||
|
||||
await native.beginInteractive("push", "/users/two?tab=activity");
|
||||
await flushVue();
|
||||
const preview = container.querySelector('[data-native-role="to"]')!;
|
||||
expect(textWithin(preview, "route-full-path")).toBe(
|
||||
"/users/two?tab=activity",
|
||||
);
|
||||
expect(textWithin(preview, "options-route")).toBe(
|
||||
"/users/two?tab=activity",
|
||||
);
|
||||
|
||||
await native.cancelInteractive();
|
||||
});
|
||||
|
||||
it("runs JavaScript Transition enter and leave hooks", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
const { container } = await mountHarness(fixtures.routes, "/users/one");
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="transition-child"]'),
|
||||
).not.toBeNull();
|
||||
fixtures.state.transitionVisible = false;
|
||||
await flushVue();
|
||||
expect(
|
||||
container.querySelector('[data-testid="transition-child"]'),
|
||||
).toBeNull();
|
||||
expectHookOrder(fixtures.events, "transition:/users/one:", [
|
||||
"beforeLeave",
|
||||
"leave",
|
||||
"afterLeave",
|
||||
]);
|
||||
|
||||
fixtures.state.transitionVisible = true;
|
||||
await flushVue();
|
||||
expect(
|
||||
container.querySelector('[data-testid="transition-child"]'),
|
||||
).not.toBeNull();
|
||||
expectHookOrder(fixtures.events, "transition:/users/one:", [
|
||||
"beforeEnter",
|
||||
"enter",
|
||||
"afterEnter",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Teleport reactive, preserves injection, and removes teleported content on eviction", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
const { native, teleportTarget } = await mountHarness(
|
||||
fixtures.routes,
|
||||
"/users/one",
|
||||
);
|
||||
|
||||
const initial = teleportTarget.querySelector<HTMLElement>(
|
||||
'[data-route="/users/one"]',
|
||||
)!;
|
||||
expect(initial.textContent).toBe("teleport revision 0");
|
||||
expect(initial.dataset.appValue).toBe("provided-by-app");
|
||||
expect(initial.dataset.pageValue).toBe("page:/users/one");
|
||||
|
||||
fixtures.state.revision += 1;
|
||||
await flushVue();
|
||||
expect(initial.textContent).toBe("teleport revision 1");
|
||||
|
||||
await native.push("/other", { presentation: "none" });
|
||||
await flushVue();
|
||||
expect(
|
||||
teleportTarget.querySelector('[data-route="/users/one"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
teleportTarget.querySelector('[data-route="/other"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
native.trimCache({ includePinned: true });
|
||||
await flushVue();
|
||||
expect(
|
||||
teleportTarget.querySelector('[data-route="/users/one"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
teleportTarget.querySelector('[data-route="/other"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders Suspense fallback and resolved async content inside a native view", async () => {
|
||||
const fixtures = createCompatibilityFixtures();
|
||||
const { container } = await mountHarness(fixtures.routes, "/users/one");
|
||||
|
||||
fixtures.state.suspenseVisible = true;
|
||||
await flushVue();
|
||||
expect(
|
||||
container.querySelector('[data-testid="suspense-fallback"]')?.textContent,
|
||||
).toBe("loading");
|
||||
expect(
|
||||
container.querySelector('[data-testid="suspense-ready"]'),
|
||||
).toBeNull();
|
||||
|
||||
fixtures.resolveSuspense();
|
||||
await flushVue();
|
||||
expect(
|
||||
container.querySelector('[data-testid="suspense-fallback"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="suspense-ready"]')?.textContent,
|
||||
).toBe("ready");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,28 @@ function roleFor(entry: NativeViewEntry) {
|
||||
return interactiveRole(entry, runtime);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
182
packages/core/src/components/NativeSheet.test.ts
Normal file
182
packages/core/src/components/NativeSheet.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNativeRouter } from "../runtime";
|
||||
import NativeRouterView from "./NativeRouterView.vue";
|
||||
import NativeSheet from "./NativeSheet.vue";
|
||||
import {
|
||||
adjacentSheetBreakpoint,
|
||||
nearestSheetBreakpoint,
|
||||
normalizeSheetBreakpoints,
|
||||
} from "./sheet";
|
||||
|
||||
const mountedApps: ReturnType<typeof createApp>[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, "matchMedia").mockReturnValue({
|
||||
matches: true,
|
||||
} as MediaQueryList);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function flushVue() {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("sheet breakpoints", () => {
|
||||
it("normalizes, finds, and moves between fractional breakpoints", () => {
|
||||
expect(normalizeSheetBreakpoints([1, 0.5, 0.5, -1, 1.2, 0.25])).toEqual([
|
||||
0.25, 0.5, 1,
|
||||
]);
|
||||
expect(nearestSheetBreakpoint([0.25, 0.5, 1], 0.62)).toBe(0.5);
|
||||
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "up")).toBe(1);
|
||||
expect(adjacentSheetBreakpoint([0.25, 0.5, 1], 0.5, "down")).toBe(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeSheet", () => {
|
||||
it("retains a visible inert underlay and exposes keyboard snap points", async () => {
|
||||
const BasePage = defineComponent({
|
||||
name: "BasePage",
|
||||
setup: () => () => h("main", { "data-testid": "base" }, "Base"),
|
||||
});
|
||||
const SheetPage = defineComponent({
|
||||
name: "SheetPage",
|
||||
setup: () => () =>
|
||||
h(
|
||||
NativeSheet,
|
||||
{ breakpoints: [0.5, 1], initialBreakpoint: 0.5 },
|
||||
{ default: () => h("p", "Sheet content") },
|
||||
),
|
||||
});
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/", component: BasePage },
|
||||
{
|
||||
path: "/sheet",
|
||||
component: SheetPage,
|
||||
meta: { native: { presentation: "sheet", parent: "/" } },
|
||||
},
|
||||
];
|
||||
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||
await router.push("/");
|
||||
await router.isReady();
|
||||
const native = createNativeRouter({ router });
|
||||
const Root = defineComponent({
|
||||
setup: () => () => h(NativeRouterView),
|
||||
});
|
||||
const app = createApp(Root);
|
||||
app.use(router);
|
||||
app.use(native);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.mount(container);
|
||||
mountedApps.push(app);
|
||||
await flushVue();
|
||||
|
||||
expect(await native.present("/sheet", "sheet")).toBe(true);
|
||||
await flushVue();
|
||||
|
||||
const active = container.querySelector('[data-native-role="active"]');
|
||||
const underlay = container.querySelector('[data-native-role="underlay"]');
|
||||
const surface = container.querySelector<HTMLElement>("[data-native-sheet]");
|
||||
const handle = container.querySelector<HTMLElement>(".nvr-sheet__handle");
|
||||
const root = container.querySelector<HTMLElement>(".nvr-sheet");
|
||||
const body = container.querySelector<HTMLElement>(".nvr-sheet__body");
|
||||
expect(active?.getAttribute("data-native-view-presentation")).toBe("sheet");
|
||||
expect(active?.hasAttribute("data-native-sheet-surface")).toBe(true);
|
||||
expect(underlay?.hasAttribute("inert")).toBe(true);
|
||||
expect(underlay?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(surface?.dataset.nativeSheetMode).toBe("breakpoints");
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||
|
||||
handle?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
|
||||
);
|
||||
await flushVue();
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
|
||||
|
||||
if (!root || !body) throw new Error("Sheet body did not render");
|
||||
Object.defineProperty(root, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 1_000,
|
||||
});
|
||||
Object.defineProperties(body, {
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollHeight: { configurable: true, value: 600 },
|
||||
});
|
||||
handle?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
||||
);
|
||||
await flushVue();
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||
|
||||
const dispatchContentPointer = (
|
||||
type: "pointerdown" | "pointermove" | "pointerup",
|
||||
pointerId: number,
|
||||
clientY: number,
|
||||
) =>
|
||||
body.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 20,
|
||||
clientY,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
pointerType: "mouse",
|
||||
}),
|
||||
);
|
||||
|
||||
// Once content wins a gesture, reaching an edge later must not reinterpret
|
||||
// all of the gesture's accumulated distance as sheet movement.
|
||||
body.scrollTop = 100;
|
||||
dispatchContentPointer("pointerdown", 2, 220);
|
||||
dispatchContentPointer("pointermove", 2, 170);
|
||||
body.scrollTop = 300;
|
||||
dispatchContentPointer("pointermove", 2, 120);
|
||||
dispatchContentPointer("pointerup", 2, 120);
|
||||
await flushVue();
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||
|
||||
// Reversing a content-owned gesture at the other boundary keeps content
|
||||
// ownership until release instead of moving both systems.
|
||||
body.scrollTop = 100;
|
||||
dispatchContentPointer("pointerdown", 3, 180);
|
||||
dispatchContentPointer("pointermove", 3, 120);
|
||||
body.scrollTop = 0;
|
||||
dispatchContentPointer("pointermove", 3, 260);
|
||||
dispatchContentPointer("pointerup", 3, 260);
|
||||
await flushVue();
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||
|
||||
body.scrollTop = 300;
|
||||
dispatchContentPointer("pointerdown", 4, 220);
|
||||
dispatchContentPointer("pointermove", 4, 120);
|
||||
dispatchContentPointer("pointerup", 4, 120);
|
||||
await flushVue();
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("1");
|
||||
|
||||
body.scrollTop = 0;
|
||||
dispatchContentPointer("pointerdown", 5, 120);
|
||||
dispatchContentPointer("pointermove", 5, 220);
|
||||
dispatchContentPointer("pointerup", 5, 220);
|
||||
await flushVue();
|
||||
expect(surface?.dataset.nativeSheetBreakpoint).toBe("0.5");
|
||||
|
||||
expect(await native.dismiss()).toBe(true);
|
||||
await flushVue();
|
||||
expect(router.currentRoute.value.path).toBe("/");
|
||||
});
|
||||
});
|
||||
562
packages/core/src/components/NativeSheet.vue
Normal file
562
packages/core/src/components/NativeSheet.vue
Normal file
@@ -0,0 +1,562 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useAttrs,
|
||||
watch,
|
||||
type PropType,
|
||||
} from "vue";
|
||||
import { useNativeRouter } from "./lifecycle";
|
||||
import {
|
||||
adjacentSheetBreakpoint,
|
||||
nearestSheetBreakpoint,
|
||||
normalizeSheetBreakpoints,
|
||||
} from "./sheet";
|
||||
|
||||
defineOptions({ name: "NativeSheet", inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
/** Fractions of the available, safe-area-contained route height. */
|
||||
breakpoints: {
|
||||
type: Array as PropType<number[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/** Initial fraction. The nearest declared breakpoint is used. */
|
||||
initialBreakpoint: Number,
|
||||
/** Current fraction for v-model. */
|
||||
modelValue: Number,
|
||||
dismissible: { type: Boolean, default: true },
|
||||
backdropDismiss: { type: Boolean, default: true },
|
||||
showHandle: { type: Boolean, default: true },
|
||||
ariaLabel: { type: String, default: "Sheet" },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: number];
|
||||
"breakpoint-change": [value: number];
|
||||
dismiss: [];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
const runtime = useNativeRouter();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const surface = ref<HTMLElement | null>(null);
|
||||
const handle = ref<HTMLElement | null>(null);
|
||||
const body = ref<HTMLElement | null>(null);
|
||||
const content = ref<HTMLElement | null>(null);
|
||||
const height = ref<number>();
|
||||
const activeBreakpoint = ref<number>();
|
||||
const dragging = ref(false);
|
||||
const ready = ref(false);
|
||||
const dismissing = ref(false);
|
||||
const normalizedBreakpoints = computed(() =>
|
||||
normalizeSheetBreakpoints(props.breakpoints),
|
||||
);
|
||||
const usesBreakpoints = computed(() => normalizedBreakpoints.value.length > 0);
|
||||
const heightStyle = computed(() =>
|
||||
height.value === undefined ? undefined : `${height.value}px`,
|
||||
);
|
||||
const breakpointLabel = computed(() =>
|
||||
activeBreakpoint.value === undefined
|
||||
? "content"
|
||||
: String(activeBreakpoint.value),
|
||||
);
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
let owningLayer: HTMLElement | null = null;
|
||||
let pointerId = -1;
|
||||
let touchId = -1;
|
||||
let candidateX = 0;
|
||||
let candidateY = 0;
|
||||
let candidateTime = 0;
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
let lastY = 0;
|
||||
let lastTime = 0;
|
||||
let velocity = 0;
|
||||
let moved = false;
|
||||
let wheelDistance = 0;
|
||||
let wheelDirection: "up" | "down" | undefined;
|
||||
let wheelOwner: "content" | "sheet" | undefined;
|
||||
let wheelLocked = false;
|
||||
let wheelResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let contentGestureOwner: "pending" | "content" | "sheet" | undefined;
|
||||
|
||||
function availableHeight() {
|
||||
return Math.max(1, root.value?.clientHeight ?? window.innerHeight);
|
||||
}
|
||||
|
||||
function bodyPadding() {
|
||||
if (!body.value) return 0;
|
||||
const style = getComputedStyle(body.value);
|
||||
return (
|
||||
(Number.parseFloat(style.paddingTop) || 0) +
|
||||
(Number.parseFloat(style.paddingBottom) || 0)
|
||||
);
|
||||
}
|
||||
|
||||
function naturalHeight() {
|
||||
return Math.min(
|
||||
availableHeight(),
|
||||
Math.max(
|
||||
1,
|
||||
(handle.value?.offsetHeight ?? 0) +
|
||||
(content.value?.scrollHeight ?? 0) +
|
||||
bodyPadding(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function requestedBreakpoint() {
|
||||
const points = normalizedBreakpoints.value;
|
||||
if (!points.length) return undefined;
|
||||
return nearestSheetBreakpoint(
|
||||
points,
|
||||
props.modelValue ??
|
||||
activeBreakpoint.value ??
|
||||
props.initialBreakpoint ??
|
||||
points[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const breakpoint = requestedBreakpoint();
|
||||
activeBreakpoint.value = breakpoint;
|
||||
height.value = breakpoint ? availableHeight() * breakpoint : naturalHeight();
|
||||
}
|
||||
|
||||
function setBreakpoint(breakpoint: number, notify = true) {
|
||||
const nearest = nearestSheetBreakpoint(
|
||||
normalizedBreakpoints.value,
|
||||
breakpoint,
|
||||
);
|
||||
if (nearest === undefined) return measure();
|
||||
activeBreakpoint.value = nearest;
|
||||
height.value = availableHeight() * nearest;
|
||||
if (notify) {
|
||||
emit("update:modelValue", nearest);
|
||||
emit("breakpoint-change", nearest);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissSheet() {
|
||||
if (!props.dismissible || dismissing.value) return false;
|
||||
dismissing.value = true;
|
||||
emit("dismiss");
|
||||
const dismissed = await runtime.dismiss();
|
||||
dismissing.value = false;
|
||||
if (!dismissed) measure();
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
function beginDrag(clientY: number, timestamp: number) {
|
||||
startY = lastY = clientY;
|
||||
lastTime = timestamp;
|
||||
startHeight = height.value ?? surface.value?.offsetHeight ?? naturalHeight();
|
||||
velocity = 0;
|
||||
moved = false;
|
||||
dragging.value = true;
|
||||
}
|
||||
|
||||
function updateDrag(clientY: number, timestamp: number) {
|
||||
const delta = clientY - startY;
|
||||
moved ||= Math.abs(delta) > 3;
|
||||
const minimum = usesBreakpoints.value
|
||||
? availableHeight() * (normalizedBreakpoints.value[0] ?? 0.1) * 0.55
|
||||
: naturalHeight() * 0.55;
|
||||
height.value = Math.max(
|
||||
Math.min(72, availableHeight()),
|
||||
Math.min(availableHeight(), Math.max(minimum, startHeight - delta)),
|
||||
);
|
||||
const elapsed = Math.max(8, timestamp - lastTime);
|
||||
velocity = ((clientY - lastY) * 1000) / elapsed / availableHeight();
|
||||
lastY = clientY;
|
||||
lastTime = timestamp;
|
||||
}
|
||||
|
||||
async function finishDrag() {
|
||||
dragging.value = false;
|
||||
if (!moved) return measure();
|
||||
|
||||
const currentHeight = height.value ?? startHeight;
|
||||
const currentFraction = currentHeight / availableHeight();
|
||||
const points = normalizedBreakpoints.value;
|
||||
const smallest = points[0];
|
||||
|
||||
if (!points.length) {
|
||||
if (
|
||||
props.dismissible &&
|
||||
(currentHeight < naturalHeight() * 0.72 || velocity > 1.1)
|
||||
)
|
||||
return void (await dismissSheet());
|
||||
return measure();
|
||||
}
|
||||
|
||||
if (
|
||||
props.dismissible &&
|
||||
smallest !== undefined &&
|
||||
currentFraction < smallest * 0.72
|
||||
)
|
||||
return void (await dismissSheet());
|
||||
|
||||
const active = activeBreakpoint.value ?? smallest!;
|
||||
if (Math.abs(velocity) > 0.65) {
|
||||
const direction = velocity < 0 ? "up" : "down";
|
||||
const adjacent = adjacentSheetBreakpoint(points, active, direction);
|
||||
if (adjacent !== undefined) return setBreakpoint(adjacent);
|
||||
if (direction === "down" && props.dismissible)
|
||||
return void (await dismissSheet());
|
||||
}
|
||||
|
||||
setBreakpoint(nearestSheetBreakpoint(points, currentFraction) ?? active);
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
dragging.value = false;
|
||||
measure();
|
||||
}
|
||||
|
||||
function pointerDown(event: PointerEvent) {
|
||||
if (!event.isPrimary || event.button !== 0 || dismissing.value) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
contentGestureOwner = "sheet";
|
||||
pointerId = event.pointerId;
|
||||
beginDrag(event.clientY, event.timeStamp);
|
||||
handle.value?.setPointerCapture?.(pointerId);
|
||||
}
|
||||
|
||||
function pointerMove(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
updateDrag(event.clientY, event.timeStamp);
|
||||
}
|
||||
|
||||
async function pointerUp(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
handle.value?.releasePointerCapture?.(pointerId);
|
||||
pointerId = -1;
|
||||
await finishDrag();
|
||||
contentGestureOwner = undefined;
|
||||
}
|
||||
|
||||
function pointerCancel(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerId = -1;
|
||||
cancelDrag();
|
||||
contentGestureOwner = undefined;
|
||||
}
|
||||
|
||||
function atTop() {
|
||||
return (body.value?.scrollTop ?? 0) <= 1;
|
||||
}
|
||||
|
||||
function atBottom() {
|
||||
const element = body.value;
|
||||
if (!element) return true;
|
||||
return element.scrollTop + element.clientHeight >= element.scrollHeight - 1;
|
||||
}
|
||||
|
||||
function canResizeFromContent(direction: "up" | "down") {
|
||||
const points = normalizedBreakpoints.value;
|
||||
const active = activeBreakpoint.value;
|
||||
if (direction === "up")
|
||||
return Boolean(
|
||||
atBottom() &&
|
||||
active !== undefined &&
|
||||
adjacentSheetBreakpoint(points, active, "up") !== undefined,
|
||||
);
|
||||
return Boolean(
|
||||
atTop() &&
|
||||
(props.dismissible ||
|
||||
(active !== undefined &&
|
||||
adjacentSheetBreakpoint(points, active, "down") !== undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
function contentGestureDirection(deltaY: number) {
|
||||
return deltaY < 0 ? ("up" as const) : ("down" as const);
|
||||
}
|
||||
|
||||
function shouldClaimContentGesture(deltaX: number, deltaY: number) {
|
||||
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
||||
return false;
|
||||
return canResizeFromContent(contentGestureDirection(deltaY));
|
||||
}
|
||||
|
||||
function contentPointerDown(event: PointerEvent) {
|
||||
if (
|
||||
event.pointerType === "touch" ||
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
dismissing.value
|
||||
)
|
||||
return;
|
||||
pointerId = event.pointerId;
|
||||
contentGestureOwner = "pending";
|
||||
candidateX = event.clientX;
|
||||
candidateY = event.clientY;
|
||||
candidateTime = event.timeStamp;
|
||||
}
|
||||
|
||||
function contentPointerMove(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
if (contentGestureOwner === "pending") {
|
||||
const deltaX = event.clientX - candidateX;
|
||||
const deltaY = event.clientY - candidateY;
|
||||
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
||||
return;
|
||||
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
|
||||
? "sheet"
|
||||
: "content";
|
||||
if (contentGestureOwner === "content") return;
|
||||
beginDrag(candidateY, candidateTime);
|
||||
body.value?.setPointerCapture?.(pointerId);
|
||||
}
|
||||
if (contentGestureOwner !== "sheet") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
updateDrag(event.clientY, event.timeStamp);
|
||||
}
|
||||
|
||||
async function contentPointerUp(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
body.value?.releasePointerCapture?.(pointerId);
|
||||
pointerId = -1;
|
||||
if (dragging.value) await finishDrag();
|
||||
contentGestureOwner = undefined;
|
||||
}
|
||||
|
||||
function contentPointerCancel(event: PointerEvent) {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerId = -1;
|
||||
if (dragging.value) cancelDrag();
|
||||
contentGestureOwner = undefined;
|
||||
}
|
||||
|
||||
function trackedTouch(list: TouchList) {
|
||||
return [...list].find((touch) => touch.identifier === touchId);
|
||||
}
|
||||
|
||||
function contentTouchStart(event: TouchEvent) {
|
||||
if (touchId !== -1 || dismissing.value) return;
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) return;
|
||||
touchId = touch.identifier;
|
||||
contentGestureOwner = "pending";
|
||||
candidateX = touch.clientX;
|
||||
candidateY = touch.clientY;
|
||||
candidateTime = event.timeStamp;
|
||||
}
|
||||
|
||||
function contentTouchMove(event: TouchEvent) {
|
||||
const touch = trackedTouch(event.touches);
|
||||
if (!touch) return;
|
||||
if (contentGestureOwner === "pending") {
|
||||
const deltaX = touch.clientX - candidateX;
|
||||
const deltaY = touch.clientY - candidateY;
|
||||
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
||||
return;
|
||||
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
|
||||
? "sheet"
|
||||
: "content";
|
||||
if (contentGestureOwner === "content") return;
|
||||
beginDrag(candidateY, candidateTime);
|
||||
}
|
||||
if (contentGestureOwner !== "sheet") return;
|
||||
if (event.cancelable) event.preventDefault();
|
||||
event.stopPropagation();
|
||||
updateDrag(touch.clientY, event.timeStamp);
|
||||
}
|
||||
|
||||
async function contentTouchEnd(event: TouchEvent) {
|
||||
if (!trackedTouch(event.changedTouches)) return;
|
||||
touchId = -1;
|
||||
if (dragging.value) await finishDrag();
|
||||
contentGestureOwner = undefined;
|
||||
}
|
||||
|
||||
function contentTouchCancel(event: TouchEvent) {
|
||||
if (!trackedTouch(event.changedTouches)) return;
|
||||
touchId = -1;
|
||||
if (dragging.value) cancelDrag();
|
||||
contentGestureOwner = undefined;
|
||||
}
|
||||
|
||||
function resetWheelHandoff() {
|
||||
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
||||
wheelDistance = 0;
|
||||
wheelDirection = undefined;
|
||||
wheelOwner = undefined;
|
||||
wheelLocked = false;
|
||||
wheelResetTimer = undefined;
|
||||
}
|
||||
|
||||
function contentWheel(event: WheelEvent) {
|
||||
const direction = event.deltaY > 0 ? ("up" as const) : ("down" as const);
|
||||
if (!event.deltaY) return;
|
||||
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
||||
wheelResetTimer = setTimeout(resetWheelHandoff, 240);
|
||||
|
||||
if (!wheelOwner)
|
||||
wheelOwner = canResizeFromContent(direction) ? "sheet" : "content";
|
||||
if (wheelOwner === "content") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (wheelDirection !== direction) wheelDistance = 0;
|
||||
wheelDirection = direction;
|
||||
wheelDistance += Math.abs(event.deltaY);
|
||||
if (!wheelLocked && wheelDistance >= 48) {
|
||||
const active = activeBreakpoint.value;
|
||||
const adjacent =
|
||||
active === undefined
|
||||
? undefined
|
||||
: adjacentSheetBreakpoint(
|
||||
normalizedBreakpoints.value,
|
||||
active,
|
||||
direction,
|
||||
);
|
||||
if (adjacent !== undefined) setBreakpoint(adjacent);
|
||||
else if (direction === "down") void dismissSheet();
|
||||
wheelLocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent) {
|
||||
const points = normalizedBreakpoints.value;
|
||||
const current = activeBreakpoint.value;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
void dismissSheet();
|
||||
return;
|
||||
}
|
||||
if (!points.length || current === undefined) return;
|
||||
const direction =
|
||||
event.key === "ArrowUp"
|
||||
? "up"
|
||||
: event.key === "ArrowDown"
|
||||
? "down"
|
||||
: undefined;
|
||||
const target =
|
||||
event.key === "Home"
|
||||
? points[0]
|
||||
: event.key === "End"
|
||||
? points.at(-1)
|
||||
: direction
|
||||
? adjacentSheetBreakpoint(points, current, direction)
|
||||
: undefined;
|
||||
if (target !== undefined) {
|
||||
event.preventDefault();
|
||||
setBreakpoint(target);
|
||||
} else if (direction === "down" && props.dismissible) {
|
||||
event.preventDefault();
|
||||
void dismissSheet();
|
||||
}
|
||||
}
|
||||
|
||||
function surfaceKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape" || event.target === handle.value) return;
|
||||
event.preventDefault();
|
||||
void dismissSheet();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.initialBreakpoint, props.breakpoints] as const,
|
||||
() => {
|
||||
if (!dragging.value) void nextTick(measure);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
owningLayer = root.value?.closest<HTMLElement>(".nvr-view") ?? null;
|
||||
if (owningLayer) owningLayer.dataset.nativeSheetSurface = "";
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!dragging.value) measure();
|
||||
});
|
||||
if (root.value) resizeObserver.observe(root.value);
|
||||
if (content.value) resizeObserver.observe(content.value);
|
||||
}
|
||||
void nextTick(() => {
|
||||
measure();
|
||||
requestAnimationFrame(() => (ready.value = true));
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
||||
if (owningLayer) delete owningLayer.dataset.nativeSheetSurface;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" v-bind="attrs" class="nvr-sheet">
|
||||
<button
|
||||
v-if="backdropDismiss && dismissible"
|
||||
class="nvr-sheet__backdrop"
|
||||
type="button"
|
||||
aria-label="Close sheet"
|
||||
@click="dismissSheet"
|
||||
/>
|
||||
<div v-else class="nvr-sheet__backdrop" aria-hidden="true" />
|
||||
<section
|
||||
ref="surface"
|
||||
class="nvr-sheet__surface"
|
||||
:style="{ height: heightStyle }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="ariaLabel"
|
||||
data-native-sheet
|
||||
:data-native-sheet-mode="usesBreakpoints ? 'breakpoints' : 'content'"
|
||||
:data-native-sheet-breakpoint="breakpointLabel"
|
||||
:data-native-sheet-dragging="String(dragging)"
|
||||
:data-native-sheet-ready="String(ready)"
|
||||
@keydown="surfaceKeyDown"
|
||||
>
|
||||
<div
|
||||
v-if="showHandle"
|
||||
ref="handle"
|
||||
class="nvr-sheet__handle"
|
||||
:role="usesBreakpoints ? 'slider' : undefined"
|
||||
:tabindex="0"
|
||||
aria-label="Resize or dismiss sheet"
|
||||
aria-orientation="vertical"
|
||||
:aria-valuemin="usesBreakpoints ? normalizedBreakpoints[0] : undefined"
|
||||
:aria-valuemax="
|
||||
usesBreakpoints ? normalizedBreakpoints.at(-1) : undefined
|
||||
"
|
||||
:aria-valuenow="usesBreakpoints ? activeBreakpoint : undefined"
|
||||
@pointerdown="pointerDown"
|
||||
@pointermove="pointerMove"
|
||||
@pointerup="pointerUp"
|
||||
@pointercancel="pointerCancel"
|
||||
@keydown="keyDown"
|
||||
>
|
||||
<slot name="handle">
|
||||
<span aria-hidden="true" />
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
ref="body"
|
||||
class="nvr-sheet__body"
|
||||
@pointerdown="contentPointerDown"
|
||||
@pointermove="contentPointerMove"
|
||||
@pointerup="contentPointerUp"
|
||||
@pointercancel="contentPointerCancel"
|
||||
@touchstart="contentTouchStart"
|
||||
@touchmove="contentTouchMove"
|
||||
@touchend="contentTouchEnd"
|
||||
@touchcancel="contentTouchCancel"
|
||||
@wheel="contentWheel"
|
||||
>
|
||||
<div ref="content" class="nvr-sheet__content"><slot /></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,7 +3,13 @@ export { default as NativeGestureLink } from "./NativeGestureLink.vue";
|
||||
export { default as NativeLink } from "./NativeLink.vue";
|
||||
export { default as 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,
|
||||
|
||||
28
packages/core/src/components/sheet.ts
Normal file
28
packages/core/src/components/sheet.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function normalizeSheetBreakpoints(values: readonly number[]) {
|
||||
return [...new Set(values.filter((value) => value > 0 && value <= 1))].sort(
|
||||
(left, right) => left - right,
|
||||
);
|
||||
}
|
||||
|
||||
export function nearestSheetBreakpoint(
|
||||
breakpoints: readonly number[],
|
||||
value: number,
|
||||
) {
|
||||
return breakpoints.reduce<number | undefined>((nearest, candidate) => {
|
||||
if (nearest === undefined) return candidate;
|
||||
return Math.abs(candidate - value) < Math.abs(nearest - value)
|
||||
? candidate
|
||||
: nearest;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
export function adjacentSheetBreakpoint(
|
||||
breakpoints: readonly number[],
|
||||
current: number,
|
||||
direction: "up" | "down",
|
||||
) {
|
||||
const normalized = normalizeSheetBreakpoints(breakpoints);
|
||||
if (direction === "up")
|
||||
return normalized.find((value) => value > current + 0.001);
|
||||
return [...normalized].reverse().find((value) => value < current - 0.001);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { createNativeNavigationProfiler } from "./profiler";
|
||||
|
||||
const Page = defineComponent({ template: "<div>page</div>" });
|
||||
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"exclude": ["src/**/*.test.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,
|
||||
|
||||
Reference in New Issue
Block a user