first commit
This commit is contained in:
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) }
|
||||
}
|
||||
Reference in New Issue
Block a user