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>
|
||||
@@ -0,0 +1,136 @@
|
||||
/* Dropdown 下拉菜单 —— 卡片浮层 + 菜单项(样式参考 shadcn DropdownMenu) */
|
||||
|
||||
/* 菜单卡片:以负 margin 抵消 popover-content 的 8px padding,
|
||||
让卡片自带背景 / 边框 / 圆角贴齐浮层边缘,视觉与 shadcn 菜单一致 */
|
||||
.dropdown-menu {
|
||||
box-sizing: border-box;
|
||||
margin: calc(-1 * var(--fms-popover-padding));
|
||||
min-width: var(--fms-dropdown-min-width);
|
||||
max-height: var(--fms-dropdown-max-height);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
background: var(--fms-card);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 菜单项:图标 + 文字 + 快捷键单行布局,hover / 键盘高亮 */
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: var(--fms-dropdown-item-padding-y) var(--fms-dropdown-item-padding-x);
|
||||
border: none;
|
||||
border-radius: var(--fms-dropdown-item-radius);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
line-height: var(--fms-line-height);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
:deep(svg) {
|
||||
flex-shrink: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* 子菜单项包裹在嵌套 Popover 的 trigger 中(inline-flex 会收缩宽度),
|
||||
改为块级填满 .dropdown-menu,保证整行可 hover / 可点击 */
|
||||
.dropdown-menu :deep(.popover-trigger) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-item:hover:not(.dropdown-item-disabled),
|
||||
.dropdown-item-active {
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
.dropdown-item:focus-visible {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 图标位:1em 宽,仅在菜单项配置了图标时渲染 */
|
||||
.dropdown-item-icon {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1em;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.dropdown-item-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 快捷键:右侧浅灰小字 */
|
||||
.dropdown-item-shortcut {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding-left: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
/* 子菜单展开箭头:靠右指示 */
|
||||
.dropdown-item-caret {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
/* 危险项:文字红色,hover 淡红底(参考 shadcn destructive 变体) */
|
||||
.dropdown-item-danger {
|
||||
color: var(--fms-danger);
|
||||
}
|
||||
|
||||
.dropdown-item-danger:hover:not(.dropdown-item-disabled),
|
||||
.dropdown-item-danger.dropdown-item-active {
|
||||
background: color-mix(in srgb, var(--fms-danger) 10%, transparent);
|
||||
}
|
||||
|
||||
/* 禁用项:灰字不可点 */
|
||||
.dropdown-item-disabled {
|
||||
color: var(--fms-disabled-text);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropdown-item-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 分组标签 */
|
||||
.dropdown-label {
|
||||
padding: var(--fms-dropdown-item-padding-y) var(--fms-dropdown-item-padding-x);
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
margin: var(--fms-dropdown-divider-margin);
|
||||
background: var(--fms-border);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dropdown-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import Popover from '../popover/popover.vue'
|
||||
|
||||
defineOptions({ name: 'DropdownMenu' })
|
||||
|
||||
/* ---------- props ---------- */
|
||||
const props = defineProps({
|
||||
/** 菜单项数组:divider / type='label' / children 子菜单 / 普通项 */
|
||||
items: { type: Array, default: () => [] },
|
||||
/** 是否参与主菜单的键盘 / 鼠标高亮联动;递归子菜单时关闭 */
|
||||
interactive: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
/* ---------- 注入:主 Dropdown 提供选中回调与高亮索引 ---------- */
|
||||
const selectItem = inject('dropdownSelect', () => {})
|
||||
const activeIndex = inject('dropdownActiveIndex', null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(item, index) in items" :key="item?.key ?? index">
|
||||
<!-- 分隔线 -->
|
||||
<div v-if="item?.divider" class="dropdown-divider" role="separator" />
|
||||
|
||||
<!-- 分组标签 -->
|
||||
<div v-else-if="item?.type === 'label'" class="dropdown-label" role="group">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
|
||||
<!-- 子菜单:嵌套 Popover,hover 展开右侧子级 -->
|
||||
<Popover
|
||||
v-else-if="item?.children?.length"
|
||||
trigger="hover"
|
||||
placement="right-start"
|
||||
:offset="0"
|
||||
:arrow="false"
|
||||
>
|
||||
<template #trigger>
|
||||
<span
|
||||
class="dropdown-item"
|
||||
:class="{ 'dropdown-item-active': interactive && activeIndex === index }"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
@mouseenter="interactive && !item.disabled && (activeIndex = index)"
|
||||
>
|
||||
<span v-if="item.icon" class="dropdown-item-icon"><component :is="item.icon" /></span>
|
||||
<span class="dropdown-item-label">{{ item.label }}</span>
|
||||
<ChevronRight class="dropdown-item-caret" aria-hidden="true" />
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<DropdownMenu :items="item.children" :interactive="false" />
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<!-- 普通项(item 为空时不渲染) -->
|
||||
<div
|
||||
v-else-if="item"
|
||||
class="dropdown-item"
|
||||
:class="{
|
||||
'dropdown-item-active': interactive && activeIndex === index,
|
||||
'dropdown-item-disabled': item.disabled,
|
||||
'dropdown-item-danger': item.danger,
|
||||
}"
|
||||
role="menuitem"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
@mouseenter="interactive && !item.disabled && (activeIndex = index)"
|
||||
@click="!item.disabled && selectItem(item)"
|
||||
>
|
||||
<span v-if="item.icon" class="dropdown-item-icon"><component :is="item.icon" /></span>
|
||||
<span class="dropdown-item-label">{{ item.label }}</span>
|
||||
<span v-if="item.shortcut" class="dropdown-item-shortcut">{{ item.shortcut }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
Reference in new issue
Block a user