feat(nuxt): default router scroll behavior (#3851)

Co-authored-by: joel <joel.wenzel@flexagon.com>
Co-authored-by: Pooya Parsa <pooya@pi0.io>
This commit is contained in:
Joel Wenzel 2022-10-19 07:43:03 -05:00 committed by GitHub
parent 66de87af91
commit ba3a11800c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 91 additions and 7 deletions

View File

@ -27,6 +27,7 @@ Hook | Arguments | Environment | Description
`link:prefetch` | `to` | Client | Called when a `<NuxtLink>` is observed to be prefetched. `link:prefetch` | `to` | Client | Called when a `<NuxtLink>` is observed to be prefetched.
`page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event. `page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event.
`page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event. `page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event.
`page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event.
# Nuxt Hooks (build time) # Nuxt Hooks (build time)

View File

@ -34,6 +34,7 @@ export interface RuntimeNuxtHooks {
'link:prefetch': (link: string) => HookResult 'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult 'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult
'page:transition:finish': (Component?: VNode) => HookResult
'vue:setup': () => void 'vue:setup': () => void
'vue:error': (...args: Parameters<Parameters<typeof onErrorCaptured>[0]>) => HookResult 'vue:error': (...args: Parameters<Parameters<typeof onErrorCaptured>[0]>) => HookResult
} }

View File

@ -139,11 +139,14 @@ export default defineNuxtModule({
addTemplate({ addTemplate({
filename: 'router.options.mjs', filename: 'router.options.mjs',
getContents: async () => { getContents: async () => {
// Check for router options // Scan and register app/router.options files
const routerOptionsFiles = (await Promise.all(nuxt.options._layers.map( const routerOptionsFiles = (await Promise.all(nuxt.options._layers.map(
async layer => await findPath(resolve(layer.config.srcDir, 'app/router.options')) async layer => await findPath(resolve(layer.config.srcDir, 'app/router.options'))
))).filter(Boolean) as string[] ))).filter(Boolean) as string[]
// Add default options
routerOptionsFiles.unshift(resolve(runtimeDir, 'router.options'))
const configRouterOptions = genObjectFromRawEntries(Object.entries(nuxt.options.router.options) const configRouterOptions = genObjectFromRawEntries(Object.entries(nuxt.options.router.options)
.map(([key, value]) => [key, genString(value as string)])) .map(([key, value]) => [key, genString(value as string)]))

View File

@ -29,6 +29,8 @@ export interface PageMeta {
layoutTransition?: boolean | TransitionProps layoutTransition?: boolean | TransitionProps
key?: false | string | ((route: RouteLocationNormalizedLoaded) => string) key?: false | string | ((route: RouteLocationNormalizedLoaded) => string)
keepalive?: boolean | KeepAliveProps keepalive?: boolean | KeepAliveProps
/** Set to `false` to avoid scrolling to top on page navigations */
scrollToTop?: boolean
} }
declare module 'vue-router' { declare module 'vue-router' {

View File

@ -1,6 +1,7 @@
import { computed, defineComponent, h, provide, reactive, onMounted, nextTick, Suspense, Transition, KeepAliveProps, TransitionProps } from 'vue' import { computed, defineComponent, h, provide, reactive, onMounted, nextTick, Suspense, Transition, KeepAliveProps, TransitionProps } from 'vue'
import type { DefineComponent, VNode } from 'vue' import type { DefineComponent, VNode } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { defu } from 'defu'
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteLocation } from 'vue-router' import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteLocation } from 'vue-router'
import { generateRouteKey, RouterViewSlotProps, wrapInKeepAlive } from './utils' import { generateRouteKey, RouterViewSlotProps, wrapInKeepAlive } from './utils'
@ -34,22 +35,27 @@ export default defineComponent({
}, },
setup (props, { attrs }) { setup (props, { attrs }) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
return () => { return () => {
return h(RouterView, { name: props.name, route: props.route, ...attrs }, { return h(RouterView, { name: props.name, route: props.route, ...attrs }, {
default: (routeProps: RouterViewSlotProps) => { default: (routeProps: RouterViewSlotProps) => {
if (!routeProps.Component) { return } if (!routeProps.Component) { return }
const key = generateRouteKey(props.pageKey, routeProps) const key = generateRouteKey(props.pageKey, routeProps)
const transitionProps = props.transition ?? routeProps.route.meta.pageTransition ?? (defaultPageTransition as TransitionProps)
const done = nuxtApp.deferHydration() const done = nuxtApp.deferHydration()
return _wrapIf(Transition, transitionProps, const hasTransition = !!(props.transition ?? routeProps.route.meta.pageTransition ?? defaultPageTransition)
const transitionProps = hasTransition && _mergeTransitionProps([
props.transition,
routeProps.route.meta.pageTransition,
defaultPageTransition,
{ onAfterLeave: () => { nuxtApp.callHook('page:transition:finish', routeProps.Component) } }
].filter(Boolean))
return _wrapIf(Transition, hasTransition && transitionProps,
wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), h(Suspense, { wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), h(Suspense, {
onPending: () => nuxtApp.callHook('page:start', routeProps.Component), onPending: () => nuxtApp.callHook('page:start', routeProps.Component),
onResolve: () => nuxtApp.callHook('page:finish', routeProps.Component).finally(done) onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).finally(done)) }
}, { default: () => h(Component, { key, routeProps, pageKey: key, hasTransition: !!transitionProps } as {}) }) }, { default: () => h(Component, { key, routeProps, pageKey: key, hasTransition } as {}) })
)).default() )).default()
} }
}) })
@ -62,6 +68,19 @@ export default defineComponent({
[key: string]: any [key: string]: any
}> }>
function _toArray (val: any) {
return Array.isArray(val) ? val : (val ? [val] : [])
}
function _mergeTransitionProps (routeProps: TransitionProps[]): TransitionProps {
const _props: TransitionProps[] = routeProps.map(prop => ({
...prop,
onAfterLeave: _toArray(prop.onAfterLeave)
}))
// @ts-ignore
return defu(..._props)
}
const Component = defineComponent({ const Component = defineComponent({
// TODO: Type props // TODO: Type props
// eslint-disable-next-line vue/require-prop-types // eslint-disable-next-line vue/require-prop-types

View File

@ -0,0 +1,58 @@
import type { RouterConfig } from '@nuxt/schema'
import type { RouterScrollBehavior } from 'vue-router'
import { nextTick } from 'vue'
import { useNuxtApp } from '#app'
type ScrollPosition = Awaited<ReturnType<RouterScrollBehavior>>
// Default router options
// https://router.vuejs.org/api/#routeroptions
export default <RouterConfig> {
scrollBehavior (to, from, savedPosition) {
const nuxtApp = useNuxtApp()
// By default when the returned position is falsy or an empty object, vue-router will retain the current scroll position
// savedPosition is only available for popstate navigations (back button)
let position: ScrollPosition = savedPosition || undefined
// Scroll to top if route is changed by default
if (
!position &&
(from && to && from.matched[0] !== to.matched[0]) &&
to.meta.scrollToTop !== false
) {
position = { left: 0, top: 0 }
}
// Hash routes on the same page, no page hook is fired so resolve here
if (to.path !== from.path) {
if (from.hash && !to.hash) {
return { left: 0, top: 0 }
}
if (to.hash) {
return { el: to.hash, top: _getHashElementScrollMarginTop(to.hash) }
}
}
// Wait for `page:transition:finish` or `page:finish` depending on if transitions are enabled or not
const hasTransition = to.meta.pageTransition !== false && from.meta.pageTransition !== false
const hookToWait = hasTransition ? 'page:transition:finish' : 'page:finish'
return new Promise((resolve) => {
nuxtApp.hooks.hookOnce(hookToWait, async () => {
await nextTick()
if (to.hash) {
position = { el: to.hash, top: _getHashElementScrollMarginTop(to.hash) }
}
resolve(position)
})
})
}
}
function _getHashElementScrollMarginTop (selector: string): number {
const elem = document.querySelector(selector)
if (elem) {
return parseFloat(getComputedStyle(elem).scrollMarginTop)
}
return 0
}