diff --git a/packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts b/packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts new file mode 100644 index 00000000..f4a12e3e --- /dev/null +++ b/packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts @@ -0,0 +1,151 @@ +import type { App } from 'vue'; +import type { NavigationGuard, NavigationHookAfter, Router } from 'vue-router'; + +import { createApp } from 'vue'; + +import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useLayoutScroll } from '../use-layout-scroll'; + +let activeApp: App | undefined; + +function createRouterMock() { + let afterHook: NavigationHookAfter | undefined; + let beforeGuard: NavigationGuard | undefined; + const removeAfterHook = vi.fn(); + const removeBeforeGuard = vi.fn(); + + const router = { + afterEach: vi.fn((hook: NavigationHookAfter) => { + afterHook = hook; + return removeAfterHook; + }), + beforeEach: vi.fn((guard: NavigationGuard) => { + beforeGuard = guard; + return removeBeforeGuard; + }), + } as unknown as Router; + + function getHooks() { + if (!afterHook || !beforeGuard) { + throw new Error('Router hooks were not registered'); + } + return { afterHook, beforeGuard }; + } + + return { + getHooks, + removeAfterHook, + removeBeforeGuard, + router, + }; +} + +function createScrollElement() { + const element = document.createElement('div'); + element.id = ELEMENT_ID_LAYOUT_SCROLL; + element.scrollTo = vi.fn(); + document.body.append(element); + return element; +} + +function mountLayoutScroll(router: Router) { + const host = document.createElement('div'); + document.body.append(host); + activeApp = createApp({ + setup() { + useLayoutScroll(router); + return () => null; + }, + }); + activeApp.mount(host); +} + +async function runBeforeGuard(guard: NavigationGuard) { + await guard({} as never, {} as never, vi.fn()); +} + +async function runAfterHook(hook: NavigationHookAfter, hash = '') { + await hook({ hash } as never, {} as never, undefined); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + window.history.replaceState({}, ''); + vi.restoreAllMocks(); +}); + +describe('useLayoutScroll', () => { + it('should scroll to top after a normal navigation', async () => { + window.history.replaceState({ position: 0 }, ''); + const element = createScrollElement(); + element.scrollTop = 240; + const routerMock = createRouterMock(); + mountLayoutScroll(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + + await runBeforeGuard(beforeGuard); + window.history.replaceState({ position: 1 }, ''); + await runAfterHook(afterHook); + + expect(element.scrollTo).toHaveBeenCalledWith({ top: 0 }); + }); + + it('should scroll a hash target into view', async () => { + window.history.replaceState({ position: 0 }, ''); + const element = createScrollElement(); + const hashTarget = document.createElement('div'); + hashTarget.id = 'section'; + hashTarget.scrollIntoView = vi.fn(); + element.append(hashTarget); + const routerMock = createRouterMock(); + mountLayoutScroll(routerMock.router); + const { afterHook } = routerMock.getHooks(); + + window.history.replaceState({ position: 1 }, ''); + await runAfterHook(afterHook, '#section'); + + expect(hashTarget.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + expect(element.scrollTo).not.toHaveBeenCalled(); + }); + + it('should restore a saved position on history navigation', async () => { + window.history.replaceState({ position: 0 }, ''); + const element = createScrollElement(); + element.scrollTop = 240; + const routerMock = createRouterMock(); + mountLayoutScroll(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + + await runBeforeGuard(beforeGuard); + window.history.replaceState({ position: 1 }, ''); + await runAfterHook(afterHook); + + element.scrollTop = 80; + window.history.replaceState({ position: 0 }, ''); + await runBeforeGuard(beforeGuard); + await runAfterHook(afterHook); + + expect(element.scrollTo).toHaveBeenLastCalledWith({ top: 240 }); + }); + + it('should remove router hooks when the scope is disposed', () => { + window.history.replaceState({ position: 0 }, ''); + createScrollElement(); + const routerMock = createRouterMock(); + mountLayoutScroll(routerMock.router); + + activeApp?.unmount(); + activeApp = undefined; + + expect(routerMock.removeBeforeGuard).toHaveBeenCalledOnce(); + expect(routerMock.removeAfterHook).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/effects/layouts/src/basic/layout.vue b/packages/effects/layouts/src/basic/layout.vue index d8646a6c..59b45e99 100644 --- a/packages/effects/layouts/src/basic/layout.vue +++ b/packages/effects/layouts/src/basic/layout.vue @@ -19,6 +19,7 @@ import { cloneDeep, mapTree } from '@vben/utils'; import { VbenAdminLayout } from '@vben-core/layout-ui'; import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui'; +import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants'; import { Breadcrumb, CheckUpdates, Preferences } from '../widgets'; import { LayoutContent, LayoutContentSpinner } from './content'; @@ -33,6 +34,7 @@ import { useMixedMenu, } from './menu'; import { LayoutTabbar } from './tabbar'; +import { useLayoutScroll } from './use-layout-scroll'; defineOptions({ name: 'BasicLayout' }); @@ -69,6 +71,9 @@ const { const accessStore = useAccessStore(); const timezoneStore = useTimezoneStore(); const { refresh } = useRefresh(); +const layoutScrollTarget = `#${ELEMENT_ID_LAYOUT_SCROLL}`; + +useLayoutScroll(); const sidebarTheme = computed(() => { const dark = isDark.value || preferences.theme.semiDarkSidebar; @@ -468,7 +473,7 @@ const headerSlots = computed(() => { @clear-preferences-and-logout="clearPreferencesAndLogout" /> - + diff --git a/packages/effects/layouts/src/basic/use-layout-scroll.ts b/packages/effects/layouts/src/basic/use-layout-scroll.ts new file mode 100644 index 00000000..d4ee372c --- /dev/null +++ b/packages/effects/layouts/src/basic/use-layout-scroll.ts @@ -0,0 +1,85 @@ +import type { Router } from 'vue-router'; + +import { nextTick, onScopeDispose } from 'vue'; +import { useRouter } from 'vue-router'; + +import { getLayoutScrollElement } from '@vben-core/shared/utils'; + +type LayoutScrollRouter = Pick; + +function getHistoryPosition() { + if (typeof window === 'undefined') { + return undefined; + } + const position = (window.history.state as null | { position?: unknown }) + ?.position; + return typeof position === 'number' ? position : undefined; +} + +function getHashTarget(hash: string) { + if (typeof document === 'undefined' || !hash.startsWith('#')) { + return null; + } + + const id = hash.slice(1); + try { + return document.querySelector( + `#${CSS.escape(decodeURIComponent(id))}`, + ); + } catch { + return document.querySelector(`#${CSS.escape(id)}`); + } +} + +export function useLayoutScroll(router: LayoutScrollRouter = useRouter()) { + const scrollPositions = new Map(); + let currentHistoryPosition = getHistoryPosition(); + let isHistoryNavigation = false; + + const removeBeforeGuard = router.beforeEach(() => { + const scrollElement = getLayoutScrollElement(); + if (scrollElement && currentHistoryPosition !== undefined) { + scrollPositions.set(currentHistoryPosition, scrollElement.scrollTop); + } + + const nextHistoryPosition = getHistoryPosition(); + isHistoryNavigation = + currentHistoryPosition !== undefined && + nextHistoryPosition !== undefined && + currentHistoryPosition !== nextHistoryPosition; + }); + + const removeAfterHook = router.afterEach(async (to, _from, failure) => { + const nextHistoryPosition = getHistoryPosition(); + + if (!failure) { + await nextTick(); + const scrollElement = getLayoutScrollElement(); + if (scrollElement) { + const savedPosition = + isHistoryNavigation && nextHistoryPosition !== undefined + ? scrollPositions.get(nextHistoryPosition) + : undefined; + + if (savedPosition === undefined) { + const hashTarget = getHashTarget(to.hash); + if (hashTarget) { + hashTarget.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else { + scrollElement.scrollTo({ top: 0 }); + } + } else { + scrollElement.scrollTo({ top: savedPosition }); + } + } + } + + currentHistoryPosition = nextHistoryPosition; + isHistoryNavigation = false; + }); + + onScopeDispose(() => { + removeBeforeGuard(); + removeAfterHook(); + }); +}