Files
workspace/code/fms/fms-vue/src/components/ui/tabs/tabs.vue
T
2026-08-24 17:27:24 +08:00

308 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, Fragment, isVNode, nextTick, reactive, ref, useId, useSlots, watch } from 'vue'
import Scrollbar from '@/components/ui/scrollbar/scrollbar.vue'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
/** 双向绑定的激活 pane 的 key(String 或 Number) */
modelValue: { type: [String, Number], default: undefined },
/** 标签方向:top 横排在上,left/right 纵向排列在左右 */
placement: {
type: String,
default: 'top',
validator: (value) => ['top', 'left', 'right'].includes(value),
},
/** 样式类型:line 下划线指示条,pill 胶囊式标签 */
type: {
type: String,
default: 'line',
validator: (value) => ['line', 'pill'].includes(value),
},
/** 仅渲染标签栏(不渲染内容区):用于纯导航场景,内容由外部 v-show 控制 */
navOnly: { type: Boolean, default: false },
/** 标签列表在标签栏内居中显示(top 水平居中,left/right 垂直居中) */
centered: { type: Boolean, default: false },
/** 整组禁用 */
disabled: { type: Boolean, default: false },
/**
* 标签数据源(与 TabPane 插槽二选一,提供时优先):
* [{ key, label, disabled?, content? }],label 可为字符串或 VNode,
* content 可为 VNode 或渲染函数
*/
items: { type: Array, default: undefined },
})
const emit = defineEmits(['update:modelValue', 'change', 'tabClick'])
const slots = useSlots()
/* ---------- 公共状态 ---------- */
const baseId = useId()
// 已激活过的 pane 集合:配合 v-if/v-show 实现「首次激活渲染 + 切换不销毁」
const renderedKeys = reactive(new Set())
// 普通对象收集 tab DOM,仅用于键盘导航聚焦,无需响应式
const tabRefs = {}
// 非受控模式(未传 modelValue)时的内部激活 key
const innerKey = ref(null)
/* ---------- 数据源归一 ---------- */
// 展平插槽 vnode:展开 Fragment,过滤注释/文本等非组件节点
function flattenVNodes(nodes) {
return nodes.flatMap((node) => {
if (node == null) return []
if (Array.isArray(node)) return flattenVNodes(node)
if (typeof node.type === 'symbol') {
if (node.type === Fragment && node.children) {
return flattenVNodes(Array.isArray(node.children) ? node.children : [node.children])
}
return []
}
return [node]
})
}
// 识别 TabPane 声明组件(vnode.type.name 由 defineOptions 指定)
function isTabPane(node) {
return node.type?.name === 'TabPane'
}
// 提取 TabPane vnode 的默认内容与 #label 插槽为渲染函数
function extractChildren(children) {
if (children == null) return {}
// 含具名插槽时 children 为插槽集合对象
if (typeof children === 'object' && !Array.isArray(children) && !isVNode(children)) {
return {
content: children.default ? () => children.default() : undefined,
label: children.label ? () => children.label() : undefined,
}
}
// 仅有默认内容:直接是单个 vnode / vnode 数组 / 字符串
const list = Array.isArray(children) ? children : [children]
return { content: () => list }
}
// TabPane 插槽模式 → 归一 panes
const slotPanes = computed(() => {
const panes = []
let index = 0
for (const node of flattenVNodes(slots.default?.() ?? [])) {
if (!isTabPane(node)) continue
const paneProps = node.props ?? {}
const { content, label } = extractChildren(node.children)
// 模板编译的 vnode.props 保留模板中原始属性名(kebab-case),h() 调用为 camelCase,两者都兼容
const tabKey = paneProps.tabKey != null ? paneProps.tabKey : paneProps['tab-key']
// 不能用 node.key 兜底:编译器会给带 v-if 的兄弟 TabPane 注入递增 key,导致多个 pane key 相同
const rawKey = tabKey != null ? tabKey : index
panes.push({
key: String(rawKey),
rawKey,
disabled: Boolean(paneProps.disabled),
labelText: label ? undefined : paneProps.label != null ? String(paneProps.label) : undefined,
labelRender: label,
content,
})
index += 1
}
return panes
})
// items 数组模式 → 归一 panes
const itemsPanes = computed(() => {
const list = props.items || []
return list.map((item, index) => {
const rawKey = item.key != null ? item.key : index
let labelText
let labelRender
if (slots.label) {
// Tabs 根提供 #label 插槽时按 items 项渲染自定义标签
labelRender = () => slots.label({ item, index })
} else if (item.label != null) {
if (isVNode(item.label)) labelRender = () => [item.label]
else labelText = String(item.label)
}
return {
key: String(rawKey),
rawKey,
disabled: Boolean(item.disabled),
labelText,
labelRender,
content:
typeof item.content === 'function'
? item.content
: item.content != null
? () => [item.content]
: undefined,
}
})
})
const mergedPanes = computed(() =>
props.items != null && props.items.length > 0 ? itemsPanes.value : slotPanes.value,
)
/* ---------- 激活管理 ---------- */
// 仅遍历可用(非禁用)pane,供键盘循环与激活回退使用
const enabledKeys = computed(() =>
mergedPanes.value.filter((pane) => !pane.disabled).map((pane) => pane.key),
)
// 受控模式取 props.modelValue,非受控模式取内部 innerKey;
// 未传或指向不存在/禁用的 pane 时,回退到第一个可用项(不写回,保持受控语义)
const currentKey = computed(() => {
const source = props.modelValue != null ? props.modelValue : innerKey.value
const active = source != null ? String(source) : null
if (active != null) {
const pane = mergedPanes.value.find((p) => p.key === active)
if (pane && !pane.disabled) return pane.key
}
return enabledKeys.value[0] ?? null
})
// 激活变化时记录已渲染 pane,实现懒渲染缓存
watch(
currentKey,
(key) => {
if (key != null) renderedKeys.add(key)
},
{ immediate: true },
)
/* ---------- 公共事件 ---------- */
// 应用选中:受控模式 emit 双向绑定由父级驱动,非受控模式更新内部状态
function applySelect(rawKey) {
if (String(rawKey) === currentKey.value) return
if (props.modelValue != null) emit('update:modelValue', rawKey)
else innerKey.value = String(rawKey)
emit('change', rawKey)
}
function onTabClick(pane, event) {
if (props.disabled || pane.disabled) return
emit('tabClick', pane.rawKey, event)
applySelect(pane.rawKey)
}
// 键盘触发的切换(不经过 tabClick)
function selectKey(key) {
const pane = mergedPanes.value.find((p) => p.key === key)
if (!pane || pane.disabled || props.disabled) return
applySelect(pane.rawKey)
}
/* ---------- 键盘导航 ---------- */
function setTabRef(key, el) {
if (el) tabRefs[key] = el
else delete tabRefs[key]
}
function focusTab(key) {
tabRefs[key]?.focus()
}
function jumpTo(key) {
if (key != null) {
selectKey(key)
nextTick(() => focusTab(key))
}
}
function onTablistKeydown(event) {
if (props.disabled || enabledKeys.value.length === 0) return
const vertical = props.placement !== 'top'
const delta =
event.key === (vertical ? 'ArrowDown' : 'ArrowRight')
? 1
: event.key === (vertical ? 'ArrowUp' : 'ArrowLeft')
? -1
: null
if (delta != null) {
event.preventDefault()
const keys = enabledKeys.value
const pos = keys.indexOf(currentKey.value)
const nextPos =
pos === -1 ? (delta === 1 ? 0 : keys.length - 1) : (pos + delta + keys.length) % keys.length
jumpTo(keys[nextPos])
return
}
if (event.key === 'Home') {
event.preventDefault()
jumpTo(enabledKeys.value[0])
} else if (event.key === 'End') {
event.preventDefault()
jumpTo(enabledKeys.value[enabledKeys.value.length - 1])
}
}
</script>
<template>
<div
class="tabs"
:class="[
`tabs-${placement}`,
`tabs-${type}`,
{ 'tabs-disabled': disabled, 'tabs-centered': centered, 'tabs-nav-only': navOnly },
]"
>
<div class="tabs-nav">
<div v-if="slots.leftExtra" class="tabs-nav-extra">
<slot name="leftExtra" />
</div>
<Scrollbar class="tabs-nav-scroll">
<div
class="tabs-nav-list"
role="tablist"
:aria-orientation="placement === 'top' ? 'horizontal' : 'vertical'"
@keydown="onTablistKeydown"
>
<button
v-for="(pane, index) in mergedPanes"
:key="pane.key"
:ref="(el) => setTabRef(pane.key, el)"
type="button"
class="tabs-tab"
:class="{
'tabs-tab-active': currentKey === pane.key,
'tabs-tab-disabled': pane.disabled,
}"
role="tab"
:id="`${baseId}-tab-${index}`"
:aria-selected="currentKey === pane.key"
:aria-controls="`${baseId}-panel-${index}`"
:tabindex="currentKey === pane.key ? 0 : -1"
:disabled="disabled || pane.disabled"
@click="(event) => onTabClick(pane, event)"
>
<component :is="pane.labelRender" v-if="pane.labelRender" />
<span v-else-if="pane.labelText !== undefined">{{ pane.labelText }}</span>
</button>
</div>
</Scrollbar>
<div v-if="slots.rightExtra" class="tabs-nav-extra">
<slot name="rightExtra" />
</div>
</div>
<div v-if="!navOnly" class="tabs-content">
<template v-for="(pane, index) in mergedPanes" :key="pane.key">
<div
v-if="renderedKeys.has(pane.key)"
v-show="currentKey === pane.key"
class="tabs-pane"
role="tabpanel"
:id="`${baseId}-panel-${index}`"
:aria-labelledby="`${baseId}-tab-${index}`"
tabindex="0"
>
<component :is="pane.content" v-if="pane.content" />
</div>
</template>
</div>
</div>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>