提交新版本
This commit is contained in:
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.9",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
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="flex-center rounded-xl px-1.5 py-0.5 text-[10px] text-primary-foreground"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
@@ -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 bg-primary text-primary dark:bg-accent;
|
||||
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply font-semibold text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply bg-heavy text-primary dark:bg-accent 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>
|
||||
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(--reka-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>
|
||||
Reference in New Issue
Block a user