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