first commit
This commit is contained in:
17
packages/core/package.json
Normal file
17
packages/core/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@native-vue-router/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"files": ["dist"],
|
||||
"sideEffects": ["./dist/style.css"],
|
||||
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" },
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
||||
"./style.css": "./dist/style.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.0"
|
||||
}
|
||||
}
|
||||
463
packages/core/src/components.ts
Normal file
463
packages/core/src/components.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
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<NativeRouterRuntime>(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<RouteLocationNormalizedLoaded>, 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<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
|
||||
|
||||
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<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 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<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)))
|
||||
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) }
|
||||
}
|
||||
10
packages/core/src/index.ts
Normal file
10
packages/core/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './types'
|
||||
export * from './runtime'
|
||||
export * from './components'
|
||||
import './style.css'
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$nativeRouter: import('./types').NativeRouterRuntime
|
||||
}
|
||||
}
|
||||
193
packages/core/src/runtime.test.ts
Normal file
193
packages/core/src/runtime.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { createApp, defineComponent, nextTick } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNativeRouter, definePresentation, shouldCommitGesture } from './runtime'
|
||||
|
||||
const Page = defineComponent({ template: '<div>page</div>' })
|
||||
|
||||
async function harness(blockB: boolean | 'redirect' = false) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/a', component: Page },
|
||||
{ path: '/b', component: Page, meta: { native: { parent: '/a' } } },
|
||||
{ path: '/c', component: Page, meta: { native: { parent: '/a' } } },
|
||||
{ path: '/modal', component: Page, meta: { native: { presentation: 'sheet', parent: '/a' } } },
|
||||
{ path: '/left', component: Page, meta: { native: { siblingOrder: 0, siblingHistory: 'replace' } } },
|
||||
{ path: '/middle', component: Page, meta: { native: { siblingOrder: 1, siblingHistory: 'replace' } } },
|
||||
{ path: '/right', component: Page, meta: { native: { siblingOrder: 2, siblingHistory: 'replace' } } },
|
||||
],
|
||||
})
|
||||
if (blockB) router.beforeEach((to) => to.path === '/b' ? (blockB === 'redirect' ? '/modal' : false) : undefined)
|
||||
await router.push('/a')
|
||||
await router.isReady()
|
||||
const native = createNativeRouter({ router, cache: { maxInactive: 2 } })
|
||||
const app = createApp(Page)
|
||||
app.use(router)
|
||||
app.use(native)
|
||||
await nextTick()
|
||||
return { router, native }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList)
|
||||
})
|
||||
|
||||
describe('gesture decisions', () => {
|
||||
it('uses progress or a deliberate velocity to commit', () => {
|
||||
expect(shouldCommitGesture(0.4, 0)).toBe(true)
|
||||
expect(shouldCommitGesture(0.12, 0.7)).toBe(true)
|
||||
expect(shouldCommitGesture(0.04, 1.4)).toBe(false)
|
||||
expect(shouldCommitGesture(0.2, 0.1)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native router transactions', () => {
|
||||
it('preloads a target without changing URL history', async () => {
|
||||
const { router, native } = await harness()
|
||||
const id = await native.beginInteractive('push', '/b')
|
||||
expect(id).not.toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/a')
|
||||
expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(true)
|
||||
await native.cancelInteractive()
|
||||
expect(router.currentRoute.value.path).toBe('/a')
|
||||
expect(native.transaction.value).toBeNull()
|
||||
})
|
||||
|
||||
it('commits a loaded preview through Vue Router', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.beginInteractive('push', '/b')
|
||||
native.updateInteractive(0.55, 0.1)
|
||||
expect(await native.finishInteractive()).toBe(true)
|
||||
expect(router.currentRoute.value.path).toBe('/b')
|
||||
expect(native.entries.value.filter((entry) => entry.status === 'active')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('snaps back and removes the preview when a guard rejects commit', async () => {
|
||||
const { router, native } = await harness(true)
|
||||
await native.beginInteractive('push', '/b')
|
||||
native.updateInteractive(0.8, 0)
|
||||
expect(await native.finishInteractive()).toBe(false)
|
||||
expect(router.currentRoute.value.path).toBe('/a')
|
||||
expect(native.entries.value.some((entry) => entry.route.path === '/b')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses declared parents for cold-start predictive back', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.replace('/b', { presentation: 'none' })
|
||||
const transaction = await native.beginInteractive('pop')
|
||||
expect(transaction).not.toBeNull()
|
||||
expect(native.transaction.value?.direction).toBe('back')
|
||||
await native.cancelInteractive()
|
||||
expect(router.currentRoute.value.path).toBe('/b')
|
||||
})
|
||||
|
||||
it('discards the stale preview when Vue Router redirects a commit', async () => {
|
||||
const { router, native } = await harness('redirect')
|
||||
await native.beginInteractive('push', '/b')
|
||||
expect(await native.finishInteractive(true)).toBe(true)
|
||||
expect(router.currentRoute.value.path).toBe('/modal')
|
||||
expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(false)
|
||||
})
|
||||
|
||||
it('registers application-defined presentations', async () => {
|
||||
const { native } = await harness()
|
||||
const presentation = definePresentation({ name: 'flip', axis: 'x', layerStyle: () => ({ opacity: 0.5 }) })
|
||||
native.registerPresentation(presentation)
|
||||
expect(native.presentationFor('flip')).toBe(presentation)
|
||||
})
|
||||
|
||||
it('treats navigation to the active route as a strict no-op', async () => {
|
||||
const { router, native } = await harness()
|
||||
const entries = [...native.entries.value]
|
||||
expect(await native.push('/a')).toBe(false)
|
||||
expect(await native.replace('/a')).toBe(false)
|
||||
expect(router.currentRoute.value.path).toBe('/a')
|
||||
expect(native.transaction.value).toBeNull()
|
||||
expect(native.entries.value).toEqual(entries)
|
||||
})
|
||||
|
||||
it('derives sibling direction from route order and uses adjacent-page motion', async () => {
|
||||
const { native } = await harness()
|
||||
await native.replace('/middle', { presentation: 'none' })
|
||||
|
||||
await native.beginInteractive('sibling', '/left', { replace: true })
|
||||
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'slide' })
|
||||
await native.cancelInteractive()
|
||||
|
||||
await native.beginInteractive('sibling', '/right', { replace: true })
|
||||
expect(native.transaction.value).toMatchObject({ direction: 'forward', presentation: 'slide' })
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('keeps replaced sibling views cached but out of the back stack', async () => {
|
||||
const { native } = await harness()
|
||||
await native.replace('/left', { presentation: 'none' })
|
||||
await native.sibling('/middle', { replace: true })
|
||||
await native.push('/c')
|
||||
|
||||
await native.beginInteractive('pop')
|
||||
const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey)
|
||||
expect(target?.route.path).toBe('/middle')
|
||||
expect(target?.route.path).not.toBe('/left')
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('does not preview a stale forward entry after pop then push', async () => {
|
||||
const { native } = await harness()
|
||||
await native.push('/b')
|
||||
await native.pop()
|
||||
await native.push('/c')
|
||||
|
||||
await native.beginInteractive('pop')
|
||||
const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey)
|
||||
expect(target?.route.path).toBe('/a')
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('dismisses with the presented route animation regardless of the route below it', async () => {
|
||||
const { native } = await harness()
|
||||
await native.push('/b')
|
||||
await native.present('/modal', 'sheet')
|
||||
|
||||
await native.beginInteractive('dismiss')
|
||||
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'sheet' })
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('refuses to overlap a second transaction with an active gesture', async () => {
|
||||
const { native } = await harness()
|
||||
const first = await native.beginInteractive('push', '/b')
|
||||
expect(await native.beginInteractive('push', '/c')).toBeNull()
|
||||
expect(native.transaction.value?.id).toBe(first)
|
||||
await native.cancelInteractive()
|
||||
})
|
||||
|
||||
it('queues imperative navigation until the current transition settles', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.beginInteractive('push', '/b')
|
||||
const finishing = native.finishInteractive(true)
|
||||
const queued = native.push('/c')
|
||||
expect(await finishing).toBe(true)
|
||||
expect(await queued).toBe(true)
|
||||
expect(router.currentRoute.value.path).toBe('/c')
|
||||
expect(native.transaction.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reconciles direct browser back navigation with the native stack', async () => {
|
||||
const { router, native } = await harness()
|
||||
await native.push('/b')
|
||||
const navigated = new Promise<void>((resolve) => {
|
||||
const remove = router.afterEach(() => {
|
||||
remove()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
router.back()
|
||||
await navigated
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/a')
|
||||
expect(native.canGoBack.value).toBe(false)
|
||||
expect(await native.beginInteractive('pop')).toBeNull()
|
||||
})
|
||||
})
|
||||
556
packages/core/src/runtime.ts
Normal file
556
packages/core/src/runtime.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
shallowRef,
|
||||
type App,
|
||||
type CSSProperties,
|
||||
} from 'vue'
|
||||
import {
|
||||
isNavigationFailure,
|
||||
loadRouteLocation,
|
||||
START_LOCATION,
|
||||
type NavigationFailure,
|
||||
type RouteLocationNormalizedLoaded,
|
||||
type RouteLocationRaw,
|
||||
} from 'vue-router'
|
||||
import type {
|
||||
NativeDirection,
|
||||
NativeGestureKind,
|
||||
NativeNavigationOptions,
|
||||
NativePlatformAdapter,
|
||||
NativePresentationDefinition,
|
||||
NativePresentationName,
|
||||
NativeRouterOptions,
|
||||
NativeRouterRuntime,
|
||||
NativeTransaction,
|
||||
NativeViewEntry,
|
||||
} from './types'
|
||||
|
||||
export const nativeRouterKey = Symbol('native-vue-router')
|
||||
|
||||
let entrySequence = 0
|
||||
|
||||
function now() {
|
||||
return typeof performance === 'undefined' ? Date.now() : performance.now()
|
||||
}
|
||||
|
||||
function entryFor(route: RouteLocationNormalizedLoaded, status: NativeViewEntry['status'], synthetic = false): NativeViewEntry {
|
||||
return {
|
||||
key: `${route.fullPath}::${++entrySequence}`,
|
||||
route,
|
||||
status,
|
||||
mounted: true,
|
||||
synthetic,
|
||||
committed: status !== 'preview',
|
||||
lastUsed: now(),
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldCommitGesture(progress: number, velocity: number, threshold = 0.36) {
|
||||
return progress >= threshold || (progress >= 0.08 && velocity >= 0.52)
|
||||
}
|
||||
|
||||
export function definePresentation(definition: NativePresentationDefinition) {
|
||||
return definition
|
||||
}
|
||||
|
||||
const builtinPresentations: NativePresentationDefinition[] = [
|
||||
{ name: 'push', axis: 'x' },
|
||||
{ name: 'reveal', axis: 'x' },
|
||||
{ name: 'slide', axis: 'x' },
|
||||
{ name: 'fade', axis: 'x' },
|
||||
{ name: 'modal', axis: 'y' },
|
||||
{ name: 'sheet', axis: 'y' },
|
||||
{ name: 'none', axis: 'x' },
|
||||
]
|
||||
|
||||
class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
readonly router
|
||||
readonly entries
|
||||
readonly activeKey
|
||||
readonly transaction
|
||||
readonly canGoBack
|
||||
|
||||
private readonly mutableEntries = shallowRef<NativeViewEntry[]>([])
|
||||
private readonly mutableActiveKey = ref('')
|
||||
private readonly mutableTransaction = shallowRef<NativeTransaction | null>(null)
|
||||
private readonly mutableHistoryKeys = shallowRef<string[]>([])
|
||||
private readonly maxInactive: number
|
||||
private readonly platform?: NativePlatformAdapter
|
||||
private readonly presentations = new Map<NativePresentationName, NativePresentationDefinition>()
|
||||
private transactionSequence = 0
|
||||
private readonly idleWaiters = new Set<() => void>()
|
||||
private removeAfterEach?: () => void
|
||||
private platformCleanup?: () => void
|
||||
private pendingPop?: (failure?: NavigationFailure | void) => void
|
||||
|
||||
constructor(options: NativeRouterOptions) {
|
||||
this.router = options.router
|
||||
this.maxInactive = options.cache?.maxInactive ?? 8
|
||||
this.platform = options.platform
|
||||
this.entries = computed(() => this.mutableEntries.value)
|
||||
this.activeKey = computed(() => this.mutableActiveKey.value)
|
||||
this.transaction = computed(() => this.mutableTransaction.value)
|
||||
this.canGoBack = computed(() => {
|
||||
return this.mutableHistoryKeys.value.length > 1 || Boolean(this.activeEntry()?.route.meta.native?.parent)
|
||||
})
|
||||
|
||||
for (const definition of builtinPresentations) this.registerPresentation(definition)
|
||||
for (const definition of options.presentations ?? []) this.registerPresentation(definition)
|
||||
|
||||
this.removeAfterEach = this.router.afterEach((to, from, failure) => {
|
||||
if (!failure) this.acceptRoute(to, from)
|
||||
this.pendingPop?.(failure)
|
||||
this.pendingPop = undefined
|
||||
})
|
||||
}
|
||||
|
||||
install(app: App) {
|
||||
app.provide(nativeRouterKey, this)
|
||||
app.config.globalProperties.$nativeRouter = this
|
||||
void this.router.isReady().then(() => {
|
||||
if (this.mutableEntries.value.length === 0 && this.router.currentRoute.value !== START_LOCATION) {
|
||||
const initial = entryFor(this.router.currentRoute.value, 'active')
|
||||
this.mutableEntries.value = [initial]
|
||||
this.mutableActiveKey.value = initial.key
|
||||
this.mutableHistoryKeys.value = [initial.key]
|
||||
}
|
||||
})
|
||||
if (this.platform?.install) {
|
||||
void Promise.resolve(this.platform.install(this)).then((cleanup) => {
|
||||
if (cleanup) this.platformCleanup = cleanup
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async preload(to: RouteLocationRaw) {
|
||||
const resolved = this.router.resolve(to)
|
||||
return await loadRouteLocation(resolved)
|
||||
}
|
||||
|
||||
async push(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('push', to, options)
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async replace(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('push', to, { ...options, replace: true })
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async sibling(to: RouteLocationRaw, options: NativeNavigationOptions = {}) {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('sibling', to, options)
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async pop() {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('pop')
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async present(to: RouteLocationRaw, presentation: NativePresentationName = 'modal') {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('present', to, { presentation })
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
await this.waitForIdle()
|
||||
const id = await this.beginInteractive('dismiss')
|
||||
if (id === null) return false
|
||||
return await this.finishInteractive(true)
|
||||
}
|
||||
|
||||
async beginInteractive(
|
||||
kind: NativeGestureKind,
|
||||
to?: RouteLocationRaw,
|
||||
options: NativeNavigationOptions = {},
|
||||
) {
|
||||
// A settling/committing navigation is authoritative. Starting another
|
||||
// transaction here would allow two animations to mutate the same ledger.
|
||||
if (this.mutableTransaction.value) return null
|
||||
const from = this.activeEntry()
|
||||
if (!from) return null
|
||||
|
||||
let target: NativeViewEntry | undefined
|
||||
let synthetic = false
|
||||
const isBack = kind === 'pop' || kind === 'dismiss'
|
||||
|
||||
if (isBack) {
|
||||
const history = this.mutableHistoryKeys.value
|
||||
target = history.length > 1 ? this.entryByKey(history[history.length - 2]) : undefined
|
||||
if (!target) {
|
||||
const parent = from.route.meta.native?.parent
|
||||
if (!parent) return null
|
||||
const parentLocation = typeof parent === 'function' ? parent(from.route) : parent
|
||||
const route = await this.preload(parentLocation)
|
||||
target = entryFor(route, 'preview', true)
|
||||
synthetic = true
|
||||
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
||||
} else if (!target.mounted) {
|
||||
target.route = await this.preload(target.route.fullPath)
|
||||
target.mounted = true
|
||||
target.status = 'inactive'
|
||||
this.touchEntries()
|
||||
}
|
||||
} else {
|
||||
if (!to) return null
|
||||
const resolved = this.router.resolve(to)
|
||||
if (resolved.fullPath === from.route.fullPath) return null
|
||||
const route = await loadRouteLocation(resolved)
|
||||
target = this.findReusable(route.fullPath)
|
||||
if (target) {
|
||||
target.route = route
|
||||
target.mounted = true
|
||||
target.status = 'preview'
|
||||
target.synthetic = false
|
||||
target.lastUsed = now()
|
||||
this.touchEntries()
|
||||
} else {
|
||||
target = entryFor(route, 'preview')
|
||||
this.mutableEntries.value = [...this.mutableEntries.value, target]
|
||||
}
|
||||
}
|
||||
|
||||
const presentationRoute = isBack ? from.route : target.route
|
||||
const presentation = options.presentation
|
||||
?? presentationRoute.meta.native?.presentation
|
||||
?? presentationRoute.meta.native?.transition
|
||||
?? (kind === 'present' || kind === 'dismiss' ? 'modal' : kind === 'sibling' ? 'slide' : 'push')
|
||||
const direction: NativeDirection = options.direction
|
||||
?? (kind === 'pop' || kind === 'dismiss'
|
||||
? 'back'
|
||||
: kind === 'present'
|
||||
? 'up'
|
||||
: kind === 'sibling'
|
||||
? this.siblingDirection(from.route, target.route)
|
||||
: 'forward')
|
||||
|
||||
const transaction: NativeTransaction = {
|
||||
id: ++this.transactionSequence,
|
||||
kind,
|
||||
direction,
|
||||
presentation,
|
||||
fromKey: from.key,
|
||||
toKey: target.key,
|
||||
progress: 0,
|
||||
velocity: 0,
|
||||
phase: 'interactive',
|
||||
replace: options.replace ?? (target.route.meta.native?.siblingHistory === 'replace'),
|
||||
sourceRect: options.sourceRect,
|
||||
}
|
||||
this.mutableTransaction.value = transaction
|
||||
if (synthetic) target.synthetic = true
|
||||
return transaction.id
|
||||
}
|
||||
|
||||
updateInteractive(progress: number, velocity = 0) {
|
||||
const current = this.mutableTransaction.value
|
||||
if (!current || current.phase !== 'interactive') return
|
||||
this.mutableTransaction.value = {
|
||||
...current,
|
||||
progress: Math.max(0, Math.min(1, progress)),
|
||||
velocity,
|
||||
}
|
||||
}
|
||||
|
||||
async finishInteractive(forceCommit?: boolean) {
|
||||
const current = this.mutableTransaction.value
|
||||
if (!current) return false
|
||||
const commit = forceCommit ?? shouldCommitGesture(current.progress, current.velocity)
|
||||
if (!commit) {
|
||||
await this.cancelInteractive()
|
||||
return false
|
||||
}
|
||||
|
||||
this.mutableTransaction.value = { ...current, phase: 'committing' }
|
||||
void this.platform?.haptic?.('commit')
|
||||
let failed = false
|
||||
try {
|
||||
const navigation = this.commitRoute(current)
|
||||
await this.animateProgress(1, current.velocity)
|
||||
failed = Boolean(await navigation)
|
||||
} catch {
|
||||
await this.animateProgress(0, 0)
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
return false
|
||||
}
|
||||
if (failed) {
|
||||
await this.animateProgress(0, 0)
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
return false
|
||||
}
|
||||
|
||||
const target = this.entryByKey(current.toKey)
|
||||
if (target && this.router.currentRoute.value.fullPath !== target.route.fullPath) {
|
||||
// Vue Router accepted the navigation but redirected it. The redirected
|
||||
// route is already authoritative; unwind the stale visual preview.
|
||||
await this.animateProgress(0, 0)
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
return true
|
||||
}
|
||||
if (target) {
|
||||
target.status = 'active'
|
||||
target.synthetic = false
|
||||
target.committed = true
|
||||
target.lastUsed = now()
|
||||
this.mutableActiveKey.value = target.key
|
||||
}
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
this.enforceCache()
|
||||
return true
|
||||
}
|
||||
|
||||
async cancelInteractive() {
|
||||
const current = this.mutableTransaction.value
|
||||
if (!current || current.phase !== 'interactive') return
|
||||
this.mutableTransaction.value = { ...current, phase: 'cancelled' }
|
||||
await this.animateProgress(0, 0)
|
||||
void this.platform?.haptic?.('cancel')
|
||||
this.removePreview(current.toKey)
|
||||
this.clearTransaction()
|
||||
this.markStatuses()
|
||||
}
|
||||
|
||||
registerPresentation(definition: NativePresentationDefinition) {
|
||||
this.presentations.set(definition.name, definition)
|
||||
}
|
||||
|
||||
presentationFor(name: NativePresentationName) {
|
||||
return this.presentations.get(name)
|
||||
}
|
||||
|
||||
layerStyle(entry: NativeViewEntry): CSSProperties | undefined {
|
||||
const transaction = this.mutableTransaction.value
|
||||
if (!transaction) return undefined
|
||||
const role = entry.key === transaction.fromKey ? 'from' : entry.key === transaction.toKey ? 'to' : undefined
|
||||
if (!role) return undefined
|
||||
return this.presentationFor(transaction.presentation)?.layerStyle?.({
|
||||
progress: transaction.progress,
|
||||
role,
|
||||
direction: transaction.direction,
|
||||
sourceRect: transaction.sourceRect,
|
||||
})
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.removeAfterEach?.()
|
||||
this.platformCleanup?.()
|
||||
}
|
||||
|
||||
private activeEntry() {
|
||||
return this.entryByKey(this.mutableActiveKey.value)
|
||||
}
|
||||
|
||||
private activeIndex() {
|
||||
return this.mutableEntries.value.findIndex((entry) => entry.key === this.mutableActiveKey.value)
|
||||
}
|
||||
|
||||
private entryByKey(key: string) {
|
||||
return this.mutableEntries.value.find((entry) => entry.key === key)
|
||||
}
|
||||
|
||||
private findReusable(fullPath: string) {
|
||||
return [...this.mutableEntries.value].reverse().find((entry) =>
|
||||
entry.route.fullPath === fullPath
|
||||
&& entry.key !== this.mutableActiveKey.value
|
||||
&& !this.mutableHistoryKeys.value.includes(entry.key),
|
||||
)
|
||||
}
|
||||
|
||||
private findHistoryEntry(fullPath: string) {
|
||||
for (const key of [...this.mutableHistoryKeys.value].reverse()) {
|
||||
const entry = this.entryByKey(key)
|
||||
if (entry?.route.fullPath === fullPath) return entry
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private siblingDirection(from: RouteLocationNormalizedLoaded, to: RouteLocationNormalizedLoaded): NativeDirection {
|
||||
const fromOrder = from.meta.native?.siblingOrder
|
||||
const toOrder = to.meta.native?.siblingOrder
|
||||
return typeof fromOrder === 'number' && typeof toOrder === 'number' && toOrder < fromOrder ? 'back' : 'forward'
|
||||
}
|
||||
|
||||
private touchEntries() {
|
||||
this.mutableEntries.value = [...this.mutableEntries.value]
|
||||
}
|
||||
|
||||
private waitForIdle() {
|
||||
if (!this.mutableTransaction.value) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => this.idleWaiters.add(resolve))
|
||||
}
|
||||
|
||||
private clearTransaction() {
|
||||
this.mutableTransaction.value = null
|
||||
for (const resolve of this.idleWaiters) resolve()
|
||||
this.idleWaiters.clear()
|
||||
}
|
||||
|
||||
private async commitRoute(transaction: NativeTransaction) {
|
||||
const target = this.entryByKey(transaction.toKey)
|
||||
if (!target) return true
|
||||
if (transaction.kind === 'pop' || transaction.kind === 'dismiss') {
|
||||
if (target.synthetic) return await this.router.replace(target.route.fullPath)
|
||||
return await new Promise<NavigationFailure | void>((resolve) => {
|
||||
this.pendingPop = resolve
|
||||
this.router.back()
|
||||
window.setTimeout(() => {
|
||||
if (this.pendingPop === resolve) {
|
||||
this.pendingPop = undefined
|
||||
resolve()
|
||||
}
|
||||
}, 1200)
|
||||
})
|
||||
}
|
||||
return transaction.replace
|
||||
? await this.router.replace(target.route.fullPath)
|
||||
: await this.router.push(target.route.fullPath)
|
||||
}
|
||||
|
||||
private acceptRoute(to: RouteLocationNormalizedLoaded, _from: RouteLocationNormalizedLoaded) {
|
||||
const transaction = this.mutableTransaction.value
|
||||
let target = transaction ? this.entryByKey(transaction.toKey) : undefined
|
||||
if (target && target.route.fullPath !== to.fullPath) target = undefined
|
||||
if (!transaction) target ??= this.findHistoryEntry(to.fullPath)
|
||||
target ??= this.findReusable(to.fullPath)
|
||||
if (!target && this.activeEntry()?.route.fullPath === to.fullPath) target = this.activeEntry()
|
||||
if (!target) {
|
||||
target = entryFor(to, 'active')
|
||||
const activeIndex = this.activeIndex()
|
||||
const head = activeIndex >= 0 ? this.mutableEntries.value.slice(0, activeIndex + 1) : this.mutableEntries.value
|
||||
this.mutableEntries.value = [...head, target]
|
||||
} else {
|
||||
target.route = to
|
||||
target.mounted = true
|
||||
target.status = 'active'
|
||||
target.committed = true
|
||||
target.lastUsed = now()
|
||||
this.touchEntries()
|
||||
}
|
||||
this.mutableActiveKey.value = target.key
|
||||
this.acceptHistory(target, transaction)
|
||||
this.markStatuses()
|
||||
}
|
||||
|
||||
private acceptHistory(target: NativeViewEntry, transaction: NativeTransaction | null) {
|
||||
const history = this.mutableHistoryKeys.value
|
||||
if (!history.length) {
|
||||
this.mutableHistoryKeys.value = [target.key]
|
||||
return
|
||||
}
|
||||
if (transaction?.kind === 'pop' || transaction?.kind === 'dismiss') {
|
||||
const targetIndex = history.lastIndexOf(target.key)
|
||||
this.mutableHistoryKeys.value = targetIndex >= 0
|
||||
? history.slice(0, targetIndex + 1)
|
||||
: [...history.slice(0, -1), target.key]
|
||||
return
|
||||
}
|
||||
if (transaction?.replace) {
|
||||
this.mutableHistoryKeys.value = [...history.slice(0, -1), target.key]
|
||||
return
|
||||
}
|
||||
const existingIndex = history.lastIndexOf(target.key)
|
||||
this.mutableHistoryKeys.value = transaction
|
||||
? [...history, target.key]
|
||||
: existingIndex >= 0
|
||||
? history.slice(0, existingIndex + 1)
|
||||
: [...history, target.key]
|
||||
}
|
||||
|
||||
private markStatuses() {
|
||||
const transaction = this.mutableTransaction.value
|
||||
for (const entry of this.mutableEntries.value) {
|
||||
if (entry.key === this.mutableActiveKey.value) entry.status = 'active'
|
||||
else if (transaction?.toKey === entry.key && entry.status === 'preview') entry.status = 'preview'
|
||||
else if (entry.mounted) entry.status = 'inactive'
|
||||
else entry.status = 'evicted'
|
||||
}
|
||||
this.touchEntries()
|
||||
}
|
||||
|
||||
private removePreview(key: string) {
|
||||
const entry = this.entryByKey(key)
|
||||
if (!entry || entry.key === this.mutableActiveKey.value) return
|
||||
if (entry.status === 'preview' || entry.synthetic) {
|
||||
if (entry.synthetic || !entry.committed) {
|
||||
this.mutableEntries.value = this.mutableEntries.value.filter((candidate) => candidate.key !== key)
|
||||
} else {
|
||||
entry.status = 'inactive'
|
||||
this.touchEntries()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enforceCache() {
|
||||
const inactive = this.mutableEntries.value
|
||||
.filter((entry) => entry.key !== this.mutableActiveKey.value && entry.mounted && entry.status === 'inactive')
|
||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||
for (const entry of inactive.slice(this.maxInactive)) {
|
||||
if (entry.route.meta.native?.cache === false || inactive.length > this.maxInactive) {
|
||||
entry.mounted = false
|
||||
entry.status = 'evicted'
|
||||
}
|
||||
}
|
||||
this.touchEntries()
|
||||
}
|
||||
|
||||
private animateProgress(target: number, initialVelocity: number) {
|
||||
const transaction = this.mutableTransaction.value
|
||||
if (!transaction) return Promise.resolve()
|
||||
if (typeof window === 'undefined' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
this.mutableTransaction.value = { ...transaction, progress: target, velocity: 0, phase: 'settling' }
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let position = transaction.progress
|
||||
let velocity = Math.max(-2, Math.min(2, initialVelocity))
|
||||
let previous = now()
|
||||
const step = (time: number) => {
|
||||
const live = this.mutableTransaction.value
|
||||
if (!live || live.id !== transaction.id) return resolve()
|
||||
const dt = Math.min(0.032, Math.max(0.001, (time - previous) / 1000))
|
||||
previous = time
|
||||
const displacement = target - position
|
||||
const acceleration = displacement * 280 - velocity * 30
|
||||
velocity += acceleration * dt
|
||||
position += velocity * dt
|
||||
const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02
|
||||
this.mutableTransaction.value = {
|
||||
...live,
|
||||
progress: done ? target : Math.max(0, Math.min(1, position)),
|
||||
velocity,
|
||||
phase: 'settling',
|
||||
}
|
||||
if (done) resolve()
|
||||
else requestAnimationFrame(step)
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createNativeRouter(options: NativeRouterOptions): NativeRouterRuntime {
|
||||
return new NativeRouterRuntimeImpl(options)
|
||||
}
|
||||
|
||||
export function isFailedNavigation(value: unknown) {
|
||||
return Boolean(value && isNavigationFailure(value))
|
||||
}
|
||||
157
packages/core/src/style.css
Normal file
157
packages/core/src/style.css
Normal file
@@ -0,0 +1,157 @@
|
||||
:root {
|
||||
--nvr-duration: 360ms;
|
||||
--nvr-scrim: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.nvr-navigator,
|
||||
.nvr-router-view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nvr-navigator {
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
|
||||
.nvr-router-view {
|
||||
isolation: isolate;
|
||||
contain: layout paint style;
|
||||
}
|
||||
|
||||
.nvr-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--nvr-view-background, #fff);
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity, border-radius;
|
||||
}
|
||||
|
||||
.nvr-view--active {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.nvr-view--inactive {
|
||||
z-index: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nvr-view--from,
|
||||
.nvr-view--to {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.nvr-view--from { z-index: 3; }
|
||||
.nvr-view--to { z-index: 2; }
|
||||
|
||||
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--from,
|
||||
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--from {
|
||||
transform: translate3d(calc(var(--native-progress) * -28%), 0, 0);
|
||||
filter: brightness(calc(1 - var(--native-progress) * .12));
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="push"][data-native-direction="forward"] .nvr-view--to,
|
||||
.nvr-router-view[data-native-presentation="reveal"][data-native-direction="forward"] .nvr-view--to {
|
||||
z-index: 4;
|
||||
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
|
||||
box-shadow: -18px 0 42px rgba(0, 0, 0, calc(var(--native-progress) * .28));
|
||||
}
|
||||
|
||||
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--from {
|
||||
z-index: 4;
|
||||
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
|
||||
box-shadow: -18px 0 42px rgba(0, 0, 0, calc((1 - var(--native-progress)) * .25));
|
||||
}
|
||||
|
||||
.nvr-router-view:is([data-native-presentation="push"], [data-native-presentation="reveal"])[data-native-direction="back"] .nvr-view--to {
|
||||
transform: translate3d(calc((var(--native-progress) - 1) * 28%), 0, 0);
|
||||
filter: brightness(calc(.88 + var(--native-progress) * .12));
|
||||
}
|
||||
|
||||
/* Sibling routes are adjacent pages, not a foreground/background stack. */
|
||||
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"] .nvr-view--from {
|
||||
transform: translate3d(calc(var(--native-progress) * -100%), 0, 0);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="slide"][data-native-direction="forward"] .nvr-view--to {
|
||||
transform: translate3d(calc((1 - var(--native-progress)) * 100%), 0, 0);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"] .nvr-view--from {
|
||||
transform: translate3d(calc(var(--native-progress) * 100%), 0, 0);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="slide"][data-native-direction="back"] .nvr-view--to {
|
||||
transform: translate3d(calc((var(--native-progress) - 1) * 100%), 0, 0);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="slide"] :is(.nvr-view--from, .nvr-view--to) {
|
||||
z-index: 3;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"] .nvr-view--to,
|
||||
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--to {
|
||||
z-index: 4;
|
||||
transform: translate3d(0, calc((1 - var(--native-progress)) * 100%), 0);
|
||||
border-radius: calc((1 - var(--native-progress)) * 24px) calc((1 - var(--native-progress)) * 24px) 0 0;
|
||||
box-shadow: 0 -24px 60px rgba(0, 0, 0, .34);
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"] .nvr-view--from,
|
||||
.nvr-router-view[data-native-presentation="sheet"] .nvr-view--from {
|
||||
transform: scale(calc(1 - var(--native-progress) * .04));
|
||||
border-radius: calc(var(--native-progress) * 18px);
|
||||
filter: brightness(calc(1 - var(--native-progress) * .24));
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="fade"] .nvr-view--from {
|
||||
opacity: calc(1 - var(--native-progress));
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="fade"] .nvr-view--to {
|
||||
opacity: var(--native-progress);
|
||||
}
|
||||
|
||||
.nvr-gesture-link {
|
||||
touch-action: pan-y pinch-zoom;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nvr-dismiss-gesture {
|
||||
touch-action: pan-x pinch-zoom;
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--from,
|
||||
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] .nvr-view--from {
|
||||
z-index: 4;
|
||||
transform: translate3d(0, calc(var(--native-progress) * 100%), 0);
|
||||
border-radius: 22px 22px 0 0;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.nvr-router-view[data-native-presentation="modal"][data-native-direction="back"] .nvr-view--to,
|
||||
.nvr-router-view[data-native-presentation="sheet"][data-native-direction="back"] .nvr-view--to {
|
||||
z-index: 2;
|
||||
transform: scale(calc(.96 + var(--native-progress) * .04));
|
||||
border-radius: calc((1 - var(--native-progress)) * 18px);
|
||||
filter: brightness(calc(.76 + var(--native-progress) * .24));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nvr-view { will-change: auto; }
|
||||
}
|
||||
132
packages/core/src/types.ts
Normal file
132
packages/core/src/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { App, CSSProperties } from 'vue'
|
||||
import type {
|
||||
RouteLocationNormalizedLoaded,
|
||||
RouteLocationRaw,
|
||||
Router,
|
||||
} from 'vue-router'
|
||||
|
||||
export type NativePresentationName =
|
||||
| 'push'
|
||||
| 'reveal'
|
||||
| 'slide'
|
||||
| 'fade'
|
||||
| 'modal'
|
||||
| 'sheet'
|
||||
| 'none'
|
||||
| (string & {})
|
||||
|
||||
export type NativeGestureKind = 'push' | 'pop' | 'sibling' | 'present' | 'dismiss'
|
||||
export type NativeDirection = 'forward' | 'back' | 'up' | 'down'
|
||||
export type NativeViewStatus = 'active' | 'inactive' | 'preview' | 'evicted'
|
||||
|
||||
export interface NativeRouteOptions {
|
||||
navigator?: string
|
||||
presentation?: NativePresentationName
|
||||
transition?: NativePresentationName
|
||||
parent?: RouteLocationRaw | ((route: RouteLocationNormalizedLoaded) => RouteLocationRaw)
|
||||
siblingGroup?: string
|
||||
siblingOrder?: number
|
||||
siblingHistory?: 'push' | 'replace'
|
||||
cache?: boolean
|
||||
gesture?: boolean | 'edge' | 'full'
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
native?: NativeRouteOptions
|
||||
}
|
||||
}
|
||||
|
||||
export interface NativeViewEntry {
|
||||
key: string
|
||||
route: RouteLocationNormalizedLoaded
|
||||
status: NativeViewStatus
|
||||
mounted: boolean
|
||||
synthetic: boolean
|
||||
/** True once Vue Router has made this route authoritative. */
|
||||
committed: boolean
|
||||
lastUsed: number
|
||||
scrollX: number
|
||||
scrollY: number
|
||||
}
|
||||
|
||||
export interface NativeSourceRect {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
export interface NativeTransaction {
|
||||
id: number
|
||||
kind: NativeGestureKind
|
||||
direction: NativeDirection
|
||||
presentation: NativePresentationName
|
||||
fromKey: string
|
||||
toKey: string
|
||||
progress: number
|
||||
velocity: number
|
||||
phase: 'candidate' | 'interactive' | 'settling' | 'committing' | 'cancelled'
|
||||
replace: boolean
|
||||
sourceRect?: NativeSourceRect
|
||||
}
|
||||
|
||||
export interface NativePresentationContext {
|
||||
progress: number
|
||||
role: 'from' | 'to'
|
||||
direction: NativeDirection
|
||||
sourceRect?: NativeSourceRect
|
||||
}
|
||||
|
||||
export interface NativePresentationDefinition {
|
||||
name: NativePresentationName
|
||||
axis?: 'x' | 'y'
|
||||
layerStyle?: (context: NativePresentationContext) => CSSProperties
|
||||
}
|
||||
|
||||
export interface NativePlatformAdapter {
|
||||
name: string
|
||||
install?: (runtime: NativeRouterRuntime) => void | (() => void) | Promise<void | (() => void)>
|
||||
haptic?: (event: 'selection' | 'commit' | 'cancel') => void | Promise<void>
|
||||
exitAtRoot?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface NativeRouterOptions {
|
||||
router: Router
|
||||
cache?: { maxInactive?: number }
|
||||
edgeWidth?: number
|
||||
platform?: NativePlatformAdapter
|
||||
presentations?: NativePresentationDefinition[]
|
||||
}
|
||||
|
||||
export interface NativeNavigationOptions {
|
||||
presentation?: NativePresentationName
|
||||
replace?: boolean
|
||||
direction?: NativeDirection
|
||||
sourceRect?: NativeSourceRect
|
||||
}
|
||||
|
||||
export interface NativeRouterRuntime {
|
||||
readonly router: Router
|
||||
readonly entries: Readonly<{ value: readonly NativeViewEntry[] }>
|
||||
readonly activeKey: Readonly<{ value: string }>
|
||||
readonly transaction: Readonly<{ value: NativeTransaction | null }>
|
||||
readonly canGoBack: Readonly<{ value: boolean }>
|
||||
install(app: App): void
|
||||
push(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
|
||||
replace(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
|
||||
sibling(to: RouteLocationRaw, options?: NativeNavigationOptions): Promise<boolean>
|
||||
pop(): Promise<boolean>
|
||||
present(to: RouteLocationRaw, presentation?: NativePresentationName): Promise<boolean>
|
||||
dismiss(): Promise<boolean>
|
||||
preload(to: RouteLocationRaw): Promise<RouteLocationNormalizedLoaded>
|
||||
beginInteractive(kind: NativeGestureKind, to?: RouteLocationRaw, options?: NativeNavigationOptions): Promise<number | null>
|
||||
updateInteractive(progress: number, velocity?: number): void
|
||||
finishInteractive(forceCommit?: boolean): Promise<boolean>
|
||||
cancelInteractive(): Promise<void>
|
||||
registerPresentation(definition: NativePresentationDefinition): void
|
||||
presentationFor(name: NativePresentationName): NativePresentationDefinition | undefined
|
||||
dispose(): void
|
||||
}
|
||||
14
packages/core/tsconfig.json
Normal file
14
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/core.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
10
packages/core/vite.config.ts
Normal file
10
packages/core/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist', emptyOutDir: true,
|
||||
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index', cssFileName: 'style' },
|
||||
rollupOptions: { external: ['vue', 'vue-router'] },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user