物业代码生成
This commit is contained in:
1
packages/@core/ui-kit/menu-ui/README.md
Normal file
1
packages/@core/ui-kit/menu-ui/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 菜单组件
|
26
packages/@core/ui-kit/menu-ui/build.config.ts
Normal file
26
packages/@core/ui-kit/menu-ui/build.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
pattern: ['**/*'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
});
|
48
packages/@core/ui-kit/menu-ui/package.json
Normal file
48
packages/@core/ui-kit/menu-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/menu-ui",
|
||||
"version": "5.5.6",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/menu-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
1
packages/@core/ui-kit/menu-ui/postcss.config.mjs
Normal file
1
packages/@core/ui-kit/menu-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RendererElement } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'CollapseTransition',
|
||||
});
|
||||
|
||||
const reset = (el: RendererElement) => {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
};
|
||||
|
||||
const on = {
|
||||
afterEnter(el: RendererElement) {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
},
|
||||
|
||||
afterLeave(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
beforeEnter(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
if (el.style.height) el.dataset.elExistsHeight = el.style.height;
|
||||
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginBottom = 0;
|
||||
},
|
||||
|
||||
beforeLeave(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
el.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
enter(el: RendererElement) {
|
||||
requestAnimationFrame(() => {
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
if (el.dataset.elExistsHeight) {
|
||||
el.style.maxHeight = el.dataset.elExistsHeight;
|
||||
} else if (el.scrollHeight === 0) {
|
||||
el.style.maxHeight = 0;
|
||||
} else {
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
el.style.marginTop = el.dataset.oldMarginTop;
|
||||
el.style.marginBottom = el.dataset.oldMarginBottom;
|
||||
el.style.overflow = 'hidden';
|
||||
});
|
||||
},
|
||||
|
||||
enterCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
leave(el: RendererElement) {
|
||||
if (el.scrollHeight !== 0) {
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.marginBottom = 0;
|
||||
}
|
||||
},
|
||||
|
||||
leaveCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="collapse-transition" v-on="on">
|
||||
<slot></slot>
|
||||
</transition>
|
||||
</template>
|
4
packages/@core/ui-kit/menu-ui/src/components/index.ts
Normal file
4
packages/@core/ui-kit/menu-ui/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Menu } from './menu.vue';
|
||||
export { default as MenuBadge } from './menu-badge.vue';
|
||||
export { default as MenuItem } from './menu-item.vue';
|
||||
export { default as SubMenu } from './sub-menu.vue';
|
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
interface Props {
|
||||
dotClass?: string;
|
||||
dotStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
dotClass: '',
|
||||
dotStyle: () => ({}),
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span class="relative mr-1 flex size-1.5">
|
||||
<span
|
||||
:class="dotClass"
|
||||
:style="dotStyle"
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
:class="dotClass"
|
||||
:style="dotStyle"
|
||||
class="relative inline-flex size-1.5 rounded-full"
|
||||
></span>
|
||||
</span>
|
||||
</template>
|
57
packages/@core/ui-kit/menu-ui/src/components/menu-badge.vue
Normal file
57
packages/@core/ui-kit/menu-ui/src/components/menu-badge.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordBadgeRaw } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isValidColor } from '@vben-core/shared/color';
|
||||
|
||||
import BadgeDot from './menu-badge-dot.vue';
|
||||
|
||||
interface Props extends MenuRecordBadgeRaw {
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const variantsMap: Record<string, string> = {
|
||||
default: 'bg-green-500',
|
||||
destructive: 'bg-destructive',
|
||||
primary: 'bg-primary',
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
};
|
||||
|
||||
const isDot = computed(() => props.badgeType === 'dot');
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const { badgeVariants } = props;
|
||||
|
||||
if (!badgeVariants) {
|
||||
return variantsMap.default;
|
||||
}
|
||||
|
||||
return variantsMap[badgeVariants] || badgeVariants;
|
||||
});
|
||||
|
||||
const badgeStyle = computed(() => {
|
||||
if (badgeClass.value && isValidColor(badgeClass.value)) {
|
||||
return {
|
||||
backgroundColor: badgeClass.value,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span v-if="isDot || badge" :class="$attrs.class" class="absolute">
|
||||
<BadgeDot v-if="isDot" :dot-class="badgeClass" :dot-style="badgeStyle" />
|
||||
<div
|
||||
v-else
|
||||
:class="badgeClass"
|
||||
:style="badgeStyle"
|
||||
class="text-primary-foreground flex-center rounded-xl px-1.5 py-0.5 text-[10px]"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
122
packages/@core/ui-kit/menu-ui/src/components/menu-item.vue
Normal file
122
packages/@core/ui-kit/menu-ui/src/components/menu-item.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuItemProps, MenuItemRegistered } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenIcon, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { MenuBadge } from '../components';
|
||||
import { useMenu, useMenuContext, useSubMenuContext } from '../hooks';
|
||||
|
||||
interface Props extends MenuItemProps {}
|
||||
|
||||
defineOptions({ name: 'MenuItem' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ click: [MenuItemRegistered] }>();
|
||||
|
||||
const slots = useSlots();
|
||||
const { b, e, is } = useNamespace('menu-item');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
|
||||
const active = computed(() => props.path === rootMenu?.activePath);
|
||||
const menuIcon = computed(() =>
|
||||
active.value ? props.activeIcon || props.icon : props.icon,
|
||||
);
|
||||
|
||||
const isTopLevelMenuItem = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
|
||||
const collapseShowTitle = computed(
|
||||
() =>
|
||||
rootMenu.props?.collapseShowTitle &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props.collapse,
|
||||
);
|
||||
|
||||
const showTooltip = computed(
|
||||
() =>
|
||||
rootMenu.props.mode === 'vertical' &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props?.collapse &&
|
||||
slots.title,
|
||||
);
|
||||
|
||||
const item: MenuItemRegistered = reactive({
|
||||
active,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path || '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 菜单项点击事件
|
||||
*/
|
||||
function handleClick() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
rootMenu?.handleMenuItemClick?.({
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
emit('click', item);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addMenuItem?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeMenuItem?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
rootMenu.theme,
|
||||
b(),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
is('collapse-show-title', collapseShowTitle),
|
||||
]"
|
||||
role="menuitem"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<VbenTooltip
|
||||
v-if="showTooltip"
|
||||
:content-class="[rootMenu.theme]"
|
||||
side="right"
|
||||
>
|
||||
<template #trigger>
|
||||
<div :class="[nsMenu.be('tooltip', 'trigger')]">
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" />
|
||||
<slot></slot>
|
||||
<span v-if="collapseShowTitle" :class="nsMenu.e('name')">
|
||||
<slot name="title"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="title"></slot>
|
||||
</VbenTooltip>
|
||||
<div v-show="!showTooltip" :class="[e('content')]">
|
||||
<MenuBadge
|
||||
v-if="rootMenu.props.mode !== 'horizontal'"
|
||||
class="right-2"
|
||||
v-bind="props"
|
||||
/>
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" />
|
||||
<slot></slot>
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
872
packages/@core/ui-kit/menu-ui/src/components/menu.vue
Normal file
872
packages/@core/ui-kit/menu-ui/src/components/menu.vue
Normal file
@@ -0,0 +1,872 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UseResizeObserverReturn } from '@vueuse/core';
|
||||
|
||||
import type { SetupContext, VNodeArrayChildren } from 'vue';
|
||||
|
||||
import type {
|
||||
MenuItemClicked,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
reactive,
|
||||
ref,
|
||||
toRef,
|
||||
useSlots,
|
||||
watch,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { Ellipsis } from '@vben-core/icons';
|
||||
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuStyle,
|
||||
} from '../hooks';
|
||||
import { useMenuScroll } from '../hooks/use-menu-scroll';
|
||||
import { flattedChildren } from '../utils';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props extends MenuProps {}
|
||||
|
||||
defineOptions({ name: 'Menu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
collapse: false,
|
||||
mode: 'vertical',
|
||||
rounded: true,
|
||||
theme: 'dark',
|
||||
scrollToActive: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string, string[]];
|
||||
open: [string, string[]];
|
||||
select: [string, string[]];
|
||||
}>();
|
||||
|
||||
const { b, is } = useNamespace('menu');
|
||||
const menuStyle = useMenuStyle();
|
||||
const slots: SetupContext['slots'] = useSlots();
|
||||
const menu = ref<HTMLUListElement>();
|
||||
const sliceIndex = ref(-1);
|
||||
const openedMenus = ref<MenuProvider['openedMenus']>(
|
||||
props.defaultOpeneds && !props.collapse ? [...props.defaultOpeneds] : [],
|
||||
);
|
||||
const activePath = ref<MenuProvider['activePath']>(props.defaultActive);
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const mouseInChild = ref(false);
|
||||
|
||||
const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
|
||||
return (
|
||||
props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
|
||||
);
|
||||
});
|
||||
|
||||
const getSlot = computed(() => {
|
||||
// 更新插槽内容
|
||||
const defaultSlots: VNodeArrayChildren = slots.default?.() ?? [];
|
||||
|
||||
const originalSlot = flattedChildren(defaultSlots) as VNodeArrayChildren;
|
||||
const slotDefault =
|
||||
sliceIndex.value === -1
|
||||
? originalSlot
|
||||
: originalSlot.slice(0, sliceIndex.value);
|
||||
|
||||
const slotMore =
|
||||
sliceIndex.value === -1 ? [] : originalSlot.slice(sliceIndex.value);
|
||||
|
||||
return { showSlotMore: slotMore.length > 0, slotDefault, slotMore };
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.collapse,
|
||||
(value) => {
|
||||
if (value) openedMenus.value = [];
|
||||
},
|
||||
);
|
||||
|
||||
watch(items.value, initMenu);
|
||||
|
||||
watch(
|
||||
() => props.defaultActive,
|
||||
(currentActive = '') => {
|
||||
if (!items.value[currentActive]) {
|
||||
activePath.value = '';
|
||||
}
|
||||
updateActiveName(currentActive);
|
||||
},
|
||||
);
|
||||
|
||||
let resizeStopper: UseResizeObserverReturn['stop'];
|
||||
watchEffect(() => {
|
||||
if (props.mode === 'horizontal') {
|
||||
resizeStopper = useResizeObserver(menu, handleResize).stop;
|
||||
} else {
|
||||
resizeStopper?.();
|
||||
}
|
||||
});
|
||||
|
||||
// 注入上下文
|
||||
createMenuContext(
|
||||
reactive({
|
||||
activePath,
|
||||
addMenuItem,
|
||||
addSubMenu,
|
||||
closeMenu,
|
||||
handleMenuItemClick,
|
||||
handleSubMenuClick,
|
||||
isMenuPopup,
|
||||
openedMenus,
|
||||
openMenu,
|
||||
props,
|
||||
removeMenuItem,
|
||||
removeSubMenu,
|
||||
subMenus,
|
||||
theme: toRef(props, 'theme'),
|
||||
items,
|
||||
}),
|
||||
);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
level: 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
function calcMenuItemWidth(menuItem: HTMLElement) {
|
||||
const computedStyle = getComputedStyle(menuItem);
|
||||
const marginLeft = Number.parseInt(computedStyle.marginLeft, 10);
|
||||
const marginRight = Number.parseInt(computedStyle.marginRight, 10);
|
||||
return menuItem.offsetWidth + marginLeft + marginRight || 0;
|
||||
}
|
||||
|
||||
function calcSliceIndex() {
|
||||
if (!menu.value) {
|
||||
return -1;
|
||||
}
|
||||
const items = [...(menu.value?.childNodes ?? [])].filter(
|
||||
(item) =>
|
||||
// remove comment type node #12634
|
||||
item.nodeName !== '#comment' &&
|
||||
(item.nodeName !== '#text' || item.nodeValue),
|
||||
) as HTMLElement[];
|
||||
|
||||
const moreItemWidth = 46;
|
||||
const computedMenuStyle = getComputedStyle(menu?.value);
|
||||
|
||||
const paddingLeft = Number.parseInt(computedMenuStyle.paddingLeft, 10);
|
||||
const paddingRight = Number.parseInt(computedMenuStyle.paddingRight, 10);
|
||||
const menuWidth = menu.value?.clientWidth - paddingLeft - paddingRight;
|
||||
|
||||
let calcWidth = 0;
|
||||
let sliceIndex = 0;
|
||||
items.forEach((item, index) => {
|
||||
calcWidth += calcMenuItemWidth(item);
|
||||
if (calcWidth <= menuWidth - moreItemWidth) {
|
||||
sliceIndex = index + 1;
|
||||
}
|
||||
});
|
||||
return sliceIndex === items.length ? -1 : sliceIndex;
|
||||
}
|
||||
|
||||
function debounce(fn: () => void, wait = 33.34) {
|
||||
let timer: null | ReturnType<typeof setTimeout>;
|
||||
return () => {
|
||||
timer && clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
fn();
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
let isFirstTimeRender = true;
|
||||
function handleResize() {
|
||||
if (sliceIndex.value === calcSliceIndex()) {
|
||||
return;
|
||||
}
|
||||
const callback = () => {
|
||||
sliceIndex.value = -1;
|
||||
nextTick(() => {
|
||||
sliceIndex.value = calcSliceIndex();
|
||||
});
|
||||
};
|
||||
callback();
|
||||
// // execute callback directly when first time resize to avoid shaking
|
||||
isFirstTimeRender ? callback() : debounce(callback)();
|
||||
isFirstTimeRender = false;
|
||||
}
|
||||
|
||||
const enableScroll = computed(
|
||||
() => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
|
||||
);
|
||||
|
||||
const { scrollToActiveItem } = useMenuScroll(activePath, {
|
||||
enable: enableScroll,
|
||||
delay: 320,
|
||||
});
|
||||
|
||||
// 监听 activePath 变化,自动滚动到激活项
|
||||
watch(activePath, () => {
|
||||
scrollToActiveItem();
|
||||
});
|
||||
|
||||
// 默认展开菜单
|
||||
function initMenu() {
|
||||
const parentPaths = getActivePaths();
|
||||
|
||||
// 展开该菜单项的路径上所有子菜单
|
||||
// expand all subMenus of the menu item
|
||||
parentPaths.forEach((path) => {
|
||||
const subMenu = subMenus.value[path];
|
||||
subMenu && openMenu(path, subMenu.parentPaths);
|
||||
});
|
||||
}
|
||||
|
||||
function updateActiveName(val: string) {
|
||||
const itemsInData = items.value;
|
||||
const item =
|
||||
itemsInData[val] ||
|
||||
(activePath.value && itemsInData[activePath.value]) ||
|
||||
itemsInData[props.defaultActive || ''];
|
||||
|
||||
activePath.value = item ? item.path : val;
|
||||
}
|
||||
|
||||
function handleMenuItemClick(data: MenuItemClicked) {
|
||||
const { collapse, mode } = props;
|
||||
if (mode === 'horizontal' || collapse) {
|
||||
openedMenus.value = [];
|
||||
}
|
||||
const { parentPaths, path } = data;
|
||||
if (!path || !parentPaths) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', path, parentPaths);
|
||||
}
|
||||
|
||||
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
|
||||
const isOpened = openedMenus.value.includes(path);
|
||||
|
||||
if (isOpened) {
|
||||
closeMenu(path, parentPaths);
|
||||
} else {
|
||||
openMenu(path, parentPaths);
|
||||
}
|
||||
}
|
||||
|
||||
function close(path: string) {
|
||||
const i = openedMenus.value.indexOf(path);
|
||||
|
||||
if (i !== -1) {
|
||||
openedMenus.value.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭、折叠菜单
|
||||
*/
|
||||
function closeMenu(path: string, parentPaths: string[]) {
|
||||
if (props.accordion) {
|
||||
openedMenus.value = subMenus.value[path]?.parentPaths ?? [];
|
||||
}
|
||||
|
||||
close(path);
|
||||
|
||||
emit('close', path, parentPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击展开菜单
|
||||
*/
|
||||
function openMenu(path: string, parentPaths: string[]) {
|
||||
if (openedMenus.value.includes(path)) {
|
||||
return;
|
||||
}
|
||||
// 手风琴模式菜单
|
||||
if (props.accordion) {
|
||||
const activeParentPaths = getActivePaths();
|
||||
if (activeParentPaths.includes(path)) {
|
||||
parentPaths = activeParentPaths;
|
||||
}
|
||||
openedMenus.value = openedMenus.value.filter((path: string) =>
|
||||
parentPaths.includes(path),
|
||||
);
|
||||
}
|
||||
openedMenus.value.push(path);
|
||||
emit('open', path, parentPaths);
|
||||
}
|
||||
|
||||
function addMenuItem(item: MenuItemRegistered) {
|
||||
items.value[item.path] = item;
|
||||
}
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
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
|
||||
ref="menu"
|
||||
:class="[
|
||||
theme,
|
||||
b(),
|
||||
is(mode, true),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
is('collapse', collapse),
|
||||
is('menu-align', mode === 'horizontal'),
|
||||
]"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
>
|
||||
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
|
||||
<template v-for="item in getSlot.slotDefault" :key="item.key">
|
||||
<component :is="item" />
|
||||
</template>
|
||||
<SubMenu is-sub-menu-more path="sub-menu-more">
|
||||
<template #title>
|
||||
<Ellipsis class="size-4" />
|
||||
</template>
|
||||
<template v-for="item in getSlot.slotMore" :key="item.key">
|
||||
<component :is="item" />
|
||||
</template>
|
||||
</SubMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
$namespace: vben;
|
||||
|
||||
@mixin menu-item-active {
|
||||
color: var(--menu-item-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-active-background-color);
|
||||
}
|
||||
|
||||
@mixin menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
// gap: 12px;
|
||||
align-items: center;
|
||||
height: var(--menu-item-height);
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: 0 var(--menu-item-margin-x) var(--menu-item-margin-y)
|
||||
var(--menu-item-margin-x);
|
||||
font-size: var(--menu-font-size);
|
||||
color: var(--menu-item-color);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-background-color);
|
||||
border: none;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
background: none !important;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__icon {
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-menu__icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
* {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menu-title {
|
||||
max-width: var(--menu-title-width);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.is-menu-align {
|
||||
justify-content: var(--menu-align, start);
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__popup-container,
|
||||
.#{$namespace}-menu {
|
||||
--menu-title-width: 140px;
|
||||
--menu-item-icon-size: 16px;
|
||||
--menu-item-height: 38px;
|
||||
--menu-item-padding-y: 21px;
|
||||
--menu-item-padding-x: 12px;
|
||||
--menu-item-popup-padding-y: 20px;
|
||||
--menu-item-popup-padding-x: 12px;
|
||||
--menu-item-margin-y: 2px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-collapse-padding-y: 23.5px;
|
||||
--menu-item-collapse-padding-x: 0px;
|
||||
--menu-item-collapse-margin-y: 4px;
|
||||
--menu-item-collapse-margin-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
--menu-item-indent: 16px;
|
||||
--menu-font-size: 14px;
|
||||
|
||||
&.is-dark {
|
||||
--menu-background-color: hsl(var(--menu));
|
||||
// --menu-submenu-opened-background-color: hsl(var(--menu-opened-dark));
|
||||
--menu-item-background-color: var(--menu-background-color);
|
||||
--menu-item-color: hsl(var(--foreground) / 80%);
|
||||
--menu-item-hover-color: hsl(var(--accent-foreground));
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-active-color: hsl(var(--accent-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--accent));
|
||||
--menu-submenu-hover-color: hsl(var(--foreground));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
--menu-submenu-active-color: hsl(var(--foreground));
|
||||
--menu-submenu-active-background-color: transparent;
|
||||
--menu-submenu-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
--menu-background-color: hsl(var(--menu));
|
||||
// --menu-submenu-opened-background-color: hsl(var(--menu-opened));
|
||||
--menu-item-background-color: var(--menu-background-color);
|
||||
--menu-item-color: hsl(var(--foreground));
|
||||
--menu-item-hover-color: var(--menu-item-color);
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-active-color: hsl(var(--primary));
|
||||
--menu-item-active-background-color: hsl(var(--primary) / 15%);
|
||||
--menu-submenu-hover-color: hsl(var(--primary));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
--menu-submenu-active-color: hsl(var(--primary));
|
||||
--menu-submenu-active-background-color: transparent;
|
||||
--menu-submenu-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&.is-rounded {
|
||||
--menu-item-margin-x: 8px;
|
||||
--menu-item-collapse-margin-x: 6px;
|
||||
--menu-item-radius: 8px;
|
||||
}
|
||||
|
||||
&.is-horizontal:not(.is-rounded) {
|
||||
--menu-item-height: 40px;
|
||||
--menu-item-radius: 6px;
|
||||
}
|
||||
|
||||
&.is-horizontal.is-rounded {
|
||||
--menu-item-height: 40px;
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-padding-x: 12px;
|
||||
}
|
||||
|
||||
// .vben-menu__popup,
|
||||
&.is-horizontal {
|
||||
--menu-item-padding-y: 0px;
|
||||
--menu-item-padding-x: 10px;
|
||||
--menu-item-margin-y: 0px;
|
||||
--menu-item-margin-x: 1px;
|
||||
--menu-background-color: transparent;
|
||||
|
||||
&.is-dark {
|
||||
--menu-item-hover-color: hsl(var(--accent-foreground));
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-active-color: hsl(var(--accent-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--accent));
|
||||
--menu-submenu-active-color: hsl(var(--foreground));
|
||||
--menu-submenu-active-background-color: hsl(var(--accent));
|
||||
--menu-submenu-hover-color: hsl(var(--accent-foreground));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
--menu-item-active-color: hsl(var(--primary));
|
||||
--menu-item-active-background-color: hsl(var(--primary) / 15%);
|
||||
--menu-item-hover-background-color: hsl(var(--accent));
|
||||
--menu-item-hover-color: hsl(var(--primary));
|
||||
--menu-submenu-active-color: hsl(var(--primary));
|
||||
--menu-submenu-active-background-color: hsl(var(--primary) / 15%);
|
||||
--menu-submenu-hover-color: hsl(var(--primary));
|
||||
--menu-submenu-hover-background-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-menu {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: hsl(var(--menu-background-color));
|
||||
|
||||
// 垂直菜单
|
||||
&.is-vertical {
|
||||
&:not(.#{$namespace}-menu.is-collapse) {
|
||||
& .#{$namespace}-menu-item,
|
||||
& .#{$namespace}-sub-menu-content,
|
||||
& .#{$namespace}-menu-item-group__title {
|
||||
padding-left: calc(
|
||||
var(--menu-item-indent) + var(--menu-level) * var(--menu-item-indent)
|
||||
);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu {
|
||||
& > .#{$namespace}-menu {
|
||||
& > .#{$namespace}-menu-item {
|
||||
padding-left: calc(
|
||||
0px + var(--menu-item-indent) + var(--menu-level) *
|
||||
var(--menu-item-indent)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu-content {
|
||||
padding-left: calc(var(--menu-item-indent) - 8px);
|
||||
}
|
||||
}
|
||||
& > .#{$namespace}-menu-item {
|
||||
padding-left: calc(var(--menu-item-indent) - 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-horizontal {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
height: var(--height-horizontal-height);
|
||||
border-right: none;
|
||||
|
||||
.#{$namespace}-menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--menu-item-height);
|
||||
padding-right: calc(var(--menu-item-padding-x) + 6px);
|
||||
margin: 0;
|
||||
margin-right: 2px;
|
||||
// border-bottom: 2px solid transparent;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu {
|
||||
height: var(--menu-item-height);
|
||||
margin-right: 2px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& .#{$namespace}-sub-menu-content {
|
||||
height: 100%;
|
||||
padding-right: 40px;
|
||||
// border-bottom: 2px solid transparent;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
}
|
||||
|
||||
& .#{$namespace}-menu-item:not(.is-disabled):hover,
|
||||
& .#{$namespace}-menu-item:not(.is-disabled):focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& > .#{$namespace}-menu-item.is-active {
|
||||
color: var(--menu-item-active-color);
|
||||
}
|
||||
|
||||
// &.is-light {
|
||||
// & > .#{$namespace}-sub-menu {
|
||||
// &.is-active {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// &:not(.is-active) .#{$namespace}-sub-menu-content {
|
||||
// &:hover {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// & > .#{$namespace}-menu-item.is-active {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
|
||||
// & .#{$namespace}-menu-item:not(.is-disabled):hover,
|
||||
// & .#{$namespace}-menu-item:not(.is-disabled):focus {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// 折叠菜单
|
||||
|
||||
&.is-collapse {
|
||||
.#{$namespace}-menu__icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
.#{$namespace}-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--menu-item-collapse-padding-y)
|
||||
var(--menu-item-collapse-padding-x);
|
||||
margin: var(--menu-item-collapse-margin-y)
|
||||
var(--menu-item-collapse-margin-x);
|
||||
transition: all 0.3s;
|
||||
|
||||
&.is-active {
|
||||
background: var(--menu-item-active-background-color) !important;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
&.is-active {
|
||||
// color: hsl(var(--primary-foreground)) !important;
|
||||
background: var(--menu-item-active-background-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-rounded {
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
&.is-collapse-show-title {
|
||||
// padding: 32px 0 !important;
|
||||
margin: 4px 8px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__popup-container {
|
||||
max-width: 240px;
|
||||
height: unset;
|
||||
padding: 0;
|
||||
background: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&__popup {
|
||||
padding: 10px 0;
|
||||
border-radius: var(--menu-item-radius);
|
||||
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
padding: var(--menu-item-popup-padding-y) var(--menu-item-popup-padding-x);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
width: var(--menu-item-icon-size);
|
||||
height: var(--menu-item-icon-size);
|
||||
margin-right: 8px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-item {
|
||||
fill: var(--menu-item-color);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
&.is-active {
|
||||
fill: var(--menu-item-active-color);
|
||||
|
||||
@include menu-item-active;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--menu-item-height);
|
||||
|
||||
span {
|
||||
@include menu-title;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapse-show-title {
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
flex-direction: column;
|
||||
}
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__name {
|
||||
display: inline-flex;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
color: var(--menu-item-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-hover-background-color) !important;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--menu-item-padding-x);
|
||||
font-size: var(--menu-font-size);
|
||||
line-height: var(--menu-item-height);
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: var(--menu-submenu-background-color);
|
||||
fill: var(--menu-item-color);
|
||||
|
||||
&.is-active {
|
||||
div[data-state='open'] > .#{$namespace}-sub-menu-content,
|
||||
> .#{$namespace}-sub-menu-content {
|
||||
// font-weight: 500;
|
||||
color: var(--menu-submenu-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-active-background-color);
|
||||
fill: var(--menu-submenu-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu-content {
|
||||
height: var(--menu-item-height);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
&__icon-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
width: inherit;
|
||||
margin-top: -8px;
|
||||
margin-right: 0;
|
||||
// font-size: 16px;
|
||||
font-weight: normal;
|
||||
opacity: 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include menu-title;
|
||||
}
|
||||
|
||||
&.is-collapse-show-title {
|
||||
flex-direction: column;
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.#{$namespace}-sub-menu-content__title {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-more {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
// &:not(.is-active):hover {
|
||||
&:hover {
|
||||
color: var(--menu-submenu-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-hover-background-color) !important;
|
||||
|
||||
// svg {
|
||||
// fill: var(--menu-submenu-hover-color);
|
||||
// }
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,2 @@
|
||||
export type * from './normal-menu';
|
||||
export { default as NormalMenu } from './normal-menu.vue';
|
@@ -0,0 +1,27 @@
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
interface NormalMenuProps {
|
||||
/**
|
||||
* 菜单数据
|
||||
*/
|
||||
activePath?: string;
|
||||
/**
|
||||
* 是否折叠
|
||||
*/
|
||||
collapse?: boolean;
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menus?: MenuRecordRaw[];
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export type { NormalMenuProps };
|
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import type { NormalMenuProps } from './normal-menu';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'NormalMenu',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activePath: '',
|
||||
collapse: false,
|
||||
menus: () => [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
enter: [MenuRecordRaw];
|
||||
select: [MenuRecordRaw];
|
||||
}>();
|
||||
|
||||
const { b, e, is } = useNamespace('normal-menu');
|
||||
|
||||
function menuIcon(menu: MenuRecordRaw) {
|
||||
return props.activePath === menu.path
|
||||
? menu.activeIcon || menu.icon
|
||||
: menu.icon;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
:class="[
|
||||
theme,
|
||||
b(),
|
||||
is('collapse', collapse),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
]"
|
||||
class="relative"
|
||||
>
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<li
|
||||
:class="[e('item'), is('active', activePath === menu.path)]"
|
||||
@click="() => emit('select', menu)"
|
||||
@mouseenter="() => emit('enter', menu)"
|
||||
>
|
||||
<VbenIcon :class="e('icon')" :icon="menuIcon(menu)" fallback />
|
||||
|
||||
<span :class="e('name')" class="truncate"> {{ menu.name }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
$namespace: vben;
|
||||
|
||||
.#{$namespace}-normal-menu {
|
||||
--menu-item-margin-y: 4px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-padding-y: 9px;
|
||||
--menu-item-padding-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
|
||||
height: calc(100% - 4px);
|
||||
|
||||
&.is-rounded {
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-margin-x: 8px;
|
||||
}
|
||||
|
||||
&.is-dark {
|
||||
.#{$namespace}-normal-menu__item {
|
||||
@apply text-foreground/80;
|
||||
// color: hsl(var(--foreground) / 80%);
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapse {
|
||||
.#{$namespace}-normal-menu__name {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// max-width: 64px;
|
||||
// max-height: 64px;
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: var(--menu-item-margin-y) var(--menu-item-margin-x);
|
||||
color: hsl(var(--foreground) / 90%);
|
||||
cursor: pointer;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&.is-active {
|
||||
@apply text-primary bg-primary dark:bg-accent;
|
||||
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply text-primary-foreground font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply dark:bg-accent text-primary bg-heavy dark:text-foreground;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
max-height: 20px;
|
||||
font-size: 20px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuItemProps } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { ChevronDown, ChevronRight } from '@vben-core/icons';
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useMenuContext } from '../hooks';
|
||||
|
||||
interface Props extends MenuItemProps {
|
||||
isMenuMore?: boolean;
|
||||
isTopLevelMenuSubmenu: boolean;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenuContent' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isMenuMore: false,
|
||||
level: 0,
|
||||
});
|
||||
|
||||
const rootMenu = useMenuContext();
|
||||
const { b, e, is } = useNamespace('sub-menu-content');
|
||||
const nsMenu = useNamespace('menu');
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
|
||||
const collapse = computed(() => {
|
||||
return rootMenu.props.collapse;
|
||||
});
|
||||
|
||||
const isFirstLevel = computed(() => {
|
||||
return props.level === 1;
|
||||
});
|
||||
|
||||
const getCollapseShowTitle = computed(() => {
|
||||
return (
|
||||
rootMenu.props.collapseShowTitle && isFirstLevel.value && collapse.value
|
||||
);
|
||||
});
|
||||
|
||||
const mode = computed(() => {
|
||||
return rootMenu?.props.mode;
|
||||
});
|
||||
|
||||
const showArrowIcon = computed(() => {
|
||||
return mode.value === 'horizontal' || !(isFirstLevel.value && collapse.value);
|
||||
});
|
||||
|
||||
const hiddenTitle = computed(() => {
|
||||
return (
|
||||
mode.value === 'vertical' &&
|
||||
isFirstLevel.value &&
|
||||
collapse.value &&
|
||||
!getCollapseShowTitle.value
|
||||
);
|
||||
});
|
||||
|
||||
const iconComp = computed(() => {
|
||||
return (mode.value === 'horizontal' && !isFirstLevel.value) ||
|
||||
(mode.value === 'vertical' && collapse.value)
|
||||
? ChevronRight
|
||||
: ChevronDown;
|
||||
});
|
||||
|
||||
const iconArrowStyle = computed(() => {
|
||||
return opened.value ? { transform: `rotate(180deg)` } : {};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
b(),
|
||||
is('collapse-show-title', getCollapseShowTitle),
|
||||
is('more', isMenuMore),
|
||||
]"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<VbenIcon
|
||||
v-if="!isMenuMore"
|
||||
:class="nsMenu.e('icon')"
|
||||
:icon="icon"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<div v-if="!hiddenTitle" :class="[e('title')]">
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="iconComp"
|
||||
v-if="!isMenuMore"
|
||||
v-show="showArrowIcon"
|
||||
:class="[e('icon-arrow')]"
|
||||
:style="iconArrowStyle"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
275
packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
Normal file
275
packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HoverCardContentProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import type { MenuItemRegistered, MenuProvider, SubMenuProps } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenHoverCard } from '@vben-core/shadcn-ui';
|
||||
|
||||
import {
|
||||
createSubMenuContext,
|
||||
useMenu,
|
||||
useMenuContext,
|
||||
useMenuStyle,
|
||||
useSubMenuContext,
|
||||
} from '../hooks';
|
||||
import CollapseTransition from './collapse-transition.vue';
|
||||
import SubMenuContent from './sub-menu-content.vue';
|
||||
|
||||
interface Props extends SubMenuProps {
|
||||
isSubMenuMore?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
isSubMenuMore: false,
|
||||
});
|
||||
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
const { b, is } = useNamespace('sub-menu');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const subMenuStyle = useMenuStyle(subMenu);
|
||||
|
||||
const mouseInChild = ref(false);
|
||||
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
handleMouseleave,
|
||||
level: (subMenu?.level ?? 0) + 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
const isTopLevelMenuSubmenu = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
|
||||
const rounded = computed(() => rootMenu?.props.rounded);
|
||||
const currentLevel = computed(() => subMenu?.level ?? 0);
|
||||
const isFirstLevel = computed(() => {
|
||||
return currentLevel.value === 1;
|
||||
});
|
||||
|
||||
const contentProps = computed((): HoverCardContentProps => {
|
||||
const isHorizontal = mode.value === 'horizontal';
|
||||
const side = isHorizontal && isFirstLevel.value ? 'bottom' : 'right';
|
||||
return {
|
||||
collisionPadding: { top: 20 },
|
||||
side,
|
||||
sideOffset: isHorizontal ? 5 : 10,
|
||||
};
|
||||
});
|
||||
|
||||
const active = computed(() => {
|
||||
let isActive = false;
|
||||
|
||||
Object.values(items.value).forEach((item) => {
|
||||
if (item.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(subMenus.value).forEach((subItem) => {
|
||||
if (subItem.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
return isActive;
|
||||
});
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击submenu展开/关闭
|
||||
*/
|
||||
function handleClick() {
|
||||
const mode = rootMenu?.props.mode;
|
||||
if (
|
||||
// 当前菜单禁用时,不展开
|
||||
props.disabled ||
|
||||
(rootMenu?.props.collapse && mode === 'vertical') ||
|
||||
// 水平模式下不展开
|
||||
mode === 'horizontal'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootMenu?.handleSubMenuClick({
|
||||
active: active.value,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseenter(event: FocusEvent | MouseEvent, showTimeout = 300) {
|
||||
if (event.type === 'focus') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!rootMenu?.props.collapse && rootMenu?.props.mode === 'vertical') ||
|
||||
props.disabled
|
||||
) {
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
rootMenu?.openMenu(props.path, parentPaths.value);
|
||||
}, showTimeout);
|
||||
parentMenu.value?.vnode.el?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
}
|
||||
|
||||
function handleMouseleave(deepDispatch = false) {
|
||||
if (
|
||||
!rootMenu?.props.collapse &&
|
||||
rootMenu?.props.mode === 'vertical' &&
|
||||
subMenu
|
||||
) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
}
|
||||
timer.value = setTimeout(() => {
|
||||
!mouseInChild.value && rootMenu?.closeMenu(props.path, parentPaths.value);
|
||||
}, 300);
|
||||
|
||||
if (deepDispatch) {
|
||||
subMenu?.handleMouseleave?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
const menuIcon = computed(() =>
|
||||
active.value ? props.activeIcon || props.icon : props.icon,
|
||||
);
|
||||
|
||||
const item = reactive({
|
||||
active,
|
||||
parentPaths,
|
||||
path: props.path,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addSubMenu?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeSubMenu?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
b(),
|
||||
is('opened', opened),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
]"
|
||||
@focus="handleMouseenter"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="() => handleMouseleave()"
|
||||
>
|
||||
<template v-if="rootMenu.isMenuPopup">
|
||||
<VbenHoverCard
|
||||
:content-class="[
|
||||
rootMenu.theme,
|
||||
nsMenu.e('popup-container'),
|
||||
is(rootMenu.theme, true),
|
||||
opened ? '' : 'hidden',
|
||||
'overflow-auto',
|
||||
'max-h-[calc(var(--radix-hover-card-content-available-height)-20px)]',
|
||||
]"
|
||||
:content-props="contentProps"
|
||||
:open="true"
|
||||
:open-delay="0"
|
||||
>
|
||||
<template #trigger>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:icon="menuIcon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
</template>
|
||||
<div
|
||||
:class="[nsMenu.is(mode, true), nsMenu.e('popup')]"
|
||||
@focus="(e) => handleMouseenter(e, 100)"
|
||||
@mouseenter="(e) => handleMouseenter(e, 100)"
|
||||
@mouseleave="() => handleMouseleave(true)"
|
||||
>
|
||||
<ul
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</VbenHoverCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:icon="menuIcon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
<CollapseTransition>
|
||||
<ul
|
||||
v-show="opened"
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</CollapseTransition>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
2
packages/@core/ui-kit/menu-ui/src/hooks/index.ts
Normal file
2
packages/@core/ui-kit/menu-ui/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './use-menu';
|
||||
export * from './use-menu-context';
|
55
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-context.ts
Normal file
55
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-context.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { MenuProvider, SubMenuProvider } from '../types';
|
||||
|
||||
import { getCurrentInstance, inject, provide } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
const menuContextKey = Symbol('menuContext');
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createMenuContext(injectMenuData: MenuProvider) {
|
||||
provide(menuContextKey, injectMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createSubMenuContext(injectSubMenuData: SubMenuProvider) {
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
provide(`subMenu:${instance?.uid}`, injectSubMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const rootMenu = inject(menuContextKey) as MenuProvider;
|
||||
return rootMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useSubMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const parentMenu = findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
const subMenu = inject(`subMenu:${parentMenu?.uid}`) as SubMenuProvider;
|
||||
return subMenu;
|
||||
}
|
||||
|
||||
export {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuContext,
|
||||
useSubMenuContext,
|
||||
};
|
46
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Normal file
46
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
Normal 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,
|
||||
};
|
||||
}
|
48
packages/@core/ui-kit/menu-ui/src/hooks/use-menu.ts
Normal file
48
packages/@core/ui-kit/menu-ui/src/hooks/use-menu.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { SubMenuProvider } from '../types';
|
||||
|
||||
import { computed, getCurrentInstance } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
function useMenu() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 获取所有父级菜单链路
|
||||
*/
|
||||
const parentPaths = computed(() => {
|
||||
let parent = instance.parent;
|
||||
const paths: string[] = [instance.props.path as string];
|
||||
while (parent?.type.name !== 'Menu') {
|
||||
if (parent?.props.path) {
|
||||
paths.unshift(parent.props.path as string);
|
||||
}
|
||||
parent = parent?.parent ?? null;
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const parentMenu = computed(() => {
|
||||
return findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
});
|
||||
|
||||
return {
|
||||
parentMenu,
|
||||
parentPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function useMenuStyle(menu?: SubMenuProvider) {
|
||||
const subMenuStyle = computed(() => {
|
||||
return {
|
||||
'--menu-level': menu ? (menu?.level ?? 0 + 1) : 0,
|
||||
};
|
||||
});
|
||||
return subMenuStyle;
|
||||
}
|
||||
|
||||
export { useMenu, useMenuStyle };
|
4
packages/@core/ui-kit/menu-ui/src/index.ts
Normal file
4
packages/@core/ui-kit/menu-ui/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as MenuBadge } from './components/menu-badge.vue';
|
||||
export * from './components/normal-menu';
|
||||
export { default as Menu } from './menu.vue';
|
||||
export type * from './types';
|
32
packages/@core/ui-kit/menu-ui/src/menu.vue
Normal file
32
packages/@core/ui-kit/menu-ui/src/menu.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import type { MenuProps } from './types';
|
||||
|
||||
import { useForwardProps } from '@vben-core/composables';
|
||||
|
||||
import { Menu } from './components';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuView',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapse: false,
|
||||
});
|
||||
|
||||
const forward = useForwardProps(props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu v-bind="forward">
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<SubMenu :menu="menu" />
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
71
packages/@core/ui-kit/menu-ui/src/sub-menu.vue
Normal file
71
packages/@core/ui-kit/menu-ui/src/sub-menu.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { MenuBadge, MenuItem, SubMenu as SubMenuComp } from './components';
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menu: MenuRecordRaw;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'SubMenuUi',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
/**
|
||||
* 判断是否有子节点,动态渲染 menu-item/sub-menu-item
|
||||
*/
|
||||
const hasChildren = computed(() => {
|
||||
const { menu } = props;
|
||||
return (
|
||||
Reflect.has(menu, 'children') && !!menu.children && menu.children.length > 0
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MenuItem
|
||||
v-if="!hasChildren"
|
||||
:key="menu.path"
|
||||
:active-icon="menu.activeIcon"
|
||||
:badge="menu.badge"
|
||||
:badge-type="menu.badgeType"
|
||||
:badge-variants="menu.badgeVariants"
|
||||
:icon="menu.icon"
|
||||
:path="menu.path"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ menu.name }}</span>
|
||||
</template>
|
||||
</MenuItem>
|
||||
<SubMenuComp
|
||||
v-else
|
||||
:key="`${menu.path}_sub`"
|
||||
:active-icon="menu.activeIcon"
|
||||
:icon="menu.icon"
|
||||
:path="menu.path"
|
||||
>
|
||||
<template #content>
|
||||
<MenuBadge
|
||||
:badge="menu.badge"
|
||||
:badge-type="menu.badgeType"
|
||||
:badge-variants="menu.badgeVariants"
|
||||
class="right-6"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span>{{ menu.name }}</span>
|
||||
</template>
|
||||
<template v-for="childItem in menu.children || []" :key="childItem.path">
|
||||
<SubMenu :menu="childItem" />
|
||||
</template>
|
||||
</SubMenuComp>
|
||||
</template>
|
144
packages/@core/ui-kit/menu-ui/src/types.ts
Normal file
144
packages/@core/ui-kit/menu-ui/src/types.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { MenuRecordBadgeRaw, ThemeModeType } from '@vben-core/typings';
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
interface MenuProps {
|
||||
/**
|
||||
* @zh_CN 是否开启手风琴模式
|
||||
* @default true
|
||||
*/
|
||||
accordion?: boolean;
|
||||
/**
|
||||
* @zh_CN 菜单是否折叠
|
||||
* @default false
|
||||
*/
|
||||
collapse?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单折叠时是否显示菜单名称
|
||||
* @default false
|
||||
*/
|
||||
collapseShowTitle?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认激活的菜单
|
||||
*/
|
||||
defaultActive?: string;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认展开的菜单
|
||||
*/
|
||||
defaultOpeneds?: string[];
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单模式
|
||||
* @default vertical
|
||||
*/
|
||||
mode?: 'horizontal' | 'vertical';
|
||||
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 是否自动滚动到激活的菜单项
|
||||
* @default false
|
||||
*/
|
||||
scrollToActive?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单主题
|
||||
* @default dark
|
||||
*/
|
||||
theme?: ThemeModeType;
|
||||
}
|
||||
|
||||
interface SubMenuProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 激活图标
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* @zh_CN submenu 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* @zh_CN menuitem 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemRegistered {
|
||||
active: boolean;
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemClicked {
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuProvider {
|
||||
activePath?: string;
|
||||
addMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
closeMenu: (path: string, parentLinks: string[]) => void;
|
||||
handleMenuItemClick: (item: MenuItemClicked) => void;
|
||||
handleSubMenuClick: (subMenu: MenuItemRegistered) => void;
|
||||
isMenuPopup: boolean;
|
||||
items: Record<string, MenuItemRegistered>;
|
||||
|
||||
openedMenus: string[];
|
||||
openMenu: (path: string, parentLinks: string[]) => void;
|
||||
props: MenuProps;
|
||||
removeMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
|
||||
subMenus: Record<string, MenuItemRegistered>;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
interface SubMenuProvider {
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
handleMouseleave?: (deepDispatch: boolean) => void;
|
||||
level: number;
|
||||
mouseInChild: Ref<boolean>;
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
MenuItemClicked,
|
||||
MenuItemProps,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
SubMenuProps,
|
||||
SubMenuProvider,
|
||||
};
|
52
packages/@core/ui-kit/menu-ui/src/utils/index.ts
Normal file
52
packages/@core/ui-kit/menu-ui/src/utils/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ComponentInternalInstance,
|
||||
VNode,
|
||||
VNodeChild,
|
||||
VNodeNormalizedChildren,
|
||||
} from 'vue';
|
||||
|
||||
import { isVNode } from 'vue';
|
||||
|
||||
type VNodeChildAtom = Exclude<VNodeChild, Array<any>>;
|
||||
type RawSlots = Exclude<VNodeNormalizedChildren, Array<any> | null | string>;
|
||||
|
||||
type FlattenVNodes = Array<RawSlots | VNodeChildAtom>;
|
||||
|
||||
/**
|
||||
* @zh_CN Find the parent component upward
|
||||
* @param instance
|
||||
* @param parentNames
|
||||
*/
|
||||
function findComponentUpward(
|
||||
instance: ComponentInternalInstance,
|
||||
parentNames: string[],
|
||||
) {
|
||||
let parent = instance.parent;
|
||||
while (parent && !parentNames.includes(parent?.type?.name ?? '')) {
|
||||
parent = parent.parent;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
const flattedChildren = (
|
||||
children: FlattenVNodes | VNode | VNodeNormalizedChildren,
|
||||
): FlattenVNodes => {
|
||||
const vNodes = Array.isArray(children) ? children : [children];
|
||||
const result: FlattenVNodes = [];
|
||||
|
||||
vNodes.forEach((child) => {
|
||||
if (Array.isArray(child)) {
|
||||
result.push(...flattedChildren(child));
|
||||
} else if (isVNode(child) && Array.isArray(child.children)) {
|
||||
result.push(...flattedChildren(child.children));
|
||||
} else {
|
||||
result.push(child);
|
||||
if (isVNode(child) && child.component?.subTree) {
|
||||
result.push(...flattedChildren(child.component.subTree));
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export { findComponentUpward, flattedChildren };
|
1
packages/@core/ui-kit/menu-ui/tailwind.config.mjs
Normal file
1
packages/@core/ui-kit/menu-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
6
packages/@core/ui-kit/menu-ui/tsconfig.json
Normal file
6
packages/@core/ui-kit/menu-ui/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Reference in New Issue
Block a user