Move components.ts into many SFC files

This commit is contained in:
2026-07-22 02:38:01 +00:00
parent 18baa96848
commit bfe364c57d
10 changed files with 814 additions and 760 deletions

View File

@@ -1,760 +0,0 @@
import {
computed,
defineComponent,
h,
inject,
onBeforeUnmount,
onMounted,
onScopeDispose,
provide,
ref,
shallowReactive,
watch,
type PropType,
type InjectionKey,
type VNode,
} from "vue";
import {
routeLocationKey,
RouterView,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
} from "vue-router";
import { nativeRouterKey } from "./runtime";
import type {
NativeDirection,
NativeNavigationOptions,
NativeRouterRuntime,
NativeSourceRect,
NativeViewEntry,
NativeViewLifecycle,
NativeViewRole,
} from "./types";
const nativeViewLifecycleKey: InjectionKey<NativeViewLifecycle> = Symbol(
"native-view-lifecycle",
);
export function useNativeRouter() {
const runtime = inject<NativeRouterRuntime>(nativeRouterKey);
if (!runtime)
throw new Error(
"Native Vue Router is not installed. Call app.use(nativeRouter).",
);
return runtime;
}
export function useNativeViewLifecycle() {
const lifecycle = inject<NativeViewLifecycle>(nativeViewLifecycleKey);
if (!lifecycle)
throw new Error(
"Native view lifecycle APIs must be used inside NativeRouterView.",
);
return lifecycle;
}
type NativeViewHook = () => void;
function onNativeViewState(
source: Readonly<{ value: boolean }>,
entering: boolean,
hook: NativeViewHook,
) {
onMounted(() => {
if (entering && source.value) hook();
});
watch(
() => source.value,
(value, previous) => {
if (value === entering && previous !== entering) hook();
},
{ flush: "sync" },
);
}
export function onNativeViewActivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, true, hook);
}
export function onNativeViewDeactivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, false, hook);
}
export function onNativeViewShow(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, true, hook);
}
export function onNativeViewHide(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, false, hook);
}
export function onNativeViewEvict(
hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void,
) {
const lifecycle = useNativeViewLifecycle();
onBeforeUnmount(() => {
if (lifecycle.status.value === "evicted")
hook(lifecycle.evictionReason.value);
});
}
function useNativeViewEffect(
source: Readonly<{ value: boolean }>,
effect: () => void | (() => void),
) {
let cleanup: void | (() => void);
const stopEffect = () => {
cleanup?.();
cleanup = undefined;
};
const stopWatch = watch(
() => source.value,
(enabled) => {
stopEffect();
if (enabled) cleanup = effect();
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(() => {
stopWatch();
stopEffect();
});
}
/** Runs an effect only while this route is the semantically active route. */
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isActive, effect);
}
/** Runs an effect while this route is active or participating in a transition. */
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isVisible, effect);
}
const NativeRouteScope = defineComponent({
name: "NativeRouteScope",
props: {
route: {
type: Object as PropType<RouteLocationNormalizedLoaded>,
required: true,
},
entryKey: { type: String, required: true },
},
setup(props, { slots }) {
const runtime = useNativeRouter();
const scopedRoute = shallowReactive({
...props.route,
}) as RouteLocationNormalizedLoaded;
watch(
() => props.route,
(route) => Object.assign(scopedRoute, route),
{ immediate: true },
);
provide(routeLocationKey, scopedRoute);
const entry = computed(() =>
runtime.entries.value.find(
(candidate) => candidate.key === props.entryKey,
),
);
const role = computed<NativeViewRole>(() =>
entry.value ? interactiveRole(entry.value, runtime) : "inactive",
);
provide<NativeViewLifecycle>(nativeViewLifecycleKey, {
key: props.entryKey,
route: computed(() => entry.value?.route ?? props.route),
status: computed(() => entry.value?.status ?? "evicted"),
role,
isActive: computed(() => runtime.activeKey.value === props.entryKey),
isVisible: computed(() => role.value !== "inactive"),
isPreview: computed(
() => runtime.transaction.value?.toKey === props.entryKey,
),
isCached: computed(() =>
Boolean(entry.value?.mounted && role.value === "inactive"),
),
evictionReason: computed(() => entry.value?.evictionReason),
});
return () => slots.default?.();
},
});
function interactiveRole(
entry: NativeViewEntry,
runtime: NativeRouterRuntime,
): NativeViewRole {
const transaction = runtime.transaction.value;
if (!transaction)
return entry.key === runtime.activeKey.value ? "active" : "inactive";
if (entry.key === transaction.fromKey) return "from";
if (entry.key === transaction.toKey) return "to";
return "inactive";
}
export const NativeRouterView = defineComponent({
name: "NativeRouterView",
setup(_, { slots }) {
const runtime = useNativeRouter();
return () => {
const transaction = runtime.transaction.value;
const style = transaction
? { "--native-progress": String(transaction.progress) }
: undefined;
const children = runtime.entries.value
.filter((entry) => entry.mounted)
.map((entry) => {
const role = interactiveRole(entry, runtime);
const definition = transaction
? runtime.presentationFor(transaction.presentation)
: undefined;
const customStyle =
transaction && (role === "from" || role === "to")
? definition?.layerStyle?.({
progress: transaction.progress,
role,
direction: transaction.direction,
sourceRect: transaction.sourceRect,
})
: undefined;
return h(
"section",
{
key: entry.key,
class: ["nvr-view", `nvr-view--${role}`],
style: customStyle,
"data-native-role": role,
"data-native-route": entry.route.fullPath,
"data-native-presentation": transaction?.presentation,
"data-native-direction": transaction?.direction,
inert: role === "inactive" ? "" : undefined,
"aria-hidden": role === "inactive" ? "true" : undefined,
},
[
h(
RouterView,
{ route: entry.route },
{
default: ({
Component,
route,
}: {
Component: VNode | null;
route: RouteLocationNormalizedLoaded;
}) => {
if (slots.default)
return slots.default({ Component, route, entry });
return Component
? h(
NativeRouteScope,
{ route, entryKey: entry.key },
{ default: () => Component },
)
: null;
},
},
),
],
);
});
return h(
"div",
{
class: [
"nvr-router-view",
transaction && "nvr-router-view--interactive",
],
style,
"data-native-presentation": transaction?.presentation,
"data-native-direction": transaction?.direction,
"data-native-transaction": transaction?.id,
"data-native-velocity": transaction
? String(transaction.velocity)
: undefined,
},
children,
);
};
},
});
export const NativeLink = defineComponent({
name: "NativeLink",
props: {
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
replace: Boolean,
presentation: String,
},
setup(props, { slots, attrs }) {
const runtime = useNativeRouter();
const href = computed(() => runtime.router.resolve(props.to).href);
const activate = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
event.preventDefault();
void (props.replace
? runtime.replace(props.to, { presentation: props.presentation })
: runtime.push(props.to, { presentation: props.presentation }));
};
return () =>
h(
"a",
{ ...attrs, href: href.value, onClick: activate },
slots.default?.(),
);
},
});
function shouldIgnoreGesture(target: EventTarget | null) {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
);
}
function sourceRect(element: HTMLElement): NativeSourceRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
}
function createPointerGesture(
element: () => HTMLElement | null,
begin: (direction: NativeDirection) => Promise<number | null>,
directionForDelta: (deltaX: number) => NativeDirection | null,
) {
let pointerId = -1;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastTime = 0;
let captured = false;
let beginPromise: Promise<number | null> | null = null;
let ending = false;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let gestureSign = 0;
const down = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
pointerId = event.pointerId;
startX = lastX = event.clientX;
startY = event.clientY;
lastTime = event.timeStamp;
captured = false;
beginPromise = null;
ending = false;
bufferedProgress = 0;
bufferedVelocity = 0;
gestureSign = 0;
};
const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!captured) {
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return;
const direction = directionForDelta(dx);
if (!direction) return reset();
captured = true;
ending = false;
gestureSign = Math.sign(dx);
element()?.setPointerCapture(pointerId);
beginPromise = begin(direction);
}
event.preventDefault();
const elapsed = Math.max(8, event.timeStamp - lastTime);
const width = Math.max(1, element()?.clientWidth ?? window.innerWidth);
bufferedVelocity =
((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width);
bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width));
lastX = event.clientX;
lastTime = event.timeStamp;
const pending = beginPromise;
if (pending) {
const id = await pending;
if (pending !== beginPromise || ending) return;
if (id === null) return reset();
const runtime = injectRuntimeFromElement(element());
runtime?.updateInteractive(bufferedProgress, bufferedVelocity);
}
};
const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
ending = true;
const runtime = injectRuntimeFromElement(element());
const pending = beginPromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
// Detach this pointer before awaiting preload/navigation/animation work. A
// new gesture may now start without this release callback erasing it.
reset();
const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) {
runtime.updateInteractive(progress, velocity);
await runtime.finishInteractive();
}
};
const cancel = async () => {
ending = true;
const runtime = injectRuntimeFromElement(element());
const pending = beginPromise;
const shouldCancel = captured;
reset();
const id = pending ? await pending : null;
if (shouldCancel && id !== null && runtime?.transaction.value?.id === id)
await runtime.cancelInteractive();
};
const reset = () => {
pointerId = -1;
captured = false;
beginPromise = null;
ending = false;
bufferedProgress = 0;
bufferedVelocity = 0;
gestureSign = 0;
};
return { down, move, up, cancel };
}
const runtimeByElement = new WeakMap<Element, NativeRouterRuntime>();
function injectRuntimeFromElement(element: Element | null) {
return element ? runtimeByElement.get(element) : undefined;
}
export const NativeGestureLink = defineComponent({
name: "NativeGestureLink",
inheritAttrs: false,
props: {
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
presentation: { type: String, default: "reveal" },
replace: Boolean,
direction: {
type: String as PropType<"left" | "right" | "any">,
default: "any",
},
as: { type: String, default: "div" },
},
setup(props, { slots, attrs }) {
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let dragDistance = 0;
let suppressClick = false;
const gesture = createPointerGesture(
() => root.value,
async (direction) =>
runtime.beginInteractive("push", props.to, {
presentation: props.presentation,
replace: props.replace,
direction,
sourceRect: root.value ? sourceRect(root.value) : undefined,
}),
(dx) => {
if (props.direction === "left" && dx >= 0) return null;
if (props.direction === "right" && dx <= 0) return null;
return dx < 0 ? "forward" : "back";
},
);
onBeforeUnmount(() => void runtime.cancelInteractive());
const click = (event: MouseEvent) => {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
void runtime.push(props.to, {
presentation: props.presentation,
replace: props.replace,
});
};
return () =>
h(
props.as,
{
...attrs,
ref: (value: unknown) => {
root.value = value as HTMLElement | null;
if (root.value) runtimeByElement.set(root.value, runtime);
},
class: ["nvr-gesture-link", attrs.class],
onPointerdown: (event: PointerEvent) => {
// Component-owned gestures outrank their containing navigator.
event.stopPropagation();
dragDistance = 0;
suppressClick = false;
gesture.down(event);
},
onPointermove: (event: PointerEvent) => {
if (event.buttons) {
dragDistance += Math.abs(event.movementX);
if (dragDistance > 8) suppressClick = true;
}
void gesture.move(event);
},
onPointerup: gesture.up,
onPointercancel: gesture.cancel,
onClick: click,
},
slots.default?.(),
);
},
});
export const NativeNavigator = defineComponent({
name: "NativeNavigator",
props: {
siblings: {
type: Array as PropType<RouteLocationRaw[]>,
default: () => [],
},
edgeWidth: { type: Number, default: 28 },
},
setup(props, { slots }) {
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let candidate: "back" | "sibling" | null = null;
const gesture = createPointerGesture(
() => root.value,
async (direction) => {
if (candidate === "back") return runtime.beginInteractive("pop");
const current = runtime.router.currentRoute.value.fullPath;
const index = props.siblings.findIndex(
(route) => runtime.router.resolve(route).fullPath === current,
);
const nextIndex = index + (direction === "forward" ? 1 : -1);
const target = props.siblings[nextIndex];
if (index < 0 || !target) return null;
return runtime.beginInteractive("sibling", target, {
replace:
runtime.router.resolve(target).meta.native?.siblingHistory !==
"push",
direction,
presentation: "slide",
});
},
(dx) => {
const rtl =
getComputedStyle(root.value ?? document.documentElement).direction ===
"rtl";
const logicalDx = rtl ? -dx : dx;
if (candidate === "back") return logicalDx > 0 ? "back" : null;
return logicalDx < 0 ? "forward" : "back";
},
);
const gestureSettingAllowsNavigation = () =>
runtime.router.currentRoute.value.meta.native?.gesture !== false;
const atLeadingEdge = (event: PointerEvent) => {
const rect = root.value?.getBoundingClientRect();
const rtl =
getComputedStyle(root.value ?? document.documentElement).direction ===
"rtl";
return rect
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <=
props.edgeWidth
: false;
};
const captureDown = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
if (
!atLeadingEdge(event) ||
!gestureSettingAllowsNavigation() ||
!runtime.canGoBack.value
)
return;
candidate = "back";
gesture.down(event);
// The application shell owns the physical back edge. Component-owned
// route gestures retain priority everywhere else.
event.stopPropagation();
};
const down = (event: PointerEvent) => {
candidate =
props.siblings.length && gestureSettingAllowsNavigation()
? "sibling"
: null;
if (candidate) gesture.down(event);
};
return () =>
h(
"div",
{
ref: (value: unknown) => {
root.value = value as HTMLElement | null;
if (root.value) runtimeByElement.set(root.value, runtime);
},
class: "nvr-navigator",
"data-native-can-go-back": String(runtime.canGoBack.value),
onPointerdownCapture: captureDown,
onPointerdown: down,
onPointermove: gesture.move,
onPointerup: gesture.up,
onPointercancel: gesture.cancel,
},
slots.default?.(),
);
},
});
export const NativeDismissGesture = defineComponent({
name: "NativeDismissGesture",
inheritAttrs: false,
props: {
as: { type: String, default: "div" },
},
setup(props, { slots, attrs }) {
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let pointerId = -1;
let startX = 0;
let startY = 0;
let lastY = 0;
let lastTime = 0;
let captured = false;
let beginPromise: Promise<number | null> | null = null;
let ending = false;
let progress = 0;
let velocity = 0;
const reset = () => {
pointerId = -1;
captured = false;
beginPromise = null;
ending = false;
progress = 0;
velocity = 0;
};
const down = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
event.stopPropagation();
pointerId = event.pointerId;
startX = event.clientX;
startY = lastY = event.clientY;
lastTime = event.timeStamp;
captured = false;
beginPromise = null;
ending = false;
progress = 0;
velocity = 0;
};
const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!captured) {
if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return;
captured = true;
ending = false;
root.value?.setPointerCapture(pointerId);
beginPromise = runtime.beginInteractive("dismiss", undefined, {
direction: "back",
});
}
event.preventDefault();
progress = Math.max(
0,
Math.min(
1,
dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight),
),
);
const height = Math.max(
1,
root.value?.clientHeight ?? window.innerHeight,
);
velocity =
((event.clientY - lastY) * 1000) /
(Math.max(8, event.timeStamp - lastTime) * height);
lastY = event.clientY;
lastTime = event.timeStamp;
const pending = beginPromise;
if (pending) {
const id = await pending;
if (pending !== beginPromise || ending) return;
if (id === null) return reset();
runtime.updateInteractive(progress, velocity);
}
};
const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
ending = true;
const pending = beginPromise;
const shouldFinish = captured;
const finalProgress = progress;
const finalVelocity = velocity;
reset();
const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
runtime.updateInteractive(finalProgress, finalVelocity);
await runtime.finishInteractive();
}
};
const cancel = async () => {
ending = true;
const pending = beginPromise;
const shouldCancel = captured;
reset();
const id = pending ? await pending : null;
if (shouldCancel && id !== null && runtime.transaction.value?.id === id)
await runtime.cancelInteractive();
};
onBeforeUnmount(() => void cancel());
return () =>
h(
props.as,
{
...attrs,
ref: (value: unknown) => {
root.value = value as HTMLElement | null;
},
class: ["nvr-dismiss-gesture", attrs.class],
onPointerdown: down,
onPointermove: move,
onPointerup: up,
onPointercancel: cancel,
},
slots.default?.(),
);
},
});
export function navigationOptionsFromElement(
element: HTMLElement,
): NativeNavigationOptions {
return { sourceRect: sourceRect(element) };
}

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useAttrs } from "vue";
import { shouldIgnoreGesture } from "./gestures";
import { useNativeRouter } from "./lifecycle";
defineOptions({ name: "NativeDismissGesture", inheritAttrs: false });
defineProps({
as: { type: String, default: "div" },
});
const attrs = useAttrs();
const passthroughAttrs = computed(() => {
const result = { ...attrs };
delete result.onPointerdown;
delete result.onPointermove;
delete result.onPointerup;
delete result.onPointercancel;
return result;
});
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let pointerId = -1;
let startX = 0;
let startY = 0;
let lastY = 0;
let lastTime = 0;
let captured = false;
let beginPromise: Promise<number | null> | null = null;
let ending = false;
let progress = 0;
let velocity = 0;
function reset() {
pointerId = -1;
captured = false;
beginPromise = null;
ending = false;
progress = 0;
velocity = 0;
}
function down(event: PointerEvent) {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
event.stopPropagation();
pointerId = event.pointerId;
startX = event.clientX;
startY = lastY = event.clientY;
lastTime = event.timeStamp;
captured = false;
beginPromise = null;
ending = false;
progress = 0;
velocity = 0;
}
async function move(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!captured) {
if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return;
captured = true;
ending = false;
root.value?.setPointerCapture(pointerId);
beginPromise = runtime.beginInteractive("dismiss", undefined, {
direction: "back",
});
}
event.preventDefault();
progress = Math.max(
0,
Math.min(
1,
dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight),
),
);
const height = Math.max(1, root.value?.clientHeight ?? window.innerHeight);
velocity =
((event.clientY - lastY) * 1000) /
(Math.max(8, event.timeStamp - lastTime) * height);
lastY = event.clientY;
lastTime = event.timeStamp;
const pending = beginPromise;
if (pending) {
const id = await pending;
if (pending !== beginPromise || ending) return;
if (id === null) return reset();
runtime.updateInteractive(progress, velocity);
}
}
async function up(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
ending = true;
const pending = beginPromise;
const shouldFinish = captured;
const finalProgress = progress;
const finalVelocity = velocity;
reset();
const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
runtime.updateInteractive(finalProgress, finalVelocity);
await runtime.finishInteractive();
}
}
async function cancel() {
ending = true;
const pending = beginPromise;
const shouldCancel = captured;
reset();
const id = pending ? await pending : null;
if (shouldCancel && id !== null && runtime.transaction.value?.id === id)
await runtime.cancelInteractive();
}
onBeforeUnmount(() => void cancel());
</script>
<template>
<component
:is="as"
ref="root"
v-bind="passthroughAttrs"
class="nvr-dismiss-gesture"
@pointerdown="down"
@pointermove="move"
@pointerup="up"
@pointercancel="cancel"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useAttrs, type PropType } from "vue";
import type { RouteLocationRaw } from "vue-router";
import type { NativePresentationName } from "../types";
import { createPointerGesture, sourceRect } from "./gestures";
import { useNativeRouter } from "./lifecycle";
defineOptions({ name: "NativeGestureLink", inheritAttrs: false });
const props = defineProps({
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
presentation: {
type: String as PropType<NativePresentationName>,
default: "reveal",
},
replace: Boolean,
direction: {
type: String as PropType<"left" | "right" | "any">,
default: "any",
},
as: { type: String, default: "div" },
});
const attrs = useAttrs();
const passthroughAttrs = computed(() => {
const result = { ...attrs };
delete result.onPointerdown;
delete result.onPointermove;
delete result.onPointerup;
delete result.onPointercancel;
delete result.onClick;
return result;
});
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let dragDistance = 0;
let suppressClick = false;
const gesture = createPointerGesture(
() => root.value,
runtime,
async (direction) =>
runtime.beginInteractive("push", props.to, {
presentation: props.presentation,
replace: props.replace,
direction,
sourceRect: root.value ? sourceRect(root.value) : undefined,
}),
(dx) => {
if (props.direction === "left" && dx >= 0) return null;
if (props.direction === "right" && dx <= 0) return null;
return dx < 0 ? "forward" : "back";
},
);
onBeforeUnmount(() => void runtime.cancelInteractive());
function pointerDown(event: PointerEvent) {
// Component-owned gestures outrank their containing navigator.
event.stopPropagation();
dragDistance = 0;
suppressClick = false;
gesture.down(event);
}
function pointerMove(event: PointerEvent) {
if (event.buttons) {
dragDistance += Math.abs(event.movementX);
if (dragDistance > 8) suppressClick = true;
}
void gesture.move(event);
}
function click(event: MouseEvent) {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
void runtime.push(props.to, {
presentation: props.presentation,
replace: props.replace,
});
}
</script>
<template>
<component
:is="as"
ref="root"
v-bind="passthroughAttrs"
class="nvr-gesture-link"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="gesture.up"
@pointercancel="gesture.cancel"
@click="click"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, useAttrs, type PropType } from "vue";
import type { RouteLocationRaw } from "vue-router";
import type { NativePresentationName } from "../types";
import { useNativeRouter } from "./lifecycle";
defineOptions({ name: "NativeLink", inheritAttrs: false });
const props = defineProps({
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
replace: Boolean,
presentation: String as PropType<NativePresentationName>,
});
const attrs = useAttrs();
const passthroughAttrs = computed(() => {
const result = { ...attrs };
delete result.onClick;
return result;
});
const runtime = useNativeRouter();
const href = computed(() => runtime.router.resolve(props.to).href);
function activate(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
event.preventDefault();
void (props.replace
? runtime.replace(props.to, { presentation: props.presentation })
: runtime.push(props.to, { presentation: props.presentation }));
}
</script>
<template>
<a v-bind="passthroughAttrs" :href="href" @click="activate"><slot /></a>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref, type PropType } from "vue";
import type { RouteLocationRaw } from "vue-router";
import { createPointerGesture, shouldIgnoreGesture } from "./gestures";
import { useNativeRouter } from "./lifecycle";
defineOptions({ name: "NativeNavigator" });
const props = defineProps({
siblings: {
type: Array as PropType<RouteLocationRaw[]>,
default: () => [],
},
edgeWidth: { type: Number, default: 28 },
});
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let candidate: "back" | "sibling" | null = null;
const gesture = createPointerGesture(
() => root.value,
runtime,
async (direction) => {
if (candidate === "back") return runtime.beginInteractive("pop");
const current = runtime.router.currentRoute.value.fullPath;
const index = props.siblings.findIndex(
(route) => runtime.router.resolve(route).fullPath === current,
);
const nextIndex = index + (direction === "forward" ? 1 : -1);
const target = props.siblings[nextIndex];
if (index < 0 || !target) return null;
return runtime.beginInteractive("sibling", target, {
replace:
runtime.router.resolve(target).meta.native?.siblingHistory !== "push",
direction,
presentation: "slide",
});
},
(dx) => {
const rtl =
getComputedStyle(root.value ?? document.documentElement).direction ===
"rtl";
const logicalDx = rtl ? -dx : dx;
if (candidate === "back") return logicalDx > 0 ? "back" : null;
return logicalDx < 0 ? "forward" : "back";
},
);
const gestureSettingAllowsNavigation = () =>
runtime.router.currentRoute.value.meta.native?.gesture !== false;
function atLeadingEdge(event: PointerEvent) {
const rect = root.value?.getBoundingClientRect();
const rtl =
getComputedStyle(root.value ?? document.documentElement).direction ===
"rtl";
return rect
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <=
props.edgeWidth
: false;
}
function captureDown(event: PointerEvent) {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
if (
!atLeadingEdge(event) ||
!gestureSettingAllowsNavigation() ||
!runtime.canGoBack.value
)
return;
candidate = "back";
gesture.down(event);
// The application shell owns the physical back edge. Component-owned route
// gestures retain priority everywhere else.
event.stopPropagation();
}
function down(event: PointerEvent) {
candidate =
props.siblings.length && gestureSettingAllowsNavigation()
? "sibling"
: null;
if (candidate) gesture.down(event);
}
</script>
<template>
<div
ref="root"
class="nvr-navigator"
:data-native-can-go-back="String(runtime.canGoBack.value)"
@pointerdown.capture="captureDown"
@pointerdown="down"
@pointermove="gesture.move"
@pointerup="gesture.up"
@pointercancel="gesture.cancel"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, provide, shallowReactive, watch, type PropType } from "vue";
import {
routeLocationKey,
type RouteLocationNormalizedLoaded,
} from "vue-router";
import type { NativeViewLifecycle, NativeViewRole } from "../types";
import { nativeViewLifecycleKey, useNativeRouter } from "./lifecycle";
const props = defineProps({
route: {
type: Object as PropType<RouteLocationNormalizedLoaded>,
required: true,
},
entryKey: { type: String, required: true },
});
const runtime = useNativeRouter();
const scopedRoute = shallowReactive({
...props.route,
}) as RouteLocationNormalizedLoaded;
watch(
() => props.route,
(route) => Object.assign(scopedRoute, route),
{ immediate: true },
);
provide(routeLocationKey, scopedRoute);
const entry = computed(() =>
runtime.entries.value.find((candidate) => candidate.key === props.entryKey),
);
const role = computed<NativeViewRole>(() => {
const transaction = runtime.transaction.value;
if (!entry.value) return "inactive";
if (!transaction)
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";
});
provide<NativeViewLifecycle>(nativeViewLifecycleKey, {
key: props.entryKey,
route: computed(() => entry.value?.route ?? props.route),
status: computed(() => entry.value?.status ?? "evicted"),
role,
isActive: computed(() => runtime.activeKey.value === props.entryKey),
isVisible: computed(() => role.value !== "inactive"),
isPreview: computed(
() => runtime.transaction.value?.toKey === props.entryKey,
),
isCached: computed(() =>
Boolean(entry.value?.mounted && role.value === "inactive"),
),
evictionReason: computed(() => entry.value?.evictionReason),
});
</script>
<template>
<slot />
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, useSlots } from "vue";
import { RouterView } from "vue-router";
import type {
NativeRouterRuntime,
NativeViewEntry,
NativeViewRole,
} from "../types";
import { useNativeRouter } from "./lifecycle";
import NativeRouteScope from "./NativeRouteScope.vue";
defineOptions({ name: "NativeRouterView" });
const runtime = useNativeRouter();
const slots = useSlots();
const mountedEntries = computed(() =>
runtime.entries.value.filter((entry) => entry.mounted),
);
const transaction = computed(() => runtime.transaction.value);
function roleFor(entry: NativeViewEntry) {
return interactiveRole(entry, runtime);
}
function layerStyleFor(entry: NativeViewEntry) {
const currentTransaction = transaction.value;
if (!currentTransaction) return undefined;
const role = roleFor(entry);
if (role !== "from" && role !== "to") return undefined;
return runtime
.presentationFor(currentTransaction.presentation)
?.layerStyle?.({
progress: currentTransaction.progress,
role,
direction: currentTransaction.direction,
sourceRect: currentTransaction.sourceRect,
});
}
function interactiveRole(
entry: NativeViewEntry,
nativeRuntime: NativeRouterRuntime,
): NativeViewRole {
const currentTransaction = nativeRuntime.transaction.value;
if (!currentTransaction)
return entry.key === nativeRuntime.activeKey.value ? "active" : "inactive";
if (entry.key === currentTransaction.fromKey) return "from";
if (entry.key === currentTransaction.toKey) return "to";
return "inactive";
}
</script>
<template>
<div
:class="['nvr-router-view', transaction && 'nvr-router-view--interactive']"
:style="
transaction
? { '--native-progress': String(transaction.progress) }
: undefined
"
:data-native-presentation="transaction?.presentation"
:data-native-direction="transaction?.direction"
:data-native-transaction="transaction?.id"
:data-native-velocity="
transaction ? String(transaction.velocity) : undefined
"
>
<section
v-for="entry in mountedEntries"
:key="entry.key"
:class="['nvr-view', `nvr-view--${roleFor(entry)}`]"
:style="layerStyleFor(entry)"
:data-native-role="roleFor(entry)"
:data-native-route="entry.route.fullPath"
:data-native-presentation="transaction?.presentation"
:data-native-direction="transaction?.direction"
:inert="roleFor(entry) === 'inactive' ? true : undefined"
:aria-hidden="roleFor(entry) === 'inactive' ? '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>
</RouterView>
</section>
</div>
</template>

