Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions packages/@core/ui-kit/menu-ui/src/components/menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
createSubMenuContext,
useMenuStyle,
} from '../hooks';
import { useMenuScroll } from '../hooks/use-menu-scroll';
import { flattedChildren } from '../utils';
import SubMenu from './sub-menu.vue';

Expand All @@ -44,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
mode: 'vertical',
rounded: true,
theme: 'dark',
scrollToActive: false,
});

const emit = defineEmits<{
Expand Down Expand Up @@ -206,15 +208,19 @@ function handleResize() {
isFirstTimeRender = false;
}

function getActivePaths() {
const activeItem = activePath.value && items.value[activePath.value];
const enableScroll = computed(
() => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
);

if (!activeItem || props.mode === 'horizontal' || props.collapse) {
return [];
}
const { scrollToActiveItem } = useMenuScroll(activePath, {
enable: enableScroll,
delay: 320,
});

return activeItem.parentPaths;
}
// 监听 activePath 变化,自动滚动到激活项
watch(activePath, () => {
scrollToActiveItem();
});

// 默认展开菜单
function initMenu() {
Expand Down Expand Up @@ -318,6 +324,16 @@ function removeSubMenu(subMenu: MenuItemRegistered) {
function removeMenuItem(item: MenuItemRegistered) {
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>
<template>
<ul
Expand Down
46 changes: 46 additions & 0 deletions packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
6 changes: 0 additions & 6 deletions packages/@core/ui-kit/menu-ui/src/menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,9 @@ defineOptions({

const props = withDefaults(defineProps<Props>(), {
collapse: false,
// theme: 'dark',
});

const forward = useForwardProps(props);

// const emit = defineEmits<{
// 'update:openKeys': [key: Key[]];
// 'update:selectedKeys': [key: Key[]];
// }>();
</script>

<template>
Expand Down
6 changes: 6 additions & 0 deletions packages/@core/ui-kit/menu-ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ interface MenuProps {
*/
rounded?: boolean;

/**
* @zh_CN 是否自动滚动到激活的菜单项
* @default false
*/
scrollToActive?: boolean;

/**
* @zh_CN 菜单主题
* @default dark
Expand Down
2 changes: 1 addition & 1 deletion packages/effects/access/src/accessible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async function generateAccessible(
}

// 生成菜单
const accessibleMenus = await generateMenus(accessibleRoutes, options.router);
const accessibleMenus = generateMenus(accessibleRoutes, options.router);

return { accessibleMenus, accessibleRoutes };
}
Expand Down
1 change: 1 addition & 0 deletions packages/effects/layouts/src/basic/menu/menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function handleMenuOpen(key: string, path: string[]) {
:menus="menus"
:mode="mode"
:rounded="rounded"
scroll-to-active
:theme="theme"
@open="handleMenuOpen"
@select="handleMenuSelect"
Expand Down
64 changes: 40 additions & 24 deletions packages/effects/layouts/src/basic/menu/use-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,57 @@ import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';

function useNavigation() {
const router = useRouter();
const routes = router.getRoutes();

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 { openInNewWindow = false, query = {} } = route?.meta ?? {};
// 检查是否应该在新窗口打开
const shouldOpenInNewWindow = (path: string): boolean => {
if (isHttpUrl(path)) {
openWindow(path, { target: '_blank' });
} else if (openInNewWindow) {
openRouteInNewWindow(path);
} else {
await router.push({
path,
query,
});
return true;
}
const route = routeMetaMap.get(path);
return route?.meta?.openInNewWindow ?? false;
};

const willOpenedByWindow = (path: string) => {
const route = routeMetaMap.get(path);
const { openInNewWindow = false } = route?.meta ?? {};
if (isHttpUrl(path)) {
return true;
} else if (openInNewWindow) {
return true;
} else {
return false;
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) => {
return shouldOpenInNewWindow(path);
};

return { navigation, willOpenedByWindow };
}

Expand Down
15 changes: 6 additions & 9 deletions packages/utils/src/helpers/__tests__/generate-menus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -82,7 +82,7 @@ describe('generateMenus', () => {
},
] as RouteRecordRaw[];

const menus = await generateMenus(mockRoutesWithMeta, mockRouter as any);
const menus = generateMenus(mockRoutesWithMeta, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
Expand All @@ -109,7 +109,7 @@ describe('generateMenus', () => {
},
] as RouteRecordRaw[];

const menus = await generateMenus(mockRoutesWithParams, mockRouter as any);
const menus = generateMenus(mockRoutesWithParams, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
Expand Down Expand Up @@ -141,10 +141,7 @@ describe('generateMenus', () => {
},
] as RouteRecordRaw[];

const menus = await generateMenus(
mockRoutesWithRedirect,
mockRouter as any,
);
const menus = generateMenus(mockRoutesWithRedirect, mockRouter as any);
expect(menus).toEqual([
// Assuming your generateMenus function excludes redirect routes from the menu
{
Expand Down Expand Up @@ -195,7 +192,7 @@ describe('generateMenus', () => {
});

it('should generate menu list with correct order', async () => {
const menus = await generateMenus(routes, router);
const menus = generateMenus(routes, router);
const expectedMenus = [
{
badge: undefined,
Expand Down Expand Up @@ -230,7 +227,7 @@ describe('generateMenus', () => {

it('should handle empty routes', async () => {
const emptyRoutes: any[] = [];
const menus = await generateMenus(emptyRoutes, router);
const menus = generateMenus(emptyRoutes, router);
expect(menus).toEqual([]);
});
});
55 changes: 32 additions & 23 deletions packages/utils/src/helpers/generate-menus.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
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';

/**
* 根据 routes 生成菜单列表
* @param routes
* @param routes - 路由配置列表
* @param router - Vue Router 实例
* @returns 生成的菜单列表
*/
async function generateMenus(
function generateMenus(
routes: RouteRecordRaw[],
router: Router,
): Promise<MenuRecordRaw[]> {
): MenuRecordRaw[] {
// 将路由列表转换为一个以 name 为键的对象映射
// 获取所有router最终的path及name
const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
router.getRoutes().map(({ name, path }) => [name, path]),
);

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 path = matchRoute?.path ?? route.path;
const { meta, name: routeName, redirect, children } = route;
const {
meta = {} as RouteMeta,
name: routeName,
redirect,
children = [],
} = route;
const {
activeIcon,
badge,
Expand All @@ -35,24 +43,27 @@ async function generateMenus(
link,
order,
title = '',
} = meta || {};
} = meta;

// 确保菜单名称不为空
const name = (title || routeName || '') as string;

// 隐藏子菜单
// 处理子菜单
const resultChildren = hideChildrenInMenu
? []
: (children as MenuRecordRaw[]);

// 将菜单的所有父级和父级菜单记录到菜单项内
if (resultChildren && resultChildren.length > 0) {
// 设置子菜单的父子关系
if (resultChildren.length > 0) {
resultChildren.forEach((child) => {
child.parents = [...(route.parents || []), path];
child.parents = [...(route.parents ?? []), path];
child.parent = path;
});
}
// 隐藏子菜单

// 确定最终路径
const resultPath = hideChildrenInMenu ? redirect || path : link || path;

return {
activeIcon,
badge,
Expand All @@ -63,19 +74,17 @@ async function generateMenus(
order,
parent: route.parent,
parents: route.parents,
path: resultPath as string,
show: !route?.meta?.hideInMenu,
children: resultChildren || [],
path: resultPath,
show: !meta.hideInMenu,
children: resultChildren,
};
});

// 对菜单进行排序,避免order=0时被替换成999的问题
menus = menus.sort((a, b) => (a?.order ?? 999) - (b?.order ?? 999));

const finalMenus = filterTree(menus, (menu) => {
return !!menu.show;
});
return finalMenus;
// 过滤掉隐藏的菜单项
return filterTree(menus, (menu) => !!menu.show);
}

export { generateMenus };