feat: 升级前端框架,适配手机端

This commit is contained in:
xiaojunnuo
2025-03-06 21:11:07 +08:00
659 changed files with 37406 additions and 873 deletions
@@ -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>
@@ -0,0 +1,4 @@
export { default as MenuBadge } from "./menu-badge.vue";
export { default as MenuItem } from "./menu-item.vue";
export { default as Menu } from "./menu.vue";
export { default as SubMenu } from "./sub-menu.vue";
@@ -0,0 +1,19 @@
<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>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { MenuRecordBadgeRaw } from "../../typings";
import { computed } from "vue";
import { isValidColor } from "../../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>
@@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { MenuItemProps, MenuItemRegistered } from "../types";
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from "vue";
import { useNamespace } from "../../composables";
import { VbenIcon, VbenTooltip } from "../../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" fallback />
<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>
@@ -0,0 +1,810 @@
<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 "../../composables";
import { Ellipsis } from "../../icons";
import { isHttpUrl } from "../../shared/utils";
import { useResizeObserver } from "@vueuse/core";
import { createMenuContext, createSubMenuContext, useMenuStyle } from "../hooks";
import { flattedChildren } from "../utils";
import SubMenu from "./sub-menu.vue";
interface Props extends MenuProps {}
// @ts-ignore
// eslint-disable-next-line vue/multi-word-component-names,vue/no-reserved-component-names
defineOptions({ name: "Menu" });
const props = withDefaults(defineProps<Props>(), {
accordion: true,
collapse: false,
mode: "vertical",
rounded: true,
theme: "dark"
});
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;
}
function getActivePaths() {
const activeItem = activePath.value && items.value[activePath.value];
if (!activeItem || props.mode === "horizontal" || props.collapse) {
return [];
}
return activeItem.parentPaths;
}
// 默认展开菜单
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;
}
if (!isHttpUrl(path)) {
activePath.value = path;
}
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);
}
</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="less">
.menu-item-active() {
color: var(--menu-item-active-color);
text-decoration: none;
cursor: pointer;
background: var(--menu-item-active-background-color);
}
.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;
}
.vben-menu__icon {
transition: transform 0.25s;
}
&:hover {
.vben-menu__icon {
transform: scale(1.2);
}
}
&:hover,
&:focus {
outline: none;
}
* {
vertical-align: bottom;
}
}
.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);
}
.vben-menu__popup-container {
padding: 0px !important;
}
.vben-menu__popup-container,
.vben-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));
}
}
}
.vben-menu {
position: relative;
box-sizing: border-box;
padding-left: 0;
margin: 0;
list-style: none;
background: hsl(var(--menu-background-color));
// 垂直菜单
&.is-vertical {
&:not(.vben-menu.is-collapse) {
& .vben-menu-item,
& .vben-sub-menu-content,
& .vben-menu-item-group__title {
padding-left: calc(var(--menu-item-indent) + var(--menu-level) * var(--menu-item-indent));
white-space: nowrap;
}
& > .vben-sub-menu {
& > .vben-menu {
& > .vben-menu-item {
padding-left: calc(0px + var(--menu-item-indent) + var(--menu-level) * var(--menu-item-indent));
}
}
& > .vben-sub-menu-content {
padding-left: calc(var(--menu-item-indent) - 8px);
}
}
& > .vben-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;
.vben-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);
}
& > .vben-sub-menu {
height: var(--menu-item-height);
margin-right: 2px;
&:focus,
&:hover {
outline: none;
}
& .vben-sub-menu-content {
height: 100%;
padding-right: 40px;
// border-bottom: 2px solid transparent;
border-radius: var(--menu-item-radius);
}
}
& .vben-menu-item:not(.is-disabled):hover,
& .vben-menu-item:not(.is-disabled):focus {
outline: none;
}
& > .vben-menu-item.is-active {
color: var(--menu-item-active-color);
}
// &.is-light {
// & > .vben-sub-menu {
// &.is-active {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// &:not(.is-active) .vben-sub-menu-content {
// &:hover {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// }
// }
// & > .vben-menu-item.is-active {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// & .vben-menu-item:not(.is-disabled):hover,
// & .vben-menu-item:not(.is-disabled):focus {
// border-bottom: 2px solid var(--menu-item-active-color);
// }
// }
}
// 折叠菜单
&.is-collapse {
.vben-menu__icon {
margin-right: 0;
}
.vben-sub-menu__icon-arrow {
display: none;
}
.vben-sub-menu-content,
.vben-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 {
.vben-sub-menu-content,
.vben-menu-item {
&.is-active {
// color: hsl(var(--primary-foreground)) !important;
background: var(--menu-item-active-background-color) !important;
}
}
}
&.is-rounded {
.vben-sub-menu-content,
.vben-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);
.vben-sub-menu-content,
.vben-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;
}
}
.vben-menu-item {
fill: var(--menu-item-color);
.menu-item();
&.is-active {
fill: var(--menu-item-active-color);
.menu-item-active();
}
&__content {
display: inline-flex;
align-items: center;
width: 100%;
height: var(--menu-item-height);
span {
.menu-title();
}
}
&.is-collapse-show-title {
padding: 32px 0 !important;
// margin: 4px 8px !important;
.vben-menu-tooltip__trigger {
flex-direction: column;
}
.vben-menu__icon {
display: block;
font-size: 20px !important;
transition: all 0.25s ease;
}
.vben-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;
}
.vben-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);
}
}
.vben-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"] > .vben-sub-menu-content,
> .vben-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);
}
}
}
.vben-sub-menu-content {
height: var(--menu-item-height);
.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 {
.menu-title();
}
&.is-collapse-show-title {
flex-direction: column;
padding: 32px 0 !important;
// margin: 4px 8px !important;
.vben-menu__icon {
display: block;
font-size: 20px !important;
transition: all 0.25s ease;
}
.vben-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 "../../../typings";
interface NormalMenuProps {
/**
* 菜单数据
*/
activePath?: string;
/**
* 是否折叠
*/
collapse?: boolean;
/**
* 菜单项
*/
menus?: MenuRecordRaw[];
/**
* @zh_CN 是否圆润风格
* @default true
*/
rounded?: boolean;
/**
* 主题
*/
theme?: "dark" | "light";
}
export type { NormalMenuProps };
@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { MenuRecordRaw } from "../../../typings";
import type { NormalMenuProps } from "./normal-menu";
import { useNamespace } from "../../../composables";
import { VbenIcon } from "../../../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="less" scoped>
.vben-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 {
.vben-normal-menu__item {
@apply text-foreground/80;
// color: hsl(var(--foreground) / 80%);
&:not(.is-active):hover {
@apply text-foreground;
}
&.is-active {
.vben-normal-menu__name,
.vben-normal-menu__icon {
@apply text-foreground;
}
}
}
}
&.is-collapse {
.vben-normal-menu__name {
width: 0;
height: 0;
margin-top: 0;
overflow: hidden;
opacity: 0;
}
.vben-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;
.vben-normal-menu__name,
.vben-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 {
.vben-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,77 @@
<script lang="ts" setup>
import type { MenuItemProps } from "../types";
import { computed } from "vue";
import { useNamespace } from "../../composables";
import { ChevronDown, ChevronRight } from "../../icons";
import { VbenIcon } from "../../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>
@@ -0,0 +1,208 @@
<script lang="ts" setup>
import type { HoverCardContentProps } from "../../shadcn-ui";
import type { MenuItemRegistered, MenuProvider, SubMenuProps } from "../types";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useNamespace } from "../../composables";
import { VbenHoverCard } from "../../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']" :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>
@@ -0,0 +1,2 @@
export * from './use-menu';
export * from './use-menu-context';
@@ -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,
};
@@ -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 };
@@ -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";
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '/@/vben/typings';
import type { MenuProps } from './types';
import { useForwardProps } from '/@/vben/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,
// theme: 'dark',
});
const forward = useForwardProps(props);
// const emit = defineEmits<{
// 'update:openKeys': [key: Key[]];
// 'update:selectedKeys': [key: Key[]];
// }>();
</script>
<template>
<Menu v-bind="forward">
<template v-for="menu in menus" :key="menu.path">
<SubMenu :menu="menu" />
</template>
</Menu>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '/@/vben/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>
@@ -0,0 +1,139 @@
import type { Component, Ref } from 'vue';
import type { MenuRecordBadgeRaw, ThemeModeType } from '/@/vben/typings';
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 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,
};
@@ -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 };