View File

@@ -0,0 +1,136 @@
import type {
NativeDirection,
NativeNavigationOptions,
NativeRouterRuntime,
NativeSourceRect,
} from "../types";
export function shouldIgnoreGesture(target: EventTarget | null) {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
);
}
export function sourceRect(element: HTMLElement): NativeSourceRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
}
export function createPointerGesture(
element: () => HTMLElement | null,
runtime: NativeRouterRuntime,
begin: (direction: NativeDirection) => Promise<number | null>,
directionForDelta: (deltaX: number) => NativeDirection | null,
) {
let pointerId = -1;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastTime = 0;
let captured = false;
let beginPromise: Promise<number | null> | null = null;
let ending = false;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let gestureSign = 0;
const reset = () => {
pointerId = -1;
captured = false;
beginPromise = null;
ending = false;
bufferedProgress = 0;
bufferedVelocity = 0;
gestureSign = 0;
};
const down = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
pointerId = event.pointerId;
startX = lastX = event.clientX;
startY = event.clientY;
lastTime = event.timeStamp;
captured = false;
beginPromise = null;
ending = false;
bufferedProgress = 0;
bufferedVelocity = 0;
gestureSign = 0;
};
const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!captured) {
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return;
const direction = directionForDelta(dx);
if (!direction) return reset();
captured = true;
ending = false;
gestureSign = Math.sign(dx);
element()?.setPointerCapture(pointerId);
beginPromise = begin(direction);
}
event.preventDefault();
const elapsed = Math.max(8, event.timeStamp - lastTime);
const width = Math.max(1, element()?.clientWidth ?? window.innerWidth);
bufferedVelocity =
((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width);
bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width));
lastX = event.clientX;
lastTime = event.timeStamp;
const pending = beginPromise;
if (pending) {
const id = await pending;
if (pending !== beginPromise || ending) return;
if (id === null) return reset();
runtime.updateInteractive(bufferedProgress, bufferedVelocity);
}
};
const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
ending = true;
const pending = beginPromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
// Detach this pointer before awaiting preload/navigation/animation work. A
// new gesture may now start without this release callback erasing it.
reset();
const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
runtime.updateInteractive(progress, velocity);
await runtime.finishInteractive();
}
};
const cancel = async () => {
ending = true;
const pending = beginPromise;
const shouldCancel = captured;
reset();
const id = pending ? await pending : null;
if (shouldCancel && id !== null && runtime.transaction.value?.id === id)
await runtime.cancelInteractive();
};
return { down, move, up, cancel };
}
export function navigationOptionsFromElement(
element: HTMLElement,
): NativeNavigationOptions {
return { sourceRect: sourceRect(element) };
}

