fix(@vben/layouts): restore internal scroll navigation

This commit is contained in:
Dream
2026-07-24 16:58:39 +08:00
parent aaf0da582c
commit b9c6d56f74
3 changed files with 242 additions and 1 deletions

View File

@@ -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();
});
});

View File

@@ -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"
/>
</template>
<VbenBackTop />
<VbenBackTop :target="layoutScrollTarget" />
</template>
</VbenAdminLayout>
</template>

View File

@@ -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<Router, 'afterEach' | 'beforeEach'>;
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<HTMLElement>(
`#${CSS.escape(decodeURIComponent(id))}`,
);
} catch {
return document.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
}
}
export function useLayoutScroll(router: LayoutScrollRouter = useRouter()) {
const scrollPositions = new Map<number, number>();
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();
});
}