Move components.ts into many SFC files
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
138
packages/core/src/components/NativeDismissGesture.vue
Executable file
138
packages/core/src/components/NativeDismissGesture.vue
Executable 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>
|
||||
104
packages/core/src/components/NativeGestureLink.vue
Executable file
104
packages/core/src/components/NativeGestureLink.vue
Executable 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>
|
||||
45
packages/core/src/components/NativeLink.vue
Executable file
45
packages/core/src/components/NativeLink.vue
Executable 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>
|
||||
105
packages/core/src/components/NativeNavigator.vue
Executable file
105
packages/core/src/components/NativeNavigator.vue
Executable 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>
|
||||
62
packages/core/src/components/NativeRouteScope.vue
Executable file
62
packages/core/src/components/NativeRouteScope.vue
Executable 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>
|
||||
97
packages/core/src/components/NativeRouterView.vue
Executable file
97
packages/core/src/components/NativeRouterView.vue
Executable 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>
|
||||
136
packages/core/src/components/gestures.ts
Executable file
136
packages/core/src/components/gestures.ts
Executable 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) };
|
||||
}
|
||||
17
packages/core/src/components/index.ts
Executable file
17
packages/core/src/components/index.ts
Executable 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";
|
||||
110
packages/core/src/components/lifecycle.ts
Executable file
110
packages/core/src/components/lifecycle.ts
Executable 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);
|
||||
}
|
||||
Reference in New Issue
Block a user