View File

@@ -0,0 +1,17 @@
export { default as NativeDismissGesture } from "./NativeDismissGesture.vue";
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 { navigationOptionsFromElement } from "./gestures";
export {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
onNativeViewHide,
onNativeViewShow,
useNativeRouter,
useNativeViewActiveEffect,
useNativeViewLifecycle,
useNativeViewVisibleEffect,
} from "./lifecycle";

View File

@@ -0,0 +1,110 @@
import {
inject,
onBeforeUnmount,
onMounted,
onScopeDispose,
watch,
type InjectionKey,
} from "vue";
import { nativeRouterKey } from "../runtime";
import type { NativeRouterRuntime, NativeViewLifecycle } from "../types";
export const nativeViewLifecycleKey: InjectionKey<NativeViewLifecycle> = Symbol(
"native-view-lifecycle",
);
export function useNativeRouter() {
const runtime = inject<NativeRouterRuntime>(nativeRouterKey);
if (!runtime)
throw new Error(
"Native Vue Router is not installed. Call app.use(nativeRouter).",
);
return runtime;
}
export function useNativeViewLifecycle() {
const lifecycle = inject<NativeViewLifecycle>(nativeViewLifecycleKey);
if (!lifecycle)
throw new Error(
"Native view lifecycle APIs must be used inside NativeRouterView.",
);
return lifecycle;
}
type NativeViewHook = () => void;
function onNativeViewState(
source: Readonly<{ value: boolean }>,
entering: boolean,
hook: NativeViewHook,
) {
onMounted(() => {
if (entering && source.value) hook();
});
watch(
() => source.value,
(value, previous) => {
if (value === entering && previous !== entering) hook();
},
{ flush: "sync" },
);
}
export function onNativeViewActivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, true, hook);
}
export function onNativeViewDeactivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, false, hook);
}
export function onNativeViewShow(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, true, hook);
}
export function onNativeViewHide(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, false, hook);
}
export function onNativeViewEvict(
hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void,
) {
const lifecycle = useNativeViewLifecycle();
onBeforeUnmount(() => {
if (lifecycle.status.value === "evicted")
hook(lifecycle.evictionReason.value);
});
}
function useNativeViewEffect(
source: Readonly<{ value: boolean }>,
effect: () => void | (() => void),
) {
let cleanup: void | (() => void);
const stopEffect = () => {
cleanup?.();
cleanup = undefined;
};
const stopWatch = watch(
() => source.value,
(enabled) => {
stopEffect();
if (enabled) cleanup = effect();
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(() => {
stopWatch();
stopEffect();
});
}
/** Runs an effect only while this route is the semantically active route. */
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isActive, effect);
}
/** Runs an effect while this route is active or participating in a transition. */
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isVisible, effect);
}