308 lines
9.9 KiB
Vue
308 lines
9.9 KiB
Vue
<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>
|