<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, provide, ref, useSlots, watch } from 'vue'
import type { VNode, VNodeChild } from 'vue'
import { FLOATING_LAYER_Z_INDEX, injectFromChain, siderCollapsedKey, useControlledModel } from 'components/utils'
import MenuNodes from './MenuNodes'
import { getItemKey, menuContextKey } from './context'
import type { MenuContext } from './context'
import { splitOverflowItems } from './overflow'
import { parseSlotItems } from './slotItems'
import type {
ItemType,
MenuExpandIconInfo,
MenuInfo,
MenuKey,
MenuMode,
MenuSelectInfo,
MenuTheme,
SubMenuType
} from './interface'
/**
* 导航菜单
*
* 以 `items` 配置描述菜单结构:`children` 表示子菜单、`type: 'group'` 表示分组、`type: 'divider'` 表示分割线。
* 展开与选中状态受控(`v-model:openKeys` / `v-model:selectedKeys`)与非受控均可使用。
*/
export interface Props {
// 双向绑定
openKeys?: MenuKey[] // 当前展开的子菜单 key 数组
selectedKeys?: MenuKey[] // 当前选中的菜单项 key 数组
// 内容数据
items?: ItemType[] // 菜单内容
// 形态外观
mode?: MenuMode // 菜单类型
theme?: MenuTheme // 主题颜色
inlineCollapsed?: boolean // inline 模式下是否收起(收起时子菜单改为浮层展开);外层为 LayoutSider 时以其收起态为准
inlineIndent?: number // inline 模式每一级菜单项的缩进宽度
expandIcon?: (info: MenuExpandIconInfo) => VNodeChild // 自定义子菜单的展开收起图标
// 状态反馈
disabled?: boolean // 是否禁用整个菜单
selectable?: boolean // 是否允许选中
multiple?: boolean // 是否允许多选
// 行为交互
triggerSubMenuAction?: 'click' | 'hover' // 子菜单的展开触发方式
subMenuOpenDelay?: number // 鼠标进入子菜单后开启的延时,单位秒
subMenuCloseDelay?: number // 鼠标离开子菜单后关闭的延时,单位秒
forceSubMenuRender?: boolean // 是否在子菜单展示之前就渲染进 DOM(首次展开因而无需等待挂载)
}
export interface MenuSlots {
default?: () => VNode[] // 子组件式菜单内容(MenuItem / MenuSubMenu / MenuItemGroup / MenuDivider)
expandIcon?: (info: MenuExpandIconInfo) => VNode[]
overflowedIndicator?: () => VNode[] // 水平空间不足时收进省略子菜单的指示器,缺省为内置三点图标
}
const props = withDefaults(defineProps<Props>(), {
openKeys: undefined,
selectedKeys: undefined,
items: () => [],
mode: 'vertical',
theme: 'light',
inlineCollapsed: false,
inlineIndent: 24,
expandIcon: undefined,
disabled: false,
selectable: true,
multiple: false,
triggerSubMenuAction: 'hover',
subMenuOpenDelay: 0,
subMenuCloseDelay: 0.1,
forceSubMenuRender: false
})
defineSlots<MenuSlots>()
const emit = defineEmits<{
click: [info: MenuInfo] // 点击菜单项时调用
deselect: [info: MenuSelectInfo] // 取消选中时调用,仅在 multiple 生效
openChange: [openKeys: MenuKey[]] // 子菜单展开 / 收起时调用
select: [info: MenuSelectInfo] // 被选中时调用
'update:openKeys': [openKeys: MenuKey[]] // 展开的子菜单 key 数组变化
'update:selectedKeys': [selectedKeys: MenuKey[]] // 选中的菜单项 key 数组变化
}>()
const EMPTY_KEYS: MenuKey[] = [] // 稳定的空数组:避免每次渲染都产生新引用而触发子组件更新
// 显式标注插槽类型:`useSlots()` 的返回值参与 `menuItems` / `displayItems` 的推导,构成推断环
// (构建期的 dts 程序会报 TS7022 / TS7024),标注后即断开;类型与 `defineSlots<MenuSlots>()` 一致
const slots: MenuSlots = useSlots()
// 强调色与选中底色:皮肤 CSS 直接消费公开令牌 `--va-menu-primary-*`(未配置时回落到全局主色)。
// 组件级主色由 ConfigProvider 写在作用域节点上;浮层面板经 Teleport 脱离作用域时,
// Provider 的边界转发会把这些令牌一并带到面板根节点,因此不再需要把私有变量随节点下发。
// (v-model:openKeys) 展开的子菜单 key 集合:传值即受控,未传时组件自持
// (v-model:selectedKeys) 选中的菜单项 key 集合:传值即受控,未传时组件自持
// 注:两个模型读到的都可能是父级数组本体,写入必须整体替换(禁止 push / splice)
const openKeysModel = useControlledModel<MenuKey[]>('openKeys', [])
const selectedKeysModel = useControlledModel<MenuKey[]>('selectedKeys', [])
// 当前激活路径:悬浮或焦点所在项的完整 key 链(含祖先)。子菜单浮层经 Teleport 脱离 DOM 树,
// 浮层内的项被悬浮时父级标题无法用 `:hover` 感知,故统一由这条路径驱动高亮
const activeKeys = ref<MenuKey[]>([])
function changeActiveKeys(keys: MenuKey[]): void {
activeKeys.value = keys
}
// 模式归一:inline 收起后菜单退化为 vertical(子菜单改用浮层承载),样式与布局随之为同一套
// 外层侧边栏下发的收起态优先:此时菜单宽度由侧边栏决定,自身 inlineCollapsed 不再参与
const siderCollapsed = injectFromChain(siderCollapsedKey) ?? null
const collapsed = computed(() => Boolean(siderCollapsed?.value ?? props.inlineCollapsed))
const mergedInlineCollapsed = computed(() => (props.mode === 'inline' || props.mode === 'vertical') && collapsed.value)
const mergedMode = computed<MenuMode>(() => (mergedInlineCollapsed.value ? 'vertical' : props.mode))
const isInlineMode = computed(() => mergedMode.value === 'inline')
const theme = computed(() => props.theme)
// 菜单内容:组件式写法(default 插槽)存在时以插槽为准,否则取 items(项目统一约定:插槽优先于 prop)
// 子组件只承载配置描述,这里读回为与 items 同构的配置树,两条数据源因此共用后续全部逻辑
const menuItems = computed<ItemType[]>(() => (slots.default ? parseSlotItems(slots.default()) : props.items))
// 水平溢出:放不下的菜单项收进「...」子菜单。起点由内核测量后回传,切分后的配置树参与
// 渲染与下方索引,故溢出子菜单对 keyPath、父级高亮、后代收起也与用户配置等价
const overflowStart = ref<number>(Number.POSITIVE_INFINITY)
const displayItems = computed<ItemType[]>(() =>
props.mode === 'horizontal'
? splitOverflowItems(menuItems.value, overflowStart.value, slots.overflowedIndicator)
: menuItems.value
)
// 结构索引:key → 祖先链与是否为子菜单,供 keyPath、父级高亮、后代收起使用
// 索引建立在切分后的配置树上:溢出子菜单合成于根组件,其子项因此与平铺时同源
const keyMetaMap = computed(() => {
const map = new Map<MenuKey, { parents: MenuKey[]; isSubMenu: boolean }>()
const walk = (nodes: ItemType[], parents: MenuKey[]): void => {
nodes.forEach((node, index) => {
if (!node || ('type' in node && node.type === 'divider')) {
return
}
if ('type' in node && node.type === 'group') {
walk(node.children ?? [], parents)
return
}
const isSubMenu = 'children' in node && Array.isArray(node.children)
// key 的取法与渲染内核同源(`getItemKey`):配置未提供 key 时同样按下标兜底,
// 否则本索引存下的 key 与内核渲染出的 key 对不上,父级高亮与后代收起都会失准
const key = getItemKey(node, index)
map.set(key, { parents, isSubMenu })
if (isSubMenu) {
walk((node as SubMenuType).children, [...parents, key])
}
})
}
walk(displayItems.value, [])
return map
})
// 含选中项的子菜单整链高亮:父级被选中态需要它(内嵌与浮层两种形态共用)
const selectedSubMenuKeys = computed(() => {
const keys = new Set<MenuKey>()
selectedKeysModel.value.forEach((key) => {
keyMetaMap.value.get(key)?.parents.forEach((parent) => keys.add(parent))
})
return [...keys]
})
function isSameKeys(current: MenuKey[], next: MenuKey[]): boolean {
return current.length === next.length && next.every((key) => current.includes(key))
}
// 后代 key:浮层型子菜单关闭时须一并收起,否则后代留在展开集合中形成「幽灵展开」
function collectDescendantKeys(key: MenuKey): MenuKey[] {
const keys: MenuKey[] = []
keyMetaMap.value.forEach((meta, itemKey) => {
if (meta.parents.includes(key)) {
keys.push(itemKey)
}
})
return keys
}
function triggerOpenKeys(keys: MenuKey[]): void {
openKeysModel.value = keys
emit('openChange', keys)
}
function handleOpenChange(key: MenuKey, open: boolean): void {
let keys = openKeysModel.value.filter((item) => item !== key)
if (open) {
keys.push(key)
} else if (!isInlineMode.value) {
const descendants = collectDescendantKeys(key)
keys = keys.filter((item) => !descendants.includes(item))
}
if (!isSameKeys(keys, openKeysModel.value)) {
triggerOpenKeys(keys)
}
}
function handleItemClick(info: MenuInfo): void {
emit('click', info)
if (props.selectable) {
const { key } = info
const existed = selectedKeysModel.value.includes(key)
const nextKeys = props.multiple
? existed
? selectedKeysModel.value.filter((item) => item !== key)
: [...selectedKeysModel.value, key]
: [key]
if (!isSameKeys(nextKeys, selectedKeysModel.value)) {
// 写模型即完成两种路径:受控时只回传(等外部更新后生效),非受控直接生效
selectedKeysModel.value = nextKeys
const menuSelectInfo: MenuSelectInfo = { ...info, selectedKeys: nextKeys }
if (existed && props.multiple) {
emit('deselect', menuSelectInfo)
} else {
emit('select', menuSelectInfo)
}
}
}
// 浮层型子菜单在选中后收起;多选不收起,便于连续勾选
if (!isInlineMode.value && !props.multiple && openKeysModel.value.length) {
triggerOpenKeys([])
}
}
function handleTitleClick(item: SubMenuType, domEvent: MouseEvent): void {
item.onTitleClick?.({ key: item.key, domEvent })
if (isInlineMode.value) {
handleOpenChange(item.key, !openKeysModel.value.includes(item.key))
} else if (props.triggerSubMenuAction === 'click') {
// 点击触发时同级只保留一个展开项,与「点击外部即收起」的语义一致
triggerOpenKeys(openKeysModel.value.includes(item.key) ? [] : [item.key])
}
}
// 展开集合在 inline 与浮层形态之间不可直接沿用(浮层态无处表达「内嵌展开」),故缓存后还原
const cachedOpenKeys = ref<MenuKey[]>([...openKeysModel.value])
let modeInitialized = false
watch(
openKeysModel,
(keys) => {
if (isInlineMode.value) {
cachedOpenKeys.value = [...keys]
}
},
{ immediate: true }
)
// 收起瞬间保留内嵌渲染:子菜单列表的收起动画要一个动画时长才播完,若当即换成浮层形态,
// 列表会整块消失、收起过程少一段「内容收拢」的动作(时长与样式表里的折叠过渡同源)
const INLINE_COLLAPSE_DURATION = 200
const retainingInline = ref(false)
let retainInlineTimer: ReturnType<typeof setTimeout> | undefined
function setRetainingInline(retaining: boolean): void {
if (retainInlineTimer !== undefined) {
clearTimeout(retainInlineTimer)
retainInlineTimer = undefined
}
retainingInline.value = retaining
if (retaining) {
retainInlineTimer = setTimeout(() => {
retainingInline.value = false
retainInlineTimer = undefined
}, INLINE_COLLAPSE_DURATION)
}
}
watch(
isInlineMode,
(inline) => {
if (!modeInitialized) {
modeInitialized = true
return
}
if (inline) {
setRetainingInline(false)
openKeysModel.value = [...cachedOpenKeys.value]
} else if (openKeysModel.value.length) {
setRetainingInline(true)
triggerOpenKeys([])
}
},
{ immediate: true }
)
// 展开图标:插槽优先于属性(项目统一约定);图标类名由渲染内核统一补,与子菜单级 expandIcon 同源
const expandIcon = computed<MenuContext['expandIcon']['value']>(() => {
const fromSlot = slots.expandIcon
const fromProp = props.expandIcon
if (!fromSlot && !fromProp) {
return undefined
}
return (info: MenuExpandIconInfo): VNodeChild => (fromSlot ? fromSlot(info) : fromProp?.(info))
})
// 点击触发模式下,浮层需在点击菜单外部时收起
function handleDocumentMouseDown(event: MouseEvent): void {
if (props.triggerSubMenuAction !== 'click' || isInlineMode.value || !openKeysModel.value.length) {
return
}
const target = event.target as HTMLElement | null
if (target?.closest('.menu-wrap') || target?.closest('.menu-submenu-popup')) {
return
}
triggerOpenKeys([])
}
onMounted(() => {
// 文档级监听在挂载后挂接:SSR 环境下没有 document
if (typeof document !== 'undefined') {
document.addEventListener('mousedown', handleDocumentMouseDown, true)
}
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('mousedown', handleDocumentMouseDown, true)
}
if (retainInlineTimer !== undefined) {
clearTimeout(retainInlineTimer)
}
})
provide<MenuContext>(menuContextKey, {
mode: mergedMode,
theme,
inlineCollapsed: mergedInlineCollapsed,
retainingInline,
inlineIndent: computed(() => props.inlineIndent),
disabled: computed(() => props.disabled),
triggerSubMenuAction: computed(() => props.triggerSubMenuAction),
subMenuOpenDelay: computed(() => props.subMenuOpenDelay),
subMenuCloseDelay: computed(() => props.subMenuCloseDelay),
openKeys: openKeysModel,
selectedKeys: selectedKeysModel,
activeKeys,
changeActiveKeys,
overflowStart,
onOverflowChange: (start: number) => {
overflowStart.value = start
},
overflowedIndicator: computed(() => slots.overflowedIndicator),
forceSubMenuRender: computed(() => props.forceSubMenuRender),
selectedSubMenuKeys,
subMenuZIndex: FLOATING_LAYER_Z_INDEX.select,
expandIcon,
onOpenChange: handleOpenChange,
onItemClick: handleItemClick,
onTitleClick: handleTitleClick
})
</script>
<template>
<MenuNodes :nodes="displayItems" is-root :level="1" :parent-keys="EMPTY_KEYS" />
</template>
<style lang="less">
/**
* 样式统一落全局块:整棵菜单树由内部渲染内核(`MenuNodes`)递归生成,其元素不带本 SFC 的
* scope id(与 `<Popup>` 宿主编译的面板壳同理,见 `Tooltip` 的全局块说明),scoped 规则匹配不到。
* 为免 `menu-*` 裸类名外溢到宿主页面,除主题变量外,其余规则一律以 `.menu-wrap`(根列表)
* 与 `.menu-submenu-popup` / `.menu-tooltip-popup`(浮层面板)三个锚点收口。
*/
/* 主题变量:`<ul>` 与浮层面板都会带上主题类,故两处都能取到值。
主色直接消费公开令牌 `--va-menu-primary-*`:ConfigProvider 写在作用域节点上,浮层由边界转发带入,
因此不需要在组件内声明私有主色变量 */
.menu-light {
--menu-background: var(--va-bg-container-color);
/* 弹出子菜单是浮层:暗色下取 elevated,避免与页面容器同色 */
--menu-popup-background: var(--va-bg-elevated-color);
--menu-color: var(--va-text-color-1);
--menu-border-color: var(--va-border-split-color);
/* 悬浮态只换底色、不换字色(字色与常态一致) */
--menu-item-hover-color: var(--va-text-color-1);
--menu-item-hover-background: var(--va-fill-color-2);
/* 选中项是「主色文字」:优先跟随组件级主色文字桥,未配置时回落 primary-text 语义位 */
--menu-item-selected-color: var(--va-menu-primary-text-color, var(--va-primary-text-color));
/* 选中态底色取主色的最浅一阶:组件级主色浅底桥,未配置时回落主色浅底 */
--menu-item-selected-background: var(--va-menu-primary-palette, var(--va-primary-bg-color));
/* 水平菜单:浅色由下划线表达选中,故不着底色 */
--menu-horizontal-selected-background: transparent;
/* 水平菜单状态条(下划线)粗细 */
--menu-horizontal-active-bar-height: 2px;
/* 水平菜单容器下边界的厚度:浅色有一条边界,水平项需上移同量,状态条才压在边界上 */
--menu-horizontal-active-bar-border-size: 1px;
--menu-item-danger-color: var(--va-error-color);
/* 危险项的悬浮字色:浅色下与常态同色 */
--menu-item-danger-hover-color: var(--va-error-color);
/* 危险项按下 / 选中时的底色:错误色的最浅一阶 */
--menu-item-danger-active-background: var(--va-error-bg-color);
--menu-item-danger-selected-background: var(--va-error-bg-color);
--menu-item-danger-selected-color: var(--va-error-color);
--menu-item-disabled-color: var(--va-text-color-disabled);
--menu-group-title-color: var(--va-text-color-3);
--menu-inline-submenu-background: var(--va-fill-color-1);
}
.menu-dark {
--menu-background: var(--va-cp-menu-dark-bg-color, var(--va-bg-brand-dark-color, #001529));
--menu-popup-background: var(--va-cp-menu-dark-bg-color, var(--va-bg-brand-dark-color, #001529));
--menu-color: rgba(255, 255, 255, 0.65);
--menu-border-color: rgba(255, 255, 255, 0.12);
--menu-item-hover-color: #fff;
--menu-item-hover-background: rgba(255, 255, 255, 0.08);
--menu-item-selected-color: #fff;
--menu-item-selected-background: var(--va-menu-primary-color, var(--va-primary-color));
--menu-horizontal-selected-background: var(--va-menu-primary-color, var(--va-primary-color));
--menu-horizontal-active-bar-height: 0;
--menu-horizontal-active-bar-border-size: 0px;
--menu-item-danger-color: var(--va-error-color);
--menu-item-danger-hover-color: var(--va-error-color);
--menu-item-danger-active-background: var(--va-error-color);
--menu-item-danger-selected-background: var(--va-error-color);
--menu-item-disabled-color: rgba(255, 255, 255, 0.25);
--menu-group-title-color: rgba(255, 255, 255, 0.65);
--menu-inline-submenu-background: var(--va-cp-menu-dark-bg-deep-color, var(--va-bg-brand-dark-deep-color, #000c17));
}
.menu-wrap {
box-sizing: border-box;
margin: 0;
padding: 0;
font-size: 14px;
line-height: 1.5714285714285714;
color: var(--menu-color);
background-color: var(--va-menu-background, var(--menu-background));
list-style: none;
outline: none;
transition: width 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.menu-wrap.menu-root {
margin: 0;
padding: 0;
list-style: none;
}
.menu-wrap.menu-horizontal {
display: flex;
flex-wrap: nowrap;
line-height: 46px;
border-bottom: 1px solid var(--menu-border-color);
}
.menu-wrap.menu-vertical,
.menu-wrap.menu-inline {
width: 100%;
}
.menu-wrap.menu-light.menu-vertical,
.menu-wrap.menu-light.menu-inline {
border-right: 1px solid var(--menu-border-color);
}
.menu-wrap.menu-vertical,
.menu-wrap.menu-inline,
.menu-submenu-list {
&::before,
&::after {
display: table;
content: '';
}
&::after {
clear: both;
}
}
.menu-submenu-popup {
min-width: 160px;
font-size: 14px;
line-height: 1.5714285714285714;
color: var(--menu-color);
background-color: var(--va-menu-popup-background, var(--menu-popup-background));
border-radius: 8px;
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 var(--va-shadow-color),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.menu-tooltip-popup {
--menu-tooltip-background: var(--va-bg-spotlight-color);
width: max-content;
pointer-events: none;
&.va-popup-placement-right,
&.va-popup-placement-rightTop,
&.va-popup-placement-rightBottom {
padding-left: 12px;
}
&.va-popup-placement-left,
&.va-popup-placement-leftTop,
&.va-popup-placement-leftBottom {
padding-right: 12px;
}
.menu-tooltip-card {
max-width: 250px;
min-height: 32px;
padding: 6px 8px;
font-size: 14px;
line-height: 1.5714285714285714;
color: #fff;
word-break: break-word;
background-color: var(--menu-tooltip-background);
border-radius: 6px;
box-shadow:
0 6px 16px 0 var(--va-shadow-color),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.menu-tooltip-arrow {
position: absolute;
z-index: 9;
display: block;
pointer-events: none;
width: 16px;
height: 16px;
overflow: hidden;
transition: top 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&::before {
position: absolute;
bottom: 0;
left: 0;
width: 16px;
height: 8px;
background-color: var(--menu-tooltip-background);
clip-path: path(
'M 0 8 A 4 4 0 0 0 2.82842712474619 6.82842712474619 L 6.585786437626905 3.0710678118654755 A 2 2 0 0 1 9.414213562373096 3.0710678118654755 L 13.17157287525381 6.82842712474619 A 4 4 0 0 0 16 8 Z'
);
content: '';
}
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 8.970562748477143px;
height: 8.970562748477143px;
margin: auto;
border-radius: 0 0 2px 0;
transform: translateY(50%) rotate(-135deg);
box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.1);
z-index: -1;
background: transparent;
content: '';
}
}
&.va-popup-placement-right .menu-tooltip-arrow,
&.va-popup-placement-rightTop .menu-tooltip-arrow,
&.va-popup-placement-rightBottom .menu-tooltip-arrow {
top: calc(50% - 8px);
left: 12px;
transform: translateX(-100%) rotate(-90deg);
}
&.va-popup-placement-left .menu-tooltip-arrow,
&.va-popup-placement-leftTop .menu-tooltip-arrow,
&.va-popup-placement-leftBottom .menu-tooltip-arrow {
top: calc(50% - 8px);
right: 12px;
transform: translateX(100%) rotate(90deg);
}
&.va-popup-placement-rightTop .menu-tooltip-arrow {
top: 12px;
transform: translateX(-100%) rotate(-90deg);
}
&.va-popup-placement-rightBottom .menu-tooltip-arrow {
top: calc(100% - 28px);
transform: translateX(-100%) rotate(-90deg);
}
&.va-popup-placement-leftTop .menu-tooltip-arrow {
top: 12px;
transform: translateX(100%) rotate(90deg);
}
&.va-popup-placement-leftBottom .menu-tooltip-arrow {
top: calc(100% - 28px);
transform: translateX(100%) rotate(90deg);
}
}
.menu-wrap,
.menu-submenu-popup {
.menu-submenu-list,
.menu-item-group-list {
margin: 0;
padding: 0;
list-style: none;
}
.menu-submenu-list {
transition:
background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.menu-submenu,
.menu-item-group {
margin: 0;
}
.menu-item-group-list {
.menu-item,
.menu-submenu-title {
padding-left: 2em;
}
}
.menu-item {
position: relative;
display: block;
box-sizing: border-box;
width: calc(100% - 8px);
height: 40px;
margin: 4px;
padding: 0 16px;
line-height: 40px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--menu-color);
border-radius: 8px;
cursor: pointer;
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
&:hover,
&.menu-item-active {
color: var(--menu-item-hover-color);
background-color: var(--menu-item-hover-background);
}
&.menu-item-selected {
color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
background-color: var(--va-menu-item-selected-background, var(--menu-item-selected-background));
}
&.menu-item-disabled {
color: var(--menu-item-disabled-color);
background-color: transparent;
cursor: not-allowed;
&:hover {
color: var(--menu-item-disabled-color);
background-color: transparent;
}
}
}
.menu-item,
.menu-submenu-title {
a,
a:hover {
color: inherit;
text-decoration: none;
}
a::before {
position: absolute;
inset: 0;
background-color: transparent;
content: '';
}
}
.menu-item-danger {
color: var(--va-error-color);
}
.menu-item-danger:not(.menu-item-selected):not(.menu-item-disabled):hover,
.menu-item-danger:not(.menu-item-selected):not(.menu-item-disabled).menu-item-active {
color: var(--menu-item-danger-hover-color);
}
.menu-item-danger.menu-item-selected:not(.menu-item-disabled) {
color: var(--menu-item-danger-selected-color);
background-color: var(--menu-item-danger-selected-background);
}
.menu-item-group-title {
padding: 8px 16px;
font-size: 14px;
line-height: 1.5714285714285714;
color: var(--menu-group-title-color);
transition: all 0.3s;
}
.menu-item-divider {
margin: 1px 0;
overflow: hidden;
line-height: 0;
border-bottom: 1px solid var(--menu-border-color);
&.menu-item-divider-dashed {
border-bottom-style: dashed;
}
}
.menu-title-content {
transition: color 0.3s;
}
.menu-item-icon {
display: inline-flex;
align-items: center;
min-width: 14px;
font-size: 14px;
vertical-align: -0.125em;
transition:
font-size 0.2s cubic-bezier(0.215, 0.61, 0.355, 1),
margin 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
color 0.3s;
+ .menu-title-content {
margin-left: 10px;
opacity: 1;
transition:
opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
margin 0.3s,
color 0.3s;
}
}
.menu-submenu {
transition:
border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
padding 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&::after {
display: table;
content: '';
}
}
.menu-submenu-title {
position: relative;
display: block;
box-sizing: border-box;
width: calc(100% - 8px);
height: 40px;
margin: 4px;
padding: 0 34px 0 16px;
line-height: 40px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--menu-color);
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.3s,
border-color 0.3s,
padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
&:hover {
color: var(--menu-item-hover-color);
background-color: var(--menu-item-hover-background);
}
}
.menu-submenu-selected > .menu-submenu-title {
color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
}
.menu-submenu:not(.menu-submenu-disabled) > .menu-submenu-title:hover {
color: var(--menu-item-hover-color);
background-color: var(--menu-item-hover-background);
}
.menu-submenu-disabled > .menu-submenu-title {
color: var(--menu-item-disabled-color);
cursor: not-allowed;
}
.menu-submenu-arrow {
position: absolute;
top: 50%;
right: 16px;
width: 10px;
color: currentColor;
transform: translateY(-50%);
transition:
transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
opacity 0.3s;
&::before,
&::after {
position: absolute;
width: 6px;
height: 1.5px;
background-color: currentColor;
border-radius: 6px;
transition:
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
top 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
content: '';
}
/* 默认指向右侧:纵向菜单与浮层内的子菜单共用 */
&::before {
transform: rotate(45deg) translateY(-2.5px);
}
&::after {
transform: rotate(-45deg) translateY(2.5px);
}
}
}
.menu-wrap:not(.menu-horizontal),
.menu-submenu-popup {
.menu-item:not(.menu-item-selected):not(.menu-item-disabled):active {
background-color: var(--va-menu-item-selected-background, var(--menu-item-selected-background));
}
.menu-item-danger.menu-item:not(.menu-item-selected):not(.menu-item-disabled):active {
background-color: var(--menu-item-danger-active-background);
}
.menu-submenu:not(.menu-submenu-disabled) > .menu-submenu-title:active {
background-color: var(--va-menu-item-selected-background, var(--menu-item-selected-background));
}
}
.menu-wrap.menu-vertical .menu-submenu-active:not(.menu-submenu-disabled) > .menu-submenu-title,
.menu-submenu-popup .menu-submenu-active:not(.menu-submenu-disabled) > .menu-submenu-title {
color: var(--menu-item-hover-color);
background-color: var(--menu-item-hover-background);
}
.menu-wrap.menu-inline {
.menu-item,
.menu-submenu-title {
display: flex;
align-items: center;
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
padding 0.2s cubic-bezier(0.215, 0.61, 0.355, 1);
> .menu-title-content {
flex: auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
> * {
flex: none;
}
}
}
.menu-wrap .menu-item.menu-item-selected,
.menu-submenu-popup .menu-item.menu-item-selected {
transition:
color 0.3s,
border-color 0.3s,
padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.menu-wrap.menu-inline .menu-item.menu-item-selected {
transition:
color 0.3s,
border-color 0.3s,
padding 0.2s cubic-bezier(0.215, 0.61, 0.355, 1);
}
.menu-wrap.menu-inline .menu-submenu-list {
background-color: var(--va-menu-inline-submenu-background, var(--menu-inline-submenu-background));
}
.menu-wrap.menu-inline .menu-submenu .menu-submenu-arrow {
&::before {
transform: rotate(-45deg) translateX(2.5px);
}
&::after {
transform: rotate(45deg) translateX(-2.5px);
}
}
.menu-wrap.menu-inline .menu-submenu-open > .menu-submenu-title > .menu-submenu-arrow {
transform: translateY(-2px);
&::before {
transform: rotate(45deg) translateX(2.5px);
}
&::after {
transform: rotate(-45deg) translateX(-2.5px);
}
}
.menu-submenu-popup {
.menu-item,
.menu-submenu-title {
border-radius: 4px;
}
}
.menu-wrap.menu-horizontal {
> .menu-item,
> .menu-submenu {
position: relative;
flex: none;
height: auto;
margin: calc(-1 * var(--menu-horizontal-active-bar-border-size)) 0 0;
padding: 0 16px;
line-height: inherit;
width: auto;
border-radius: 0;
overflow: visible;
&::after {
position: absolute;
display: block;
right: 16px;
bottom: 0;
left: 16px;
border-bottom: var(--menu-horizontal-active-bar-height) solid transparent;
transition: border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
content: '';
}
}
> .menu-item {
&:hover::after,
&.menu-item-active::after,
&.menu-item-selected::after {
border-bottom-color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
}
}
> .menu-submenu {
&:hover::after,
&.menu-submenu-active::after,
&.menu-submenu-open::after,
&.menu-submenu-selected::after {
border-bottom-color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
}
}
> .menu-item:hover,
> .menu-item-active,
> .menu-submenu > .menu-submenu-title:hover,
> .menu-submenu-active > .menu-submenu-title {
background-color: transparent;
}
> .menu-item.menu-item-selected,
> .menu-submenu.menu-submenu-selected {
background-color: var(--menu-horizontal-selected-background);
}
> .menu-submenu > .menu-submenu-title {
height: 100%;
margin: 0;
padding: 0;
line-height: inherit;
width: auto;
overflow: visible;
}
.menu-submenu-arrow {
display: none;
}
}
.menu-wrap.menu-dark.menu-horizontal {
border-bottom: 0;
}
.menu-wrap.menu-horizontal > .menu-overflow-probe {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
pointer-events: none;
}
.menu-overflow-indicator {
display: inline-block;
vertical-align: -0.125em;
}
.menu-wrap.menu-inline-collapsed {
width: var(--layout-sider-width, 80px);
> .menu-item,
> .menu-item-group > .menu-item-group-list > .menu-item,
> .menu-item-group > .menu-item-group-list > .menu-submenu > .menu-submenu-title,
> .menu-submenu > .menu-submenu-title {
padding: 0 calc(50% - 12px);
text-overflow: clip;
.menu-submenu-arrow,
.menu-submenu-expand-icon {
opacity: 0;
}
.menu-item-icon {
margin: 0;
font-size: 16px;
line-height: 40px;
+ .menu-title-content {
display: inline-block;
opacity: 0;
}
}
}
.menu-inline-collapsed-noicon {
display: inline-block;
font-size: 16px;
line-height: 40px;
}
.menu-item-group-title {
padding-inline: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.menu-wrap .menu-submenu-list.menu-motion-collapse-enter-active,
.menu-wrap .menu-submenu-list.menu-motion-collapse-leave-active {
overflow: hidden;
transition:
height 0.2s cubic-bezier(0.645, 0.045, 0.355, 1),
opacity 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.menu-zoom-enter,
.menu-zoom-leave {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
}
.menu-zoom-fast-enter,
.menu-zoom-fast-leave {
animation-duration: 0.1s;
animation-fill-mode: both;
animation-play-state: paused;
}
.menu-zoom-fast-enter {
scale: none;
opacity: 0;
animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
}
.menu-zoom-fast-enter-active {
animation-name: menu-zoom-in;
animation-play-state: running;
}
.menu-zoom-fast-leave {
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.menu-zoom-fast-leave-active {
animation-name: menu-zoom-out;
animation-play-state: running;
}
.menu-slide-enter,
.menu-slide-leave {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
}
.menu-slide-enter {
scale: 1 0.8;
opacity: 0;
animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.menu-slide-enter-active {
animation-name: menu-slide-in;
animation-play-state: running;
}
.menu-slide-leave {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
}
.menu-slide-leave-active {
animation-name: menu-slide-out;
animation-play-state: running;
}
@keyframes menu-slide-in {
0% {
scale: 1 0.8;
opacity: 0;
}
100% {
scale: 1;
opacity: 1;
}
}
@keyframes menu-slide-out {
0% {
scale: 1;
opacity: 1;
}
100% {
scale: 1 0.8;
opacity: 0;
}
}
.menu-zoom-enter {
scale: none;
opacity: 0;
animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
}
.menu-zoom-enter-active {
animation-name: menu-zoom-in;
animation-play-state: running;
}
.menu-zoom-leave {
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.menu-zoom-leave-active {
animation-name: menu-zoom-out;
animation-play-state: running;
}
@keyframes menu-zoom-in {
0% {
scale: 0.8;
opacity: 0;
}
100% {
scale: 1;
opacity: 1;
}
}
@keyframes menu-zoom-out {
0% {
scale: 1;
opacity: 1;
}
100% {
scale: 0.8;
opacity: 0;
}
}
</style>
<script setup lang="ts">
import Menu from './Menu.vue'
import { computed, h, ref } from 'vue'
import {
AppstoreOutlined,
CalendarOutlined,
DesktopOutlined,
DownOutlined,
InboxOutlined,
MailOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MinusOutlined,
PieChartOutlined,
PlusOutlined,
RightOutlined,
SettingOutlined
} from '@ant-design/icons-vue'
import type { ItemType, MenuExpandIconInfo, MenuIcon, MenuKey } from 'vue-amazing-ui'
// 演示数据构造:图标以渲染函数给出
function getItem(label: string, key: string, icon?: MenuIcon, children?: ItemType[], type?: 'group'): ItemType {
return { key, icon, children, label, type } as ItemType
}
// 顶部导航
const horizontalSelected = ref<MenuKey[]>(['mail'])
const horizontalItems: ItemType[] = [
{ key: 'mail', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
{ key: 'app', icon: () => h(AppstoreOutlined), label: 'Navigation Two', title: 'Navigation Two' },
{
key: 'sub1',
icon: () => h(SettingOutlined),
label: 'Navigation Three - Submenu',
title: 'Navigation Three - Submenu',
children: [
getItem('Item 1', 'g1', undefined, [getItem('Option 1', 'setting:1'), getItem('Option 2', 'setting:2')], 'group'),
getItem('Item 2', 'g2', undefined, [getItem('Option 3', 'setting:3'), getItem('Option 4', 'setting:4')], 'group')
]
},
{ key: 'alipay', label: 'Navigation Four - Link', title: 'Navigation Four - Link' }
]
// 内嵌菜单
const inlineSelected = ref<MenuKey[]>(['1'])
const inlineOpenKeys = ref<MenuKey[]>(['sub1'])
const inlineItems: ItemType[] = [
getItem('Navigation One', 'sub1', () => h(MailOutlined), [
getItem('Item 1', 'g1', undefined, [getItem('Option 1', '1'), getItem('Option 2', '2')], 'group'),
getItem('Item 2', 'g2', undefined, [getItem('Option 3', '3'), getItem('Option 4', '4')], 'group')
]),
getItem('Navigation Two', 'sub2', () => h(AppstoreOutlined), [
getItem('Option 5', '5'),
getItem('Option 6', '6'),
getItem('Submenu', 'sub3', undefined, [getItem('Option 7', '7'), getItem('Option 8', '8')])
]),
{ type: 'divider' },
getItem('Navigation Three', 'sub4', () => h(SettingOutlined), [
getItem('Option 9', '9'),
getItem('Option 10', '10'),
getItem('Option 11', '11'),
getItem('Option 12', '12')
]),
getItem('Group', 'grp', undefined, [getItem('Option 13', '13'), getItem('Option 14', '14')], 'group')
]
// 垂直菜单
const verticalSelected = ref<MenuKey[]>(['1'])
const verticalOpenKeys = ref<MenuKey[]>(['sub1'])
const verticalItems: ItemType[] = [
{ key: '1', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
{ key: '2', icon: () => h(CalendarOutlined), label: 'Navigation Two', title: 'Navigation Two' },
{
key: 'sub1',
icon: () => h(AppstoreOutlined),
label: 'Navigation Three',
title: 'Navigation Three',
children: [
{ key: '3', label: 'Option 3', title: 'Option 3' },
{ key: '4', label: 'Option 4', title: 'Option 4' },
{
key: 'sub1-2',
label: 'Submenu',
title: 'Submenu',
children: [
{ key: '5', label: 'Option 5', title: 'Option 5' },
{ key: '6', label: 'Option 6', title: 'Option 6' }
]
}
]
},
{
key: 'sub2',
icon: () => h(SettingOutlined),
label: 'Navigation Four',
title: 'Navigation Four',
children: [
{ key: '7', label: 'Option 7', title: 'Option 7' },
{ key: '8', label: 'Option 8', title: 'Option 8' },
{ key: '9', label: 'Option 9', title: 'Option 9' },
{ key: '10', label: 'Option 10', title: 'Option 10' }
]
}
]
// 切换菜单类型
const switchMode = ref(false)
const switchTheme = ref(false)
const switchSelected = ref<MenuKey[]>(['1'])
const switchOpenKeys = ref<MenuKey[]>(['sub1'])
const switchItems: ItemType[] = [
getItem('Navigation One', '1', () => h(MailOutlined)),
getItem('Navigation Two', '2', () => h(CalendarOutlined)),
getItem('Navigation Three', 'sub1', () => h(AppstoreOutlined), [
getItem('Option 3', '3'),
getItem('Option 4', '4'),
getItem('Submenu', 'sub1-2', undefined, [getItem('Option 5', '5'), getItem('Option 6', '6')])
]),
getItem('Navigation Four', 'sub2', () => h(SettingOutlined), [
getItem('Option 7', '7'),
getItem('Option 8', '8'),
getItem('Option 9', '9'),
getItem('Option 10', '10')
])
]
// 子菜单展开触发方式与开合延时
const triggerHover = ref(true)
const triggerDelaySelected = ref<MenuKey[]>(['1'])
const triggerDelayItems: ItemType[] = [
getItem('Navigation One', '1', () => h(MailOutlined)),
getItem('Navigation Two', 'sub1', () => h(AppstoreOutlined), [
getItem('Option 1', '1-1'),
getItem('Option 2', '1-2')
]),
getItem('Navigation Three', 'sub2', () => h(SettingOutlined), [
getItem('Option 3', '2-1'),
getItem('Option 4', '2-2')
])
]
// 自定义缩进:同一份层级结构并排对比默认 24 与 32,两个实例各自持展开状态
const indentDefaultOpenKeys = ref<MenuKey[]>(['sub1', 'sub1-2'])
const indentWideOpenKeys = ref<MenuKey[]>(['sub1', 'sub1-2'])
const indentItems: ItemType[] = [
getItem('Navigation One', 'sub1', () => h(MailOutlined), [
getItem('Option 1', '1'),
getItem('Submenu', 'sub1-2', undefined, [getItem('Option 2', '2')])
]),
getItem('Navigation Two', '2', () => h(AppstoreOutlined))
]
// 缩起内嵌菜单
const collapsed = ref(false)
const collapsedSelected = ref<MenuKey[]>(['1'])
const collapsedOpenKeys = ref<MenuKey[]>(['sub1'])
const collapsedItems: ItemType[] = [
{ key: '1', icon: () => h(PieChartOutlined), label: 'Option 1', title: 'Option 1' },
{ key: '2', icon: () => h(DesktopOutlined), label: 'Option 2', title: 'Option 2' },
{ key: '3', icon: () => h(InboxOutlined), label: 'Option 3', title: 'Option 3' },
{
key: 'sub1',
icon: () => h(MailOutlined),
label: 'Navigation One',
title: 'Navigation One',
children: [
{ key: '5', label: 'Option 5', title: 'Option 5' },
{ key: '6', label: 'Option 6', title: 'Option 6' },
{ key: '7', label: 'Option 7', title: 'Option 7' },
{ key: '8', label: 'Option 8', title: 'Option 8' }
]
},
{
key: 'sub2',
icon: () => h(AppstoreOutlined),
label: 'Navigation Two',
title: 'Navigation Two',
children: [
{ key: '9', label: 'Option 9', title: 'Option 9' },
{ key: '10', label: 'Option 10', title: 'Option 10' },
{
key: 'sub3',
label: 'Submenu',
title: 'Submenu',
children: [
{ key: '11', label: 'Option 11', title: 'Option 11' },
{ key: '12', label: 'Option 12', title: 'Option 12' }
]
}
]
}
]
// 收起态长标题提示:标题长到换行 ⇒ 提示面板比菜单项更高
const longTitleSelected = ref<MenuKey[]>(['1'])
const longTitleItems: ItemType[] = [
{
key: '1',
icon: () => h(PieChartOutlined),
label: '很长的标题 One:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果',
title: '很长的标题 One:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果'
},
{
key: '2',
icon: () => h(DesktopOutlined),
label: '较长的标题 Two:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态',
title: '较长的标题 Two:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态'
},
{
key: '3',
icon: () => h(InboxOutlined),
label: '很长的标题 Three:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果',
title: '很长的标题 Three:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果'
},
{
key: '4',
icon: () => h(MailOutlined),
label: '较长的标题 Four:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态',
title: '较长的标题 Four:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态'
},
{
key: '5',
icon: () => h(AppstoreOutlined),
label: '很长的标题 Five:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果',
title: '很长的标题 Five:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果'
}
]
// 只展开当前父级菜单
const rootSubmenuKeys: MenuKey[] = ['sub1', 'sub2', 'sub4']
const parentOpenKeys = ref<MenuKey[]>(['sub1'])
const parentSelected = ref<MenuKey[]>([])
const parentItems: ItemType[] = [
getItem('Navigation One', 'sub1', () => h(MailOutlined), [
getItem('Option 1', '1'),
getItem('Option 2', '2'),
getItem('Option 3', '3'),
getItem('Option 4', '4')
]),
getItem('Navigation Two', 'sub2', () => h(AppstoreOutlined), [
getItem('Option 5', '5'),
getItem('Option 6', '6'),
getItem('Submenu', 'sub3', undefined, [getItem('Option 7', '7'), getItem('Option 8', '8')])
]),
getItem('Navigation Three', 'sub4', () => h(SettingOutlined), [
getItem('Option 9', '9'),
getItem('Option 10', '10'),
getItem('Option 11', '11'),
getItem('Option 12', '12')
])
]
// 展开集合受控:仅保留最新展开的顶层子菜单,其余一并收起
function onParentOpenChange(openKeys: MenuKey[]) {
const latestOpenKey = openKeys.find((key) => !parentOpenKeys.value.includes(key))
if (latestOpenKey === undefined || !rootSubmenuKeys.includes(latestOpenKey)) {
parentOpenKeys.value = openKeys
} else {
parentOpenKeys.value = [latestOpenKey]
}
}
// 多选
const multipleSelected = ref<MenuKey[]>(['1'])
const multipleItems: ItemType[] = [
{ key: '1', label: 'Option 1' },
{ key: '2', label: 'Option 2' },
{ key: '3', label: 'Option 3' },
{
key: 'sub1',
label: 'Navigation',
children: [
{ key: '4', label: 'Option 4' },
{ key: '5', label: 'Option 5' }
]
}
]
// 禁用
const disabledSelected = ref<MenuKey[]>(['1'])
const wholeDisabled = ref(false)
const disabledItems: ItemType[] = [
{ key: '1', label: 'Option 1' },
{ key: '2', label: 'Option 2(disabled)', disabled: true },
{ key: '3', label: 'Option 3(danger)', danger: true },
{ key: '4', label: 'Option 4', disabled: true }
]
// 空数据
const emptyItems: ItemType[] = []
// 自定义展开图标
const expandIconSelected = ref<MenuKey[]>(['1'])
const expandIconOpenKeys = ref<MenuKey[]>(['sub1'])
const expandIconItems: ItemType[] = [
getItem('Navigation One', 'sub1', () => h(MailOutlined), [
getItem('Option 1', '1'),
getItem('Option 2', '2'),
getItem('Submenu', 'sub2', undefined, [getItem('Option 3', '3'), getItem('Option 4', '4')])
]),
getItem('Navigation Two', 'sub3', () => h(AppstoreOutlined), [getItem('Option 5', '5'), getItem('Option 6', '6')])
]
// 属性形态的展开图标:与插槽形态等价,渲染函数入参含所在子菜单配置与 isOpen
const expandIconByProp = (info: MenuExpandIconInfo) =>
h(RightOutlined, { class: ['demo-expand-icon', { 'demo-expand-icon-open': info.isOpen }] })
const expandIconPropOpenKeys = ref<MenuKey[]>(['sub1'])
// 主题:深色皮肤的底色由主题色派生,故颜色选择器直接绑定作用域种子
const themePrimary = ref('#1677ff')
const themeDark = ref(true)
const themeSelected = ref<MenuKey[]>(['1'])
const themeOpenKeys = ref<MenuKey[]>(['sub1'])
const themeItems: ItemType[] = [
{ key: '1', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
{ key: '2', icon: () => h(CalendarOutlined), label: 'Navigation Two', title: 'Navigation Two' },
{
key: 'sub1',
icon: () => h(AppstoreOutlined),
label: 'Navigation Three',
title: 'Navigation Three',
children: [
{ key: '3', label: 'Option 3', title: 'Option 3' },
{ key: '4', label: 'Option 4', title: 'Option 4' },
{
key: 'sub1-2',
label: 'Submenu',
title: 'Submenu',
children: [
{ key: '5', label: 'Option 5', title: 'Option 5' },
{ key: '6', label: 'Option 6', title: 'Option 6' }
]
}
]
},
{
key: 'sub2',
icon: () => h(SettingOutlined),
label: 'Navigation Four',
title: 'Navigation Four',
children: [
{ key: '7', label: 'Option 7', title: 'Option 7' },
{ key: '8', label: 'Option 8', title: 'Option 8' },
{ key: '9', label: 'Option 9', title: 'Option 9' },
{ key: '10', label: 'Option 10', title: 'Option 10' }
]
}
]
// 子菜单主题:根目录深色、子目录浅色(子菜单 theme 随开关变化)
const submenuDark = ref(false)
const submenuSelected = ref<MenuKey[]>(['1'])
const submenuOpenKeys = ref<MenuKey[]>(['sub1'])
const submenuItems = computed<ItemType[]>(() => [
{
key: 'sub1',
icon: () => h(MailOutlined),
label: 'Navigation One',
title: 'Navigation One',
theme: submenuDark.value ? 'dark' : 'light',
children: [
{ key: '1', label: 'Option 1', title: 'Option 1' },
{ key: '2', label: 'Option 2', title: 'Option 2' },
{ key: '3', label: 'Option 3', title: 'Option 3' }
]
},
{ key: '5', label: 'Option 5', title: 'Option 5' },
{ key: '6', label: 'Option 6', title: 'Option 6' }
])
// 组件式用法
const componentSelected = ref<MenuKey[]>(['1'])
const componentOpenKeys = ref<MenuKey[]>(['sub2'])
// 组件式自定义展开图标:子菜单级 expandIcon 优先,未提供的子菜单回落 Menu 级
const componentExpandIconSelected = ref<MenuKey[]>(['1'])
const componentExpandIconOpenKeys = ref<MenuKey[]>(['sub1'])
// 水平溢出指示器:容器放不下的项收进省略子菜单,其指示器由 overflowedIndicator 插槽自定义
const overflowItems: ItemType[] = [
{ key: 'mail', label: 'Navigation One', title: 'Navigation One' },
{ key: 'app', label: 'Navigation Two', title: 'Navigation Two' },
{ key: 'calendar', label: 'Navigation Three', title: 'Navigation Three' },
{ key: 'setting', label: 'Navigation Four', title: 'Navigation Four' },
{ key: 'desktop', label: 'Navigation Five', title: 'Navigation Five' }
]
// 子菜单预渲染:开启后子菜单面板在首次展开前就已渲染进 DOM(保持不可见)
const forceRenderEnabled = ref(false)
const forceRenderItems: ItemType[] = [
{ key: '1', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
{
key: 'sub1',
icon: () => h(AppstoreOutlined),
label: 'Navigation Two',
title: 'Navigation Two',
children: [
{ key: '2', label: 'Option 2', title: 'Option 2' },
{ key: '3', label: 'Option 3', title: 'Option 3' }
]
},
{
key: 'sub2',
icon: () => h(SettingOutlined),
label: 'Navigation Three',
title: 'Navigation Three',
children: [
{ key: '4', label: 'Option 4', title: 'Option 4' },
{ key: '5', label: 'Option 5', title: 'Option 5' }
]
}
]
</script>
<template>
<div>
<h1>{{ $route.name }} {{ $route.meta.title }}</h1>
<h2 class="mt30 mb10">顶部导航</h2>
<p class="mb10">水平的顶部导航菜单</p>
<Menu v-model:selectedKeys="horizontalSelected" mode="horizontal" :items="horizontalItems" />
<h2 class="mt30 mb10">内嵌菜单</h2>
<p class="mb10">子菜单内嵌在菜单区域内,可逐级展开</p>
<Menu
v-model:openKeys="inlineOpenKeys"
v-model:selectedKeys="inlineSelected"
style="width: 256px"
mode="inline"
:items="inlineItems"
/>
<h2 class="mt30 mb10">垂直菜单</h2>
<p class="mb10">子菜单以弹出形式展示,支持多级嵌套</p>
<Menu
v-model:openKeys="verticalOpenKeys"
v-model:selectedKeys="verticalSelected"
style="width: 256px"
mode="vertical"
:items="verticalItems"
/>
<h2 class="mt30 mb10">切换菜单类型</h2>
<p class="mb10">动态切换菜单模式与主题</p>
<Flex vertical>
<Flex align="center">
<Switch v-model:value="switchMode" checked="vertical" unchecked="inline" />
<Switch v-model:value="switchTheme" checked="dark" unchecked="light" />
</Flex>
<Menu
v-model:openKeys="switchOpenKeys"
v-model:selectedKeys="switchSelected"
style="width: 256px"
:mode="switchMode ? 'vertical' : 'inline'"
:theme="switchTheme ? 'dark' : 'light'"
:items="switchItems"
/>
</Flex>
<h2 class="mt30 mb10">子菜单展开触发与延时</h2>
<p class="mb10"
>切换子菜单的展开触发方式;<code>hover</code> 触发时,<code>subMenuOpenDelay</code> /
<code>subMenuCloseDelay</code> 分别决定展开与收起的延时</p
>
<Space vertical>
<Switch v-model:value="triggerHover" checked="hover" unchecked="click" />
<Menu
v-model:selectedKeys="triggerDelaySelected"
style="width: 256px"
mode="vertical"
:trigger-sub-menu-action="triggerHover ? 'hover' : 'click'"
:sub-menu-open-delay="0.3"
:sub-menu-close-delay="0.8"
:items="triggerDelayItems"
/>
</Space>
<h2 class="mt30 mb10">自定义缩进</h2>
<p class="mb10"
><code>inline</code> 模式下 <code>inlineIndent</code> 决定每一级菜单项的缩进宽度,左侧为默认
<code>24</code>、右侧为 <code>32</code></p
>
<Flex wrap="wrap" :gap="24">
<Menu v-model:openKeys="indentDefaultOpenKeys" style="width: 256px" mode="inline" :items="indentItems" />
<Menu
v-model:openKeys="indentWideOpenKeys"
style="width: 256px"
mode="inline"
:inline-indent="32"
:items="indentItems"
/>
</Flex>
<h2 class="mt30 mb10">缩起内嵌菜单</h2>
<p class="mb10">收起时只显示图标,子菜单以浮层展示,悬浮菜单项可查看完整标题</p>
<!-- 宽度交给外层容器:内嵌菜单收起后自身宽度收到 80px,若把宽度写在菜单上会把它钉死在展开宽度 -->
<Space vertical style="width: 256px">
<Button type="primary" @click="collapsed = !collapsed">
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</Button>
<Menu
v-model:openKeys="collapsedOpenKeys"
v-model:selectedKeys="collapsedSelected"
mode="inline"
theme="dark"
:inline-collapsed="collapsed"
:items="collapsedItems"
/>
</Space>
<h2 class="mt30 mb10">收起态长标题提示</h2>
<p class="mb10"
>提示取
<code>title</code
>,长标题会让提示折成多行(面板高于菜单项);把某项滚动到窗口上/下缘附近再悬浮,提示面板会贴边完整显示,箭头依然对准该项</p
>
<!-- 标题写长使提示折成多行:提示面板高于菜单项,箭头仍指向该项 -->
<div style="width: 256px">
<Menu
v-model:selectedKeys="longTitleSelected"
mode="inline"
theme="dark"
:inline-collapsed="true"
:items="longTitleItems"
/>
</div>
<h2 class="mt30 mb10">只展开当前父级菜单</h2>
<p class="mb10">点击菜单时收起其他已展开的菜单,保持菜单聚焦简洁</p>
<Menu
style="width: 256px"
mode="inline"
:items="parentItems"
:open-keys="parentOpenKeys"
v-model:selectedKeys="parentSelected"
@open-change="onParentOpenChange"
/>
<h2 class="mt30 mb10">多选</h2>
<p class="mb10">通过 <code>multiple</code> 允许多选,再次点击已选中项会触发取消选中</p>
<Menu v-model:selectedKeys="multipleSelected" style="width: 256px" mode="inline" multiple :items="multipleItems" />
<h2 class="mt30 mb10">禁用</h2>
<p class="mb10">
通过 <code>disabled</code> 禁用整个菜单;菜单项自身的 <code>disabled</code> 只禁用该项,<code>danger</code>
用于呈现错误状态样式
</p>
<Space vertical>
<Switch v-model:value="wholeDisabled" checked="禁用" unchecked="可用" />
<Menu
v-model:selectedKeys="disabledSelected"
style="width: 256px"
mode="inline"
:disabled="wholeDisabled"
:items="disabledItems"
/>
</Space>
<h2 class="mt30 mb10">空数据</h2>
<p class="mb10">菜单内容为空时渲染空列表,不报错也不残留占位</p>
<Menu style="width: 256px" mode="inline" :items="emptyItems" />
<h2 class="mt30 mb10">自定义展开图标</h2>
<p class="mb10"
>通过
<code>expandIcon</code> 属性或同名插槽自定义子菜单的展开收起图标:上方为插槽形态、下方为属性形态,两者等价</p
>
<Menu
v-model:openKeys="expandIconOpenKeys"
v-model:selectedKeys="expandIconSelected"
style="width: 256px"
mode="inline"
:items="expandIconItems"
>
<template #expandIcon="{ isOpen }">
<RightOutlined class="demo-expand-icon" :class="{ 'demo-expand-icon-open': isOpen }" />
</template>
</Menu>
<Menu
v-model:openKeys="expandIconPropOpenKeys"
style="width: 256px; margin-top: 16px"
mode="inline"
:items="expandIconItems"
:expand-icon="expandIconByProp"
/>
<h2 class="mt30 mb10">组件式用法</h2>
<p class="mb10">
以 <code>MenuItem</code> / <code>MenuSubMenu</code> / <code>MenuItemGroup</code> /
<code>MenuDivider</code> 子组件描述菜单结构,与 <code>items</code> 配置等价;两者同时提供时以子组件为准
</p>
<Menu
v-model:openKeys="componentOpenKeys"
v-model:selectedKeys="componentSelected"
style="width: 256px"
mode="inline"
>
<MenuItem key="1">
<template #icon><MailOutlined /></template>
Option 1
</MenuItem>
<MenuSubMenu key="sub2">
<template #icon><AppstoreOutlined /></template>
<template #title>Navigation Two</template>
<MenuItem key="2">Option 2</MenuItem>
<MenuItem key="3">Option 3</MenuItem>
<MenuSubMenu key="sub2-1" title="Submenu">
<MenuItem key="4">Option 4</MenuItem>
<MenuItem key="5">Option 5</MenuItem>
</MenuSubMenu>
</MenuSubMenu>
<MenuDivider />
<MenuItemGroup title="Group">
<MenuItem key="6">Option 6</MenuItem>
<MenuItem key="7">Option 7</MenuItem>
</MenuItemGroup>
<MenuItem key="8" disabled>Option 8</MenuItem>
</Menu>
<h2 class="mt30 mb10">组件式自定义展开图标</h2>
<p class="mb10">
子菜单的 <code>expandIcon</code> 插槽优先于 <code>Menu</code> 的同名插槽,未提供时回落到 <code>Menu</code> 级图标
</p>
<Menu
v-model:openKeys="componentExpandIconOpenKeys"
v-model:selectedKeys="componentExpandIconSelected"
style="width: 256px"
mode="inline"
>
<template #expandIcon="{ isOpen }">
<RightOutlined class="demo-expand-icon" :class="{ 'demo-expand-icon-open': isOpen }" />
</template>
<MenuSubMenu key="sub1" title="Navigation One">
<template #icon><MailOutlined /></template>
<template #expandIcon="{ isOpen }">
<span class="demo-expand-switch">
<PlusOutlined :class="{ 'demo-expand-switch-hidden': isOpen }" />
<MinusOutlined :class="{ 'demo-expand-switch-hidden': !isOpen }" />
</span>
</template>
<MenuItem key="1">Option 1</MenuItem>
<MenuItem key="2">Option 2</MenuItem>
</MenuSubMenu>
<MenuSubMenu key="sub3" title="Navigation Three">
<template #icon><SettingOutlined /></template>
<MenuItem key="3">Option 3</MenuItem>
<MenuItem key="4">Option 4</MenuItem>
</MenuSubMenu>
</Menu>
<h2 class="mt30 mb10">水平溢出指示器</h2>
<p class="mb10">
水平空间不足时,放不下的菜单项会收进省略子菜单;其指示器由
<code>overflowedIndicator</code> 插槽自定义(未提供时使用内置三点图标)
</p>
<div class="demo-overflow-box">
<Menu mode="horizontal" :items="overflowItems">
<template #overflowedIndicator>
<DownOutlined />
</template>
</Menu>
</div>
<h2 class="mt30 mb10">子菜单预渲染</h2>
<p class="mb10">
开启 <code>forceSubMenuRender</code> 后,子菜单面板在首次展开前就已渲染进 DOM(保持不可见),首次展开因此
无需等待挂载;可用开发者工具查看未展开的子菜单下是否已存在 <code>.menu-submenu-popup</code> 面板
</p>
<Space vertical>
<Switch v-model:value="forceRenderEnabled" checked="On" unchecked="Off" />
<Menu
style="width: 256px"
mode="vertical"
:force-sub-menu-render="forceRenderEnabled"
:items="forceRenderItems"
/>
</Space>
<h2 class="mt30 mb10">主题</h2>
<p class="mb10"
>内建 <code>light</code> 与 <code>dark</code> 两套主题,默认为
<code>light</code>;深色皮肤的底色由主题色派生,改主色时根列表、浮层与内联子菜单同步换色相</p
>
<Flex vertical>
<Space align="center">
<Switch v-model:value="themeDark" checked="Dark" unchecked="Light" />
<!-- 主题色只作用于本用例:作用域种子随选择器变化,深色皮肤的派生底色随之更新 -->
<ColorPicker v-model:value="themePrimary" :show-alpha="false" style="width: 160px" />
</Space>
<ConfigProvider :theme="{ seed: { primary: themePrimary } }">
<Menu
v-model:openKeys="themeOpenKeys"
v-model:selectedKeys="themeSelected"
style="width: 256px"
mode="inline"
:theme="themeDark ? 'dark' : 'light'"
:items="themeItems"
/>
</ConfigProvider>
</Flex>
<h2 class="mt30 mb10">子菜单主题</h2>
<p class="mb10">通过子菜单的 <code>theme</code> 属性设置其主题,可实现根目录深色、子目录浅色的效果</p>
<Space vertical>
<Switch v-model:value="submenuDark" checked="Dark" unchecked="Light" />
<Menu
style="width: 256px"
mode="vertical"
theme="dark"
v-model:openKeys="submenuOpenKeys"
v-model:selectedKeys="submenuSelected"
:items="submenuItems"
/>
</Space>
</div>
</template>
<style lang="less" scoped>
/* 展开图标用「单个箭头旋转 90°」表达展开态:直接切换图标会有一次瞬跳,旋转则连续过渡 */
.demo-expand-icon {
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
&.demo-expand-icon-open {
transform: rotate(90deg);
}
}
/* 加号 / 减号叠放在同一位置,靠「旋转 + 淡出」互换:若并列放置,过渡期间会同时出现两个图标并把标题挤动 */
.demo-expand-switch {
position: relative;
display: inline-block;
width: 1em;
height: 1em;
> * {
position: absolute;
top: 0;
left: 0;
transition:
opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
&.demo-expand-switch-hidden {
opacity: 0;
transform: rotate(-90deg);
}
}
}
/* 水平溢出用例的限宽容器:宽度不足以容纳全部菜单项时才会触发溢出省略 */
.demo-overflow-box {
width: 480px;
max-width: 100%;
}
</style>