import { computed, defineComponent, h, inject, onBeforeUnmount, provide, ref, shallowReactive, watch, type PropType, 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, } from './types' export function useNativeRouter() { const runtime = inject(nativeRouterKey) if (!runtime) throw new Error('Native Vue Router is not installed. Call app.use(nativeRouter).') return runtime } export function useNativeViewLifecycle() { const runtime = useNativeRouter() return { activeKey: runtime.activeKey, transaction: runtime.transaction, } } const NativeRouteScope = defineComponent({ name: 'NativeRouteScope', props: { route: { type: Object as PropType, required: true }, }, setup(props, { slots }) { const scopedRoute = shallowReactive({ ...props.route }) as RouteLocationNormalizedLoaded watch(() => props.route, (route) => Object.assign(scopedRoute, route), { immediate: true }) provide(routeLocationKey, scopedRoute) return () => slots.default?.() }, }) function interactiveRole(entry: NativeViewEntry, runtime: NativeRouterRuntime) { 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-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 }, { 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, }, children) } }, }) export const NativeLink = defineComponent({ name: 'NativeLink', props: { to: { type: [String, Object] as PropType, 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, 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 | null = null let ending = false let bufferedProgress = 0 let bufferedVelocity = 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 } 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 element()?.setPointerCapture(pointerId) beginPromise = begin(direction) } event.preventDefault() const elapsed = Math.max(8, event.timeStamp - lastTime) bufferedVelocity = Math.abs(event.clientX - lastX) / elapsed bufferedProgress = Math.min(1, Math.abs(dx) / Math.max(1, element()?.clientWidth ?? window.innerWidth)) lastX = event.clientX lastTime = event.timeStamp const pending = beginPromise if (pending) { const id = await pending if (id === null) return reset() if (pending !== beginPromise || ending) return 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 id = beginPromise ? await beginPromise : null if (captured && id !== null && runtime) { runtime.updateInteractive(bufferedProgress, bufferedVelocity) await runtime.finishInteractive() } reset() } const cancel = async () => { ending = true const runtime = injectRuntimeFromElement(element()) const id = beginPromise ? await beginPromise : null if (captured && id !== null && runtime) await runtime.cancelInteractive() reset() } const reset = () => { pointerId = -1 captured = false beginPromise = null ending = false bufferedProgress = 0 bufferedVelocity = 0 } return { down, move, up, cancel } } const runtimeByElement = new WeakMap() 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, 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(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, default: () => [] }, edgeWidth: { type: Number, default: 28 }, }, setup(props, { slots }) { const runtime = useNativeRouter() const root = ref(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 down = (event: PointerEvent) => { const rect = root.value?.getBoundingClientRect() const rtl = getComputedStyle(root.value ?? document.documentElement).direction === 'rtl' const gestureSetting = runtime.router.currentRoute.value.meta.native?.gesture const atEdge = rect ? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <= props.edgeWidth : false candidate = atEdge && gestureSetting !== false && runtime.canGoBack.value ? 'back' : props.siblings.length && gestureSetting !== false ? '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', 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(null) let pointerId = -1 let startX = 0 let startY = 0 let lastY = 0 let lastTime = 0 let captured = false let beginPromise: Promise | 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))) velocity = Math.max(0, event.clientY - lastY) / Math.max(8, event.timeStamp - lastTime) lastY = event.clientY lastTime = event.timeStamp const pending = beginPromise if (pending) { const id = await pending if (id === null) return reset() if (pending !== beginPromise || ending) return runtime.updateInteractive(progress, velocity) } } const up = async (event: PointerEvent) => { if (event.pointerId !== pointerId) return ending = true const id = beginPromise ? await beginPromise : null if (captured && id !== null) { runtime.updateInteractive(progress, velocity) await runtime.finishInteractive() } reset() } const cancel = async () => { ending = true const id = beginPromise ? await beginPromise : null if (captured && id !== null) await runtime.cancelInteractive() reset() } 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) } }