Files
workspace/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue
T
2026-08-17 22:52:27 +08:00

207 lines
6.5 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>
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>