Files
workspace/code/g3soft-libs/packages/ui/src/dropdown/DropdownMenu.vue
T
2026-10-10 17:33:59 +08:00

152 lines
5.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { inject, isVNode, provide, ref, type Ref, type VNode } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useNamespace } from '../_composables/useNamespace'
import G3Popover from '../popover/Popover.vue'
import { dropdownActiveIndexKey, dropdownSelectKey } from './context'
import type { DropdownItemData } from './types'
/**
* 下拉菜单列表(内部组件)—— 把 items 数组渲染成分隔线 / 分组标签 / 子菜单 / 普通项。
*/
defineOptions({ name: 'G3DropdownMenu' })
const props = withDefaults(
defineProps<{
/** 菜单项数组 */
items?: DropdownItemData[]
/** 根层菜单:与 Dropdown 的键盘导航联动;子菜单为 false */
interactive?: boolean
}>(),
{ items: () => [], interactive: true },
)
const { b, e, m, is } = useNamespace('dropdown')
const selectItem = inject(dropdownSelectKey, null)
const injectedActiveIndex = inject(dropdownActiveIndexKey, null)
/**
* 本层生效的高亮索引(Ref<number>)。
*
* 根层(interactive=true):用 Dropdown 注入的那个 —— 键盘 ↑↓ 改的就是它,
* 鼠标划过也要回写同一个,两者才能联动(这是原有行为,必须保住)。
*
* 子菜单(interactive=false):**自持一个**,不碰注入值。原因见 setActive。
*/
const localActiveIndex = ref(-1)
const activeIndex: Ref<number> =
props.interactive && injectedActiveIndex ? injectedActiveIndex : localActiveIndex
function isActive(index: number): boolean {
return activeIndex.value === index
}
/**
* 鼠标移入时更新本层高亮。
*
* 子菜单这里曾经是空操作(interactive=false 直接 return),导致子项完全没有 hover 反馈;
* 而且根层那个共享索引的残留值会命中子项索引(两层都从 0 开始),
* 表现为 hover 子项时灰底「一闪即消」—— 面板自身的 mouseenter 会触发
* setOpen(true) → onOpenChange 把根层 activeIndex 重置为 -1,子项又无力重新置位。
* 现在子菜单自持索引,面板重入再也冲不掉它。
*/
function setActive(index: number): void {
activeIndex.value = index
}
function clearActive(): void {
activeIndex.value = -1
}
/** 把这层的高亮索引 provide 给子菜单(子菜单会再 provide 自己那层覆盖掉) */
provide(dropdownActiveIndexKey, activeIndex)
function onItemClick(item: DropdownItemData) {
if (item.disabled) return
item.onClick?.(item)
selectItem?.(item)
}
/** 渲染函数组件:把 VNode 形态的图标原样渲染 */
const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
</script>
<template>
<template v-for="(item, index) in props.items" :key="item?.key ?? index">
<!-- 分隔线 -->
<div v-if="item?.divider" :class="e('divider')" role="separator" />
<!-- 分组标签 -->
<div v-else-if="item?.type === 'label'" :class="e('label')" role="group">
{{ item.label }}
</div>
<!-- 子菜单:嵌套 Popover,hover 展开 -->
<G3Popover
v-else-if="item?.children?.length"
trigger="hover"
placement="right-start"
:offset="0"
:arrow="false"
unwrapped
>
<template #trigger>
<span
:class="[
e('item'),
{
[is('item-active')]: isActive(index),
[is('item-selected')]: item.selected,
[is('item-disabled')]: item.disabled,
},
]"
role="menuitem"
aria-haspopup="menu"
:aria-current="item.selected ? 'page' : undefined"
@mouseenter="!item.disabled && setActive(index)"
>
<span v-if="item.icon" :class="e('item-icon')">
<component :is="item.icon" v-if="!isVNode(item.icon)" />
<VNodeRenderer v-else :node="(item.icon as VNode)" />
</span>
<span :class="e('item-label')">{{ item.label }}</span>
<ChevronRight :class="e('item-caret')" aria-hidden="true" />
</span>
</template>
<template #content>
<div :class="e('menu')" role="menu" @mouseleave="clearActive">
<G3DropdownMenu :items="item.children" :interactive="false" />
</div>
</template>
</G3Popover>
<!-- 普通项 -->
<div
v-else-if="item"
:class="[
e('item'),
{
[is('item-active')]: isActive(index),
[is('item-selected')]: item.selected,
[is('item-disabled')]: item.disabled,
[is('item-danger')]: item.danger,
},
]"
role="menuitem"
:aria-disabled="item.disabled || undefined"
:aria-current="item.selected ? 'page' : undefined"
@mouseenter="!item.disabled && setActive(index)"
@click="onItemClick(item)"
>
<span v-if="item.icon" :class="e('item-icon')">
<component :is="item.icon" v-if="!isVNode(item.icon)" />
<VNodeRenderer v-else :node="(item.icon as VNode)" />
</span>
<span :class="e('item-label')">{{ item.label }}</span>
<span v-if="item.shortcut" :class="e('item-shortcut')">{{ item.shortcut }}</span>
</div>
</template>
</template>