20260816220132
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user