feat: support smooth auto-scroll to active menu item (#6102)

This commit is contained in:
Vben 2025-05-03 18:05:26 +08:00 committed by GitHub
parent 17a18fc9ba
commit 045bc4e5ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 155 additions and 70 deletions

View File

@ -31,6 +31,7 @@ import {
createSubMenuContext, createSubMenuContext,
useMenuStyle, useMenuStyle,
} from '../hooks'; } from '../hooks';
import { useMenuScroll } from '../hooks/use-menu-scroll';
import { flattedChildren } from '../utils'; import { flattedChildren } from '../utils';
import SubMenu from './sub-menu.vue'; import SubMenu from './sub-menu.vue';
@ -44,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
mode: 'vertical', mode: 'vertical',
rounded: true, rounded: true,
theme: 'dark', theme: 'dark',
scrollToActive: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -206,15 +208,19 @@ function handleResize() {
isFirstTimeRender = false; isFirstTimeRender = false;
} }
function getActivePaths() { const enableScroll = computed(
const activeItem = activePath.value && items.value[activePath.value]; () => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
);
if (!activeItem || props.mode === 'horizontal' || props.collapse) { const { scrollToActiveItem } = useMenuScroll(activePath, {
return []; enable: enableScroll,
} delay: 320,
});
return activeItem.parentPaths; // activePath
} watch(activePath, () => {
scrollToActiveItem();
});
// //
function initMenu() { function initMenu() {
@ -318,6 +324,16 @@ function removeSubMenu(subMenu: MenuItemRegistered) {
function removeMenuItem(item: MenuItemRegistered) { function removeMenuItem(item: MenuItemRegistered) {
Reflect.deleteProperty(items.value, item.path); Reflect.deleteProperty(items.value, item.path);
} }
function getActivePaths() {
const activeItem = activePath.value && items.value[activePath.value];
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
return [];
}
return activeItem.parentPaths;
}
</script> </script>
<template> <template>
<ul <ul

View File

@ -0,0 +1,46 @@
import type { Ref } from 'vue';
import { watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
interface UseMenuScrollOptions {
delay?: number;
enable?: boolean | Ref<boolean>;
}
export function useMenuScroll(
activePath: Ref<string | undefined>,
options: UseMenuScrollOptions = {},
) {
const { enable = true, delay = 320 } = options;
function scrollToActiveItem() {
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
if (!isEnabled) return;
const activeElement = document.querySelector(
`aside li[role=menuitem].is-active`,
);
if (activeElement) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
}
const debouncedScroll = useDebounceFn(scrollToActiveItem, delay);
watch(activePath, () => {
const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
if (!isEnabled) return;
debouncedScroll();
});
return {
scrollToActiveItem,
};
}

View File

@ -18,15 +18,9 @@ defineOptions({
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
collapse: false, collapse: false,
// theme: 'dark',
}); });
const forward = useForwardProps(props); const forward = useForwardProps(props);
// const emit = defineEmits<{
// 'update:openKeys': [key: Key[]];
// 'update:selectedKeys': [key: Key[]];
// }>();
</script> </script>
<template> <template>

View File

@ -42,6 +42,12 @@ interface MenuProps {
*/ */
rounded?: boolean; rounded?: boolean;
/**
* @zh_CN
* @default false
*/
scrollToActive?: boolean;
/** /**
* @zh_CN * @zh_CN
* @default dark * @default dark

View File

@ -66,7 +66,7 @@ async function generateAccessible(
} }
// 生成菜单 // 生成菜单
const accessibleMenus = await generateMenus(accessibleRoutes, options.router); const accessibleMenus = generateMenus(accessibleRoutes, options.router);
return { accessibleMenus, accessibleRoutes }; return { accessibleMenus, accessibleRoutes };
} }

View File

@ -37,6 +37,7 @@ function handleMenuOpen(key: string, path: string[]) {
:menus="menus" :menus="menus"
:mode="mode" :mode="mode"
:rounded="rounded" :rounded="rounded"
scroll-to-active
:theme="theme" :theme="theme"
@open="handleMenuOpen" @open="handleMenuOpen"
@select="handleMenuSelect" @select="handleMenuSelect"

View File

@ -6,39 +6,55 @@ import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
function useNavigation() { function useNavigation() {
const router = useRouter(); const router = useRouter();
const routes = router.getRoutes();
const routeMetaMap = new Map<string, RouteRecordNormalized>(); const routeMetaMap = new Map<string, RouteRecordNormalized>();
routes.forEach((route) => { // 初始化路由映射
routeMetaMap.set(route.path, route); const initRouteMetaMap = () => {
const routes = router.getRoutes();
routes.forEach((route) => {
routeMetaMap.set(route.path, route);
});
};
initRouteMetaMap();
// 监听路由变化
router.afterEach(() => {
initRouteMetaMap();
}); });
const navigation = async (path: string) => { // 检查是否应该在新窗口打开
const route = routeMetaMap.get(path); const shouldOpenInNewWindow = (path: string): boolean => {
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
if (isHttpUrl(path)) { if (isHttpUrl(path)) {
openWindow(path, { target: '_blank' }); return true;
} else if (openInNewWindow) { }
openRouteInNewWindow(path); const route = routeMetaMap.get(path);
} else { return route?.meta?.openInNewWindow ?? false;
await router.push({ };
path,
query, const navigation = async (path: string) => {
}); try {
const route = routeMetaMap.get(path);
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
if (isHttpUrl(path)) {
openWindow(path, { target: '_blank' });
} else if (openInNewWindow) {
openRouteInNewWindow(path);
} else {
await router.push({
path,
query,
});
}
} catch (error) {
console.error('Navigation failed:', error);
throw error;
} }
}; };
const willOpenedByWindow = (path: string) => { const willOpenedByWindow = (path: string) => {
const route = routeMetaMap.get(path); return shouldOpenInNewWindow(path);
const { openInNewWindow = false } = route?.meta ?? {};
if (isHttpUrl(path)) {
return true;
} else if (openInNewWindow) {
return true;
} else {
return false;
}
}; };
return { navigation, willOpenedByWindow }; return { navigation, willOpenedByWindow };

View File

@ -69,7 +69,7 @@ describe('generateMenus', () => {
}, },
]; ];
const menus = await generateMenus(mockRoutes, mockRouter as any); const menus = generateMenus(mockRoutes, mockRouter as any);
expect(menus).toEqual(expectedMenus); expect(menus).toEqual(expectedMenus);
}); });
@ -82,7 +82,7 @@ describe('generateMenus', () => {
}, },
] as RouteRecordRaw[]; ] as RouteRecordRaw[];
const menus = await generateMenus(mockRoutesWithMeta, mockRouter as any); const menus = generateMenus(mockRoutesWithMeta, mockRouter as any);
expect(menus).toEqual([ expect(menus).toEqual([
{ {
badge: undefined, badge: undefined,
@ -109,7 +109,7 @@ describe('generateMenus', () => {
}, },
] as RouteRecordRaw[]; ] as RouteRecordRaw[];
const menus = await generateMenus(mockRoutesWithParams, mockRouter as any); const menus = generateMenus(mockRoutesWithParams, mockRouter as any);
expect(menus).toEqual([ expect(menus).toEqual([
{ {
badge: undefined, badge: undefined,
@ -141,10 +141,7 @@ describe('generateMenus', () => {
}, },
] as RouteRecordRaw[]; ] as RouteRecordRaw[];
const menus = await generateMenus( const menus = generateMenus(mockRoutesWithRedirect, mockRouter as any);
mockRoutesWithRedirect,
mockRouter as any,
);
expect(menus).toEqual([ expect(menus).toEqual([
// Assuming your generateMenus function excludes redirect routes from the menu // Assuming your generateMenus function excludes redirect routes from the menu
{ {
@ -195,7 +192,7 @@ describe('generateMenus', () => {
}); });
it('should generate menu list with correct order', async () => { it('should generate menu list with correct order', async () => {
const menus = await generateMenus(routes, router); const menus = generateMenus(routes, router);
const expectedMenus = [ const expectedMenus = [
{ {
badge: undefined, badge: undefined,
@ -230,7 +227,7 @@ describe('generateMenus', () => {
it('should handle empty routes', async () => { it('should handle empty routes', async () => {
const emptyRoutes: any[] = []; const emptyRoutes: any[] = [];
const menus = await generateMenus(emptyRoutes, router); const menus = generateMenus(emptyRoutes, router);
expect(menus).toEqual([]); expect(menus).toEqual([]);
}); });
}); });

View File

@ -1,30 +1,38 @@
import type { Router, RouteRecordRaw } from 'vue-router'; import type { Router, RouteRecordRaw } from 'vue-router';
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings'; import type {
ExRouteRecordRaw,
MenuRecordRaw,
RouteMeta,
} from '@vben-core/typings';
import { filterTree, mapTree } from '@vben-core/shared/utils'; import { filterTree, mapTree } from '@vben-core/shared/utils';
/** /**
* routes * routes
* @param routes * @param routes -
* @param router - Vue Router
* @returns
*/ */
async function generateMenus( function generateMenus(
routes: RouteRecordRaw[], routes: RouteRecordRaw[],
router: Router, router: Router,
): Promise<MenuRecordRaw[]> { ): MenuRecordRaw[] {
// 将路由列表转换为一个以 name 为键的对象映射 // 将路由列表转换为一个以 name 为键的对象映射
// 获取所有router最终的path及name
const finalRoutesMap: { [key: string]: string } = Object.fromEntries( const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
router.getRoutes().map(({ name, path }) => [name, path]), router.getRoutes().map(({ name, path }) => [name, path]),
); );
let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => { let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => {
// 路由表的路径写法有多种这里从router获取到最终的path并赋值 // 获取最终的路由路径
const path = finalRoutesMap[route.name as string] ?? route.path; const path = finalRoutesMap[route.name as string] ?? route.path ?? '';
// 转换为菜单结构 const {
// const path = matchRoute?.path ?? route.path; meta = {} as RouteMeta,
const { meta, name: routeName, redirect, children } = route; name: routeName,
redirect,
children = [],
} = route;
const { const {
activeIcon, activeIcon,
badge, badge,
@ -35,24 +43,27 @@ async function generateMenus(
link, link,
order, order,
title = '', title = '',
} = meta || {}; } = meta;
// 确保菜单名称不为空
const name = (title || routeName || '') as string; const name = (title || routeName || '') as string;
// 隐藏子菜单 // 处理子菜单
const resultChildren = hideChildrenInMenu const resultChildren = hideChildrenInMenu
? [] ? []
: (children as MenuRecordRaw[]); : (children as MenuRecordRaw[]);
// 将菜单的所有父级和父级菜单记录到菜单项内 // 设置子菜单的父子关系
if (resultChildren && resultChildren.length > 0) { if (resultChildren.length > 0) {
resultChildren.forEach((child) => { resultChildren.forEach((child) => {
child.parents = [...(route.parents || []), path]; child.parents = [...(route.parents ?? []), path];
child.parent = path; child.parent = path;
}); });
} }
// 隐藏子菜单
// 确定最终路径
const resultPath = hideChildrenInMenu ? redirect || path : link || path; const resultPath = hideChildrenInMenu ? redirect || path : link || path;
return { return {
activeIcon, activeIcon,
badge, badge,
@ -63,19 +74,17 @@ async function generateMenus(
order, order,
parent: route.parent, parent: route.parent,
parents: route.parents, parents: route.parents,
path: resultPath as string, path: resultPath,
show: !route?.meta?.hideInMenu, show: !meta.hideInMenu,
children: resultChildren || [], children: resultChildren,
}; };
}); });
// 对菜单进行排序避免order=0时被替换成999的问题 // 对菜单进行排序避免order=0时被替换成999的问题
menus = menus.sort((a, b) => (a?.order ?? 999) - (b?.order ?? 999)); menus = menus.sort((a, b) => (a?.order ?? 999) - (b?.order ?? 999));
const finalMenus = filterTree(menus, (menu) => { // 过滤掉隐藏的菜单项
return !!menu.show; return filterTree(menus, (menu) => !!menu.show);
});
return finalMenus;
} }
export { generateMenus }; export { generateMenus };