207 lines
6.5 KiB
Vue
207 lines
6.5 KiB
Vue
<script setup>
|
||
import { computed, nextTick, provide, ref, useSlots } from 'vue'
|
||
import Popover from '../popover/popover.vue'
|
||
import DropdownMenu from './menu.vue'
|
||
import DropdownItem from './DropdownItem.vue'
|
||
|
||
/* ---------- props / emits / slots ---------- */
|
||
const props = defineProps({
|
||
/** 菜单项数组;不传时若默认插槽含 <DropdownItem> 则由其收集渲染,否则用 #menu 插槽(逃生舱) */
|
||
items: { type: Array, default: undefined },
|
||
/** 受控打开状态;不传则非受控 */
|
||
open: { type: Boolean, default: undefined },
|
||
/** 非受控时的初始打开状态 */
|
||
defaultOpen: { type: Boolean, default: false },
|
||
/** 触发方式:click 点击 / hover 悬停 / contextmenu 右键 */
|
||
trigger: {
|
||
type: String,
|
||
default: 'click',
|
||
validator: (v) => ['click', 'hover', 'contextmenu'].includes(v),
|
||
},
|
||
/** 菜单浮层方位(复用 Popover 的 12 方位) */
|
||
placement: { type: String, default: 'bottom-start' },
|
||
/** 禁用态 */
|
||
disabled: { type: Boolean, default: false },
|
||
/** hover 触发打开延迟(ms) */
|
||
mouseEnterDelay: { type: Number, default: 100 },
|
||
/** hover 触发关闭延迟(ms);主菜单 hover + 子菜单组合建议调大以覆盖移入子菜单的时间 */
|
||
mouseLeaveDelay: { type: Number, default: 200 },
|
||
/** 与触发器的间距(px) */
|
||
offset: { type: Number, default: 4 },
|
||
})
|
||
|
||
const emit = defineEmits(['update:open', 'openChange', 'click'])
|
||
const slots = useSlots()
|
||
|
||
/* ---------- items 来源:优先 props.items,未传时从默认插槽收集 <DropdownItem> ---------- */
|
||
function collectDropdownItems() {
|
||
const defaultSlot = slots.default
|
||
if (!defaultSlot) return []
|
||
const items = []
|
||
for (const vnode of defaultSlot()) {
|
||
// 跳过注释 / 文本节点
|
||
if (!vnode.type) continue
|
||
// 仅收集 <DropdownItem> 类型节点
|
||
if (vnode.type !== DropdownItem) continue
|
||
const p = vnode.props || {}
|
||
// divider 项:忽略其余属性
|
||
if (p.divider != null && p.divider !== false) {
|
||
items.push({ divider: true })
|
||
continue
|
||
}
|
||
const item = {}
|
||
if (p.itemKey != null) item.key = p.itemKey
|
||
if (p.icon != null) item.icon = p.icon
|
||
if (p.label != null) item.label = p.label
|
||
if (p.danger) item.danger = true
|
||
if (p.disabled) item.disabled = true
|
||
if (p.onClick != null) item.onClick = p.onClick
|
||
items.push(item)
|
||
}
|
||
return items
|
||
}
|
||
|
||
const resolvedItems = computed(() => {
|
||
if (props.items != null) return props.items
|
||
// 未传 items 时,仅当默认插槽确实声明了 <DropdownItem> 才收集并启用数组渲染;
|
||
// 否则保留 undefined,回退到 #menu 逃生舱(避免空数组误吞 #menu 内容)
|
||
const collected = collectDropdownItems()
|
||
return collected.length ? collected : undefined
|
||
})
|
||
|
||
/* ---------- 公共状态 ---------- */
|
||
const open = ref(props.defaultOpen)
|
||
const menuEl = ref(null)
|
||
const activeIndex = ref(-1)
|
||
|
||
// Popover 受控模式:trigger 触发 / 外部点击 / Esc 的开关统一经 onOpenChange 同步(与 Select 一致)
|
||
const isOpen = computed(() => (props.open !== undefined ? props.open : open.value))
|
||
|
||
// 可键盘导航的项索引(跳过 divider / label / disabled / 空节点)
|
||
const enabledIndices = computed(() => {
|
||
const list = resolvedItems.value || []
|
||
return list.reduce((acc, item, index) => {
|
||
if (item && !item.divider && item.type !== 'label' && !item.disabled) acc.push(index)
|
||
return acc
|
||
}, [])
|
||
})
|
||
|
||
/* ---------- 公共事件 ---------- */
|
||
function onOpenChange(value) {
|
||
open.value = value
|
||
emit('update:open', value)
|
||
emit('openChange', value)
|
||
if (value) {
|
||
activeIndex.value = -1
|
||
nextTick(() => {
|
||
// 聚焦菜单容器承接键盘导航;浮层由 v-if + Transition 渲染,nextTick 后已挂载
|
||
menuEl.value?.focus()
|
||
})
|
||
} else {
|
||
activeIndex.value = -1
|
||
}
|
||
}
|
||
|
||
function selectItem(item) {
|
||
emit('click', { key: item.key, item })
|
||
onOpenChange(false)
|
||
}
|
||
|
||
function close() {
|
||
onOpenChange(false)
|
||
}
|
||
|
||
/* ---------- 键盘导航 ---------- */
|
||
function moveActive(direction) {
|
||
const indices = enabledIndices.value
|
||
if (!indices.length) {
|
||
activeIndex.value = -1
|
||
return
|
||
}
|
||
const pos = indices.indexOf(activeIndex.value)
|
||
const nextPos =
|
||
pos === -1
|
||
? direction === 1
|
||
? 0
|
||
: indices.length - 1
|
||
: (pos + direction + indices.length) % indices.length
|
||
activeIndex.value = indices[nextPos]
|
||
nextTick(scrollActiveIntoView)
|
||
}
|
||
|
||
function scrollActiveIntoView() {
|
||
menuEl.value?.querySelector('.dropdown-item-active')?.scrollIntoView({ block: 'nearest' })
|
||
}
|
||
|
||
function onMenuKeydown(event) {
|
||
switch (event.key) {
|
||
case 'ArrowDown':
|
||
event.preventDefault()
|
||
moveActive(1)
|
||
break
|
||
case 'ArrowUp':
|
||
event.preventDefault()
|
||
moveActive(-1)
|
||
break
|
||
case 'Home':
|
||
event.preventDefault()
|
||
activeIndex.value = enabledIndices.value[0] ?? -1
|
||
nextTick(scrollActiveIntoView)
|
||
break
|
||
case 'End':
|
||
event.preventDefault()
|
||
activeIndex.value = enabledIndices.value[enabledIndices.value.length - 1] ?? -1
|
||
nextTick(scrollActiveIntoView)
|
||
break
|
||
case 'Enter': {
|
||
event.preventDefault()
|
||
const item = resolvedItems.value?.[activeIndex.value]
|
||
// 子菜单父项 Enter 不选中(子菜单为 hover 展开,键盘 MVP 不支持逐层进入)
|
||
if (item && enabledIndices.value.includes(activeIndex.value) && !item.children) {
|
||
selectItem(item)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------- 注入子菜单 ---------- */
|
||
provide('dropdownSelect', selectItem)
|
||
provide('dropdownActiveIndex', activeIndex)
|
||
</script>
|
||
|
||
<template>
|
||
<Popover
|
||
:open="isOpen"
|
||
:trigger="trigger"
|
||
:placement="placement"
|
||
:disabled="disabled"
|
||
:arrow="false"
|
||
:offset="offset"
|
||
:mouse-enter-delay="mouseEnterDelay"
|
||
:mouse-leave-delay="mouseLeaveDelay"
|
||
@update:open="onOpenChange"
|
||
>
|
||
<!-- 触发器:#trigger 具名插槽优先,未传时回退默认插槽 -->
|
||
<template #trigger>
|
||
<slot name="trigger"><slot /></slot>
|
||
</template>
|
||
|
||
<template #content>
|
||
<div ref="menuEl" class="dropdown-menu" role="menu" tabindex="-1" @keydown="onMenuKeydown">
|
||
<!-- 富头部插槽:菜单项列表上方的自定义区域(头像+名字+组织等),有内容时才渲染 -->
|
||
<div v-if="$slots.header" class="dropdown-header">
|
||
<slot name="header" />
|
||
</div>
|
||
|
||
<DropdownMenu v-if="resolvedItems != null" :items="resolvedItems" />
|
||
<slot v-else name="menu" :close="close" />
|
||
</div>
|
||
</template>
|
||
</Popover>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
@use './index.scss';
|
||
</style>
|