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>
@@ -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>