fix(@vben/layouts): restore internal scroll navigation
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
85
packages/effects/layouts/src/basic/use-layout-scroll.ts
Normal file
85
packages/effects/layouts/src/basic/use-layout-scroll.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user