152 lines
5.1 KiB
Vue
152 lines
5.1 KiB
Vue
<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>
|