20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
1 parent b6f9a4c912
commit 72993ecdcd
179 files changed
+20050 -633

No files matched your search

@@ -0,0 +1,169 @@
<script setup>
import { computed, nextTick, provide, ref } from 'vue'
import Popover from '../popover/popover.vue'
import DropdownMenu from './menu.vue'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
/** 菜单项数组;不传时使用 #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 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 = props.items || []
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 = props.items?.[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"
>
<DropdownMenu v-if="items != null" :items="items" />
<slot v-else name="menu" :close="close" />
</div>
</template>
</Popover>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>