20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,291 @@
|
||||
<script setup>
|
||||
import { computed, isVNode, nextTick, reactive, ref, useId, useSlots, watch } from '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),
|
||||
},
|
||||
/** 标签列表在标签栏内居中显示(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)
|
||||
// key 解析顺序:tabKey prop > vnode.key > 出现顺序索引
|
||||
const rawKey =
|
||||
paneProps.tabKey != null ? paneProps.tabKey : node.key != null ? node.key : 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-disabled': disabled, 'tabs-centered': centered }]"
|
||||
>
|
||||
<div class="tabs-nav">
|
||||
<div v-if="slots.leftExtra" class="tabs-nav-extra">
|
||||
<slot name="leftExtra" />
|
||||
</div>
|
||||
<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>
|
||||
<div v-if="slots.rightExtra" class="tabs-nav-extra">
|
||||
<slot name="rightExtra" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
Reference in new issue
Block a user