20260817172952

This commit is contained in:
oneao committed 2026-08-17 17:29:54 +08:00
1 parent 4ef678ba65
commit 5bbd3c88b4
1865 files changed
+2261 -901

No files matched your search

@@ -49,8 +49,8 @@ function toggle() {
}
.theme-toggle-icon {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
}
@media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,26 @@
<script setup>
/* DropdownItem —— 声明式菜单项(类 el-dropdown-item 写法)。
*
* 本组件不渲染任何可见 DOM,仅用于被 <Dropdown> 的默认插槽收集其
* props + 默认插槽内容,并转换为 items 数组交由 DropdownMenu 渲染,
* 从而复用现有的键盘导航 / 高亮 / 子菜单体系。
*
* 支持的属性与 items 数组项一一对应:
* key 标识,点击时回传 @click 的 { key, item }
* icon 图标组件(Lucide)
* label 菜单文本(也可直接用默认插槽写富内容,由父级读取 children)
* danger 危险项(红色)
* disabled 禁用项
* divider 渲染为分隔线(设置后其余属性忽略)
* onClick 项自带点击回调,命中时优先执行
*/
defineProps({
key: { type: [String, Number], default: undefined },
icon: { type: [Object, Function], default: undefined },
label: { type: String, default: '' },
danger: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
divider: { type: Boolean, default: false },
onClick: { type: Function, default: undefined },
})
</script>
@@ -1,11 +1,12 @@
<script setup>
import { computed, nextTick, provide, ref } from 'vue'
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({
/** 菜单项数组;不传时使用 #menu 插槽自定义菜单内容(逃生舱) */
/** 菜单项数组;不传时若默认插槽含 <DropdownItem> 则由其收集渲染,否则用 #menu 插槽(逃生舱) */
items: { type: Array, default: undefined },
/** 受控打开状态;不传则非受控 */
open: { type: Boolean, default: undefined },
@@ -30,6 +31,43 @@ const props = defineProps({
})
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.key != null) item.key = p.key
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)
@@ -41,7 +79,7 @@ const isOpen = computed(() => (props.open !== undefined ? props.open : open.valu
// 可键盘导航的项索引(跳过 divider / label / disabled / 空节点)
const enabledIndices = computed(() => {
const list = props.items || []
const list = resolvedItems.value || []
return list.reduce((acc, item, index) => {
if (item && !item.divider && item.type !== 'label' && !item.disabled) acc.push(index)
return acc
@@ -117,7 +155,7 @@ function onMenuKeydown(event) {
break
case 'Enter': {
event.preventDefault()
const item = props.items?.[activeIndex.value]
const item = resolvedItems.value?.[activeIndex.value]
// 子菜单父项 Enter 不选中(子菜单为 hover 展开,键盘 MVP 不支持逐层进入)
if (item && enabledIndices.value.includes(activeIndex.value) && !item.children) {
selectItem(item)
@@ -157,7 +195,12 @@ provide('dropdownActiveIndex', activeIndex)
tabindex="-1"
@keydown="onMenuKeydown"
>
<DropdownMenu v-if="items != null" :items="items" />
<!-- 富头部插槽:菜单项列表上方的自定义区域(头像+名字+组织等),有内容时才渲染 -->
<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>
@@ -18,6 +18,15 @@
outline: none;
}
/* 富头部:菜单项列表上方的自定义区域(头像 + 名字 + 组织等)。
与下方列表之间以 1px 分隔线隔开,本身不可点击、不可选中文本。 */
.dropdown-header {
padding: 8px 8px 8px;
border-bottom: 1px solid var(--fms-border);
margin-bottom: 4px;
user-select: none;
}
/* 菜单项:图标 + 文字 + 快捷键单行布局,hover / 键盘高亮 */
.dropdown-item {
display: flex;
@@ -16,6 +16,14 @@ const props = defineProps({
/* ---------- 注入:主 Dropdown 提供选中回调与高亮索引 ---------- */
const selectItem = inject('dropdownSelect', () => {})
const activeIndex = inject('dropdownActiveIndex', null)
/* 普通项点击:先执行项自带 onClick(item),再走主 Dropdown 的 selectItem
(selectItem 会 emit 全局 click + 关闭浮层,保证两种用法互不冲突) */
function onItemClick(item) {
if (item.disabled) return
item.onClick?.(item)
selectItem(item)
}
</script>
<template>
@@ -68,7 +76,7 @@ const activeIndex = inject('dropdownActiveIndex', null)
role="menuitem"
:aria-disabled="item.disabled || undefined"
@mouseenter="interactive && !item.disabled && (activeIndex = index)"
@click="!item.disabled && selectItem(item)"
@click="onItemClick(item)"
>
<span v-if="item.icon" class="dropdown-item-icon"><component :is="item.icon" /></span>
<span class="dropdown-item-label">{{ item.label }}</span>
@@ -142,6 +142,19 @@ function applySizes(result, index) {
innerSizes.value = next
}
// 拖拽/键盘调整结束后,未显式声明尺寸(无 size 且无 defaultSize)的面板恢复为
// flex 自动填充,避免其被固定为拖拽时的 px 宽度,导致容器尺寸变化
// (如侧栏折叠、窗口缩放)时剩余空间无法被自动填满
function releaseAutoPanels() {
const next = [...innerSizes.value]
panelItems.value.forEach((panel, i) => {
if (next[i] !== undefined && panel.props.size == null && panel.props.defaultSize == null) {
delete next[i]
}
})
innerSizes.value = next
}
function onBarPointerDown(index, event) {
if (event.button !== 0 || !canResize(index)) return
event.preventDefault()
@@ -195,6 +208,7 @@ function onPointerUp() {
document.body.style.webkitUserSelect = ''
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
releaseAutoPanels()
emit('resize-end', finalSizes)
}
@@ -216,6 +230,7 @@ function onBarKeydown(index, event) {
const startSizes = measurePanelSizes()
const result = applyOffset(index, startSizes, container, delta)
applySizes(result, index)
releaseAutoPanels()
emit('resize', [...result])
emit('resize-end', [...result])
}