20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
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>