625 lines
14 KiB
Vue
625 lines
14 KiB
Vue
<script lang='ts' setup>
|
||
interface TabsProps {
|
||
modelValue?: string | number;
|
||
type?: "bar" | "line" | "segment";
|
||
size?: "small" | "medium" | "large";
|
||
animated?: boolean;
|
||
closable?: boolean;
|
||
addable?: boolean;
|
||
placement?: "top" | "bottom" | "left" | "right";
|
||
sticky?: boolean;
|
||
stickyTop?: string | number;
|
||
tabStyle?: string | Record<string, any>;
|
||
tabClass?: string;
|
||
paneStyle?: string | Record<string, any>;
|
||
paneClass?: string;
|
||
}
|
||
|
||
interface TabsEmits {
|
||
(e: "update:modelValue", value: string | number): void;
|
||
(e: "tabClick", key: string | number, event: Event): void;
|
||
(e: "tabClose", key: string | number): void;
|
||
(e: "tabAdd"): void;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<TabsProps>(), {
|
||
type: "bar",
|
||
size: "medium",
|
||
animated: true,
|
||
closable: false,
|
||
addable: false,
|
||
placement: "top",
|
||
sticky: false,
|
||
stickyTop: 0,
|
||
});
|
||
|
||
const emit = defineEmits<TabsEmits>();
|
||
|
||
// 内部状态
|
||
const currentTab = ref<string | number>();
|
||
const tabsRef = ref<HTMLElement>();
|
||
const activeBarRef = ref<HTMLElement>();
|
||
|
||
// 子组件列表
|
||
const tabPanes = ref<Array<{
|
||
name: string | number;
|
||
title: string;
|
||
disabled?: boolean;
|
||
closable?: boolean;
|
||
instance: any;
|
||
}>>([]);
|
||
|
||
// 使用provide/inject在父子组件间传递数据
|
||
const tabsContext = {
|
||
currentTab: readonly(currentTab),
|
||
type: toRef(props, "type"),
|
||
size: toRef(props, "size"),
|
||
closable: toRef(props, "closable"),
|
||
animated: toRef(props, "animated"),
|
||
placement: toRef(props, "placement"),
|
||
tabStyle: toRef(props, "tabStyle"),
|
||
tabClass: toRef(props, "tabClass"),
|
||
paneStyle: toRef(props, "paneStyle"),
|
||
paneClass: toRef(props, "paneClass"),
|
||
activateTab,
|
||
closeTab,
|
||
registerTabPane,
|
||
unregisterTabPane,
|
||
};
|
||
|
||
provide("tabs-context", tabsContext);
|
||
|
||
// 注册tab pane
|
||
function registerTabPane(pane: any) {
|
||
const existingIndex = tabPanes.value.findIndex(p => p.name === pane.name);
|
||
if (existingIndex === -1) {
|
||
tabPanes.value.push(pane);
|
||
// 如果当前没有激活的tab且这是第一个可用的tab,则激活它
|
||
if (!currentTab.value && !pane.disabled) {
|
||
currentTab.value = pane.name;
|
||
emit("update:modelValue", pane.name);
|
||
// 延迟更新active bar,确保DOM完全渲染
|
||
setTimeout(() => {
|
||
updateActiveBar();
|
||
}, 10);
|
||
}
|
||
}
|
||
}
|
||
|
||
function unregisterTabPane(pane: any) {
|
||
const index = tabPanes.value.findIndex(p => p.name === pane.name);
|
||
if (index > -1) {
|
||
tabPanes.value.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
// 激活tab
|
||
function activateTab(key: string | number, event?: Event) {
|
||
const pane = tabPanes.value.find(p => p.name === key);
|
||
if (!pane || pane.disabled)
|
||
return;
|
||
|
||
if (currentTab.value === key)
|
||
return;
|
||
|
||
currentTab.value = key;
|
||
emit("update:modelValue", key);
|
||
|
||
if (event) {
|
||
emit("tabClick", key, event);
|
||
}
|
||
|
||
// 更新active bar位置
|
||
nextTick(updateActiveBar);
|
||
}
|
||
|
||
// 关闭tab
|
||
function closeTab(key: string | number) {
|
||
emit("tabClose", key);
|
||
}
|
||
|
||
// 添加tab
|
||
function addTab() {
|
||
emit("tabAdd");
|
||
}
|
||
|
||
// 更新active bar位置(用于bar和line类型)
|
||
function updateActiveBar() {
|
||
if (props.type !== "bar" && props.type !== "line")
|
||
return;
|
||
if (!tabsRef.value || !activeBarRef.value)
|
||
return;
|
||
|
||
const activeTabEl = tabsRef.value.querySelector(`[data-tab-key="${currentTab.value}"]`) as HTMLElement;
|
||
if (!activeTabEl)
|
||
return;
|
||
|
||
// 获取tab label元素来计算更精确的宽度
|
||
const labelEl = activeTabEl.querySelector(".ui-tab__label") as HTMLElement;
|
||
|
||
if (labelEl) {
|
||
// 创建一个临时元素来测量文本的实际宽度
|
||
const tempEl = document.createElement("span");
|
||
tempEl.style.visibility = "hidden";
|
||
tempEl.style.position = "absolute";
|
||
tempEl.style.fontSize = window.getComputedStyle(labelEl).fontSize;
|
||
tempEl.style.fontFamily = window.getComputedStyle(labelEl).fontFamily;
|
||
tempEl.style.fontWeight = window.getComputedStyle(labelEl).fontWeight;
|
||
tempEl.textContent = labelEl.textContent;
|
||
document.body.appendChild(tempEl);
|
||
|
||
const textWidth = tempEl.offsetWidth;
|
||
document.body.removeChild(tempEl);
|
||
|
||
const tabsRect = tabsRef.value.getBoundingClientRect();
|
||
const labelRect = labelEl.getBoundingClientRect();
|
||
|
||
if (props.placement === "top" || props.placement === "bottom") {
|
||
// 居中对齐文本宽度
|
||
const leftOffset = labelRect.left - tabsRect.left + (labelRect.width - textWidth) / 2;
|
||
activeBarRef.value.style.left = `${leftOffset}px`;
|
||
activeBarRef.value.style.width = `${textWidth}px`;
|
||
}
|
||
else {
|
||
activeBarRef.value.style.top = `${labelRect.top - tabsRect.top}px`;
|
||
activeBarRef.value.style.height = `${textWidth}px`;
|
||
}
|
||
}
|
||
else {
|
||
// 降级到原来的逻辑
|
||
const tabsRect = tabsRef.value.getBoundingClientRect();
|
||
const activeRect = activeTabEl.getBoundingClientRect();
|
||
|
||
if (props.placement === "top" || props.placement === "bottom") {
|
||
activeBarRef.value.style.left = `${activeRect.left - tabsRect.left}px`;
|
||
activeBarRef.value.style.width = `${activeRect.width}px`;
|
||
}
|
||
else {
|
||
activeBarRef.value.style.top = `${activeRect.top - tabsRect.top}px`;
|
||
activeBarRef.value.style.height = `${activeRect.height}px`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 监听modelValue变化
|
||
watch(() => props.modelValue, (newValue) => {
|
||
if (newValue !== undefined && newValue !== currentTab.value) {
|
||
currentTab.value = newValue;
|
||
nextTick(updateActiveBar);
|
||
}
|
||
}, { immediate: true });
|
||
|
||
// 监听tabPanes变化,自动设置第一个tab为活跃状态
|
||
watch(tabPanes, (newPanes) => {
|
||
if ((!currentTab.value || currentTab.value === "") && newPanes.length > 0) {
|
||
const firstAvailablePane = newPanes.find(pane => !pane.disabled);
|
||
if (firstAvailablePane) {
|
||
currentTab.value = firstAvailablePane.name;
|
||
emit("update:modelValue", firstAvailablePane.name);
|
||
nextTick(updateActiveBar);
|
||
}
|
||
}
|
||
}, { flush: "post" });
|
||
|
||
// 组件挂载后初始化
|
||
onMounted(() => {
|
||
// 如果没有当前激活的tab但有可用的tabs,选择第一个
|
||
if (!currentTab.value && tabPanes.value.length > 0) {
|
||
const firstAvailablePane = tabPanes.value.find(pane => !pane.disabled);
|
||
if (firstAvailablePane) {
|
||
currentTab.value = firstAvailablePane.name;
|
||
emit("update:modelValue", firstAvailablePane.name);
|
||
}
|
||
}
|
||
|
||
// 延迟更新active bar,确保DOM完全渲染
|
||
setTimeout(() => {
|
||
updateActiveBar();
|
||
});
|
||
});
|
||
|
||
// 暴露方法
|
||
defineExpose({
|
||
registerTabPane,
|
||
unregisterTabPane,
|
||
updateActiveBar,
|
||
});
|
||
|
||
// 计算样式类
|
||
const tabsClasses = computed(() => [
|
||
"ui-tabs",
|
||
`ui-tabs--${props.type}`,
|
||
`ui-tabs--${props.size}`,
|
||
`ui-tabs--${props.placement}`,
|
||
{
|
||
"ui-tabs--animated": props.animated,
|
||
"ui-tabs--sticky": props.sticky,
|
||
},
|
||
]);
|
||
|
||
// 计算sticky样式
|
||
const stickyStyle = computed(() => {
|
||
if (!props.sticky)
|
||
return {};
|
||
|
||
const topValue = typeof props.stickyTop === "number"
|
||
? `${props.stickyTop}px`
|
||
: props.stickyTop;
|
||
|
||
return {
|
||
position: "sticky" as const,
|
||
top: topValue,
|
||
zIndex: 100,
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div :class="tabsClasses">
|
||
<div
|
||
class="ui-tabs__nav-wrapper"
|
||
:class="[`ui-tabs__nav-wrapper--${placement}`]"
|
||
:style="props.sticky ? stickyStyle : {}"
|
||
>
|
||
<div
|
||
ref="tabsRef"
|
||
class="ui-tabs__nav"
|
||
:class="[`ui-tabs__nav--${type}`, `ui-tabs__nav--${size}`]"
|
||
>
|
||
<!-- 渲染标签列表 -->
|
||
<div
|
||
v-for="pane in tabPanes"
|
||
:key="pane.name"
|
||
class="ui-tab" :class="[
|
||
`ui-tab--${type}`,
|
||
`ui-tab--${size}`,
|
||
{
|
||
'ui-tab--active': currentTab === pane.name,
|
||
'ui-tab--disabled': pane.disabled,
|
||
'ui-tab--closable': pane.closable || closable,
|
||
},
|
||
]"
|
||
:data-tab-key="pane.name"
|
||
:style="tabStyle"
|
||
@click="activateTab(pane.name, $event)"
|
||
>
|
||
<span class="ui-tab__label">
|
||
{{ pane.title }}
|
||
</span>
|
||
|
||
<!-- Close Button -->
|
||
<button
|
||
v-if="(pane.closable || closable) && type !== 'segment'"
|
||
class="ui-tab__close"
|
||
@click.stop="closeTab(pane.name)"
|
||
>
|
||
<ion-icon name="close-outline" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Active Bar (用于bar和line类型) -->
|
||
<div
|
||
v-if="type === 'bar' || type === 'line'"
|
||
ref="activeBarRef"
|
||
class="ui-tabs__active-bar"
|
||
:class="`ui-tabs__active-bar--${type}`"
|
||
/>
|
||
|
||
<!-- Add Button -->
|
||
<button
|
||
v-if="addable"
|
||
class="ui-tabs__add-btn"
|
||
@click="addTab"
|
||
>
|
||
<ion-icon name="add-outline" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签内容区域 -->
|
||
<div class="ui-tabs__content">
|
||
<slot />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@reference "tailwindcss";
|
||
|
||
.ui-tabs {
|
||
@apply w-full;
|
||
--ui-tabs-primary: var(--ion-color-primary);
|
||
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb);
|
||
--ui-tabs-background: var(--ion-background-color);
|
||
--ui-tabs-text: var(--ion-text-color);
|
||
--ui-tabs-text-rgb: var(--ion-text-color-rgb);
|
||
}
|
||
|
||
/* 导航包装器 */
|
||
.ui-tabs__nav-wrapper {
|
||
@apply relative;
|
||
}
|
||
|
||
/* Sticky 布局 */
|
||
.ui-tabs--sticky .ui-tabs__nav-wrapper {
|
||
background-color: var(--ui-tabs-background);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.ui-tabs--sticky.ui-tabs--animated .ui-tabs__nav-wrapper {
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.ui-tabs__nav-wrapper--bottom {
|
||
border-top: 1px solid var(--ion-border-color, #e5e7eb);
|
||
}
|
||
|
||
.ui-tabs__nav-wrapper--left {
|
||
border-right: 1px solid var(--ion-border-color, #e5e7eb);
|
||
}
|
||
|
||
.ui-tabs__nav-wrapper--right {
|
||
border-left: 1px solid var(--ion-border-color, #e5e7eb);
|
||
}
|
||
|
||
/* 导航容器 */
|
||
.ui-tabs__nav {
|
||
@apply relative flex;
|
||
}
|
||
|
||
.ui-tabs__nav--bar,
|
||
.ui-tabs__nav--line {
|
||
@apply gap-6;
|
||
}
|
||
|
||
.ui-tabs__nav--segment {
|
||
@apply gap-1 p-1 rounded-lg;
|
||
background-color: var(--ion-color-light, #f8f9fa);
|
||
}
|
||
|
||
/* 尺寸 */
|
||
.ui-tabs__nav--small {
|
||
@apply text-sm;
|
||
}
|
||
|
||
.ui-tabs__nav--medium {
|
||
@apply text-base;
|
||
}
|
||
|
||
.ui-tabs__nav--large {
|
||
@apply text-lg;
|
||
}
|
||
|
||
/* Tab Labels */
|
||
.ui-tab {
|
||
@apply relative flex items-center justify-between cursor-pointer transition-all duration-200;
|
||
color: var(--ion-color-medium, #6b7280);
|
||
}
|
||
|
||
.ui-tab:hover:not(.ui-tab--disabled):not(.ui-tab--active) {
|
||
color: var(--ion-color-primary) !important;
|
||
}
|
||
|
||
.ui-tab--active {
|
||
@apply font-medium;
|
||
color: var(--ion-color-primary) !important;
|
||
}
|
||
|
||
.ui-tab--disabled {
|
||
@apply cursor-not-allowed opacity-50;
|
||
color: var(--ion-color-light-shade, #d1d5db) !important;
|
||
}
|
||
|
||
/* Bar 类型 */
|
||
.ui-tab--bar {
|
||
@apply py-3 border-b-2 border-transparent;
|
||
}
|
||
|
||
.ui-tab--bar.ui-tab--active {
|
||
border-color: var(--ion-color-primary) !important;
|
||
}
|
||
|
||
/* Line 类型 */
|
||
.ui-tab--line {
|
||
@apply py-3;
|
||
}
|
||
|
||
/* Segment 类型 */
|
||
.ui-tab--segment {
|
||
@apply px-3 py-2 rounded-md;
|
||
}
|
||
|
||
.ui-tab--segment.ui-tab--active {
|
||
background-color: var(--ui-tabs-background);
|
||
box-shadow:
|
||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
/* 尺寸变化 */
|
||
.ui-tab--small {
|
||
@apply text-sm;
|
||
}
|
||
|
||
.ui-tab--small.ui-tab--bar,
|
||
.ui-tab--small.ui-tab--line {
|
||
@apply py-2;
|
||
}
|
||
|
||
.ui-tab--small.ui-tab--segment {
|
||
@apply px-2 py-1;
|
||
}
|
||
|
||
.ui-tab--large {
|
||
@apply text-lg;
|
||
}
|
||
|
||
.ui-tab--large.ui-tab--bar,
|
||
.ui-tab--large.ui-tab--line {
|
||
@apply py-4;
|
||
}
|
||
|
||
.ui-tab--large.ui-tab--segment {
|
||
@apply px-4 py-3;
|
||
}
|
||
|
||
/* Tab Label */
|
||
.ui-tab__label {
|
||
@apply flex-1;
|
||
}
|
||
|
||
/* Close Button */
|
||
.ui-tab__close {
|
||
@apply ml-2 p-1 rounded-full opacity-60 hover:opacity-100 transition-all duration-200;
|
||
background-color: transparent;
|
||
}
|
||
|
||
.ui-tab__close:hover {
|
||
background-color: rgba(var(--ui-tabs-primary-rgb), 0.1);
|
||
}
|
||
|
||
.ui-tab__close ion-icon {
|
||
@apply text-sm;
|
||
}
|
||
|
||
/* Active Bar */
|
||
.ui-tabs__active-bar {
|
||
@apply absolute transition-all duration-300 ease-out;
|
||
}
|
||
|
||
.ui-tabs__active-bar--bar {
|
||
@apply bottom-0;
|
||
height: 2px;
|
||
background-color: var(--ui-tabs-primary);
|
||
}
|
||
|
||
.ui-tabs__active-bar--line {
|
||
@apply bottom-0 h-px;
|
||
background-color: var(--ui-tabs-primary);
|
||
}
|
||
|
||
/* Add Button */
|
||
.ui-tabs__add-btn {
|
||
@apply flex items-center justify-center px-3 py-2 transition-colors duration-200;
|
||
color: var(--ion-color-medium, #6b7280);
|
||
}
|
||
|
||
.ui-tabs__add-btn:hover {
|
||
color: var(--ui-tabs-primary);
|
||
}
|
||
|
||
.ui-tabs__add-btn ion-icon {
|
||
@apply text-lg;
|
||
}
|
||
|
||
/* 内容区域 */
|
||
.ui-tabs__content {
|
||
@apply relative;
|
||
}
|
||
|
||
/* 动画支持 */
|
||
.ui-tabs--animated .ui-tabs__content {
|
||
@apply overflow-hidden;
|
||
}
|
||
|
||
/* 垂直布局 */
|
||
.ui-tabs--left,
|
||
.ui-tabs--right {
|
||
@apply flex;
|
||
}
|
||
|
||
.ui-tabs--left .ui-tabs__nav-wrapper {
|
||
@apply min-w-48;
|
||
}
|
||
|
||
.ui-tabs--right .ui-tabs__nav-wrapper {
|
||
@apply min-w-48 order-2;
|
||
}
|
||
|
||
.ui-tabs--left .ui-tabs__nav,
|
||
.ui-tabs--right .ui-tabs__nav {
|
||
@apply flex-col;
|
||
}
|
||
|
||
.ui-tabs--left .ui-tabs__content,
|
||
.ui-tabs--right .ui-tabs__content {
|
||
@apply flex-1;
|
||
}
|
||
|
||
/* 垂直布局tab调整 */
|
||
.ui-tabs--left .ui-tab,
|
||
.ui-tabs--right .ui-tab {
|
||
@apply justify-start w-full;
|
||
}
|
||
|
||
.ui-tabs--left .ui-tab--bar,
|
||
.ui-tabs--right .ui-tab--bar {
|
||
@apply border-b-0 border-r-2;
|
||
}
|
||
|
||
.ui-tabs--left .ui-tab--bar.ui-tab--active,
|
||
.ui-tabs--right .ui-tab--bar.ui-tab--active {
|
||
border-color: var(--ui-tabs-primary);
|
||
}
|
||
|
||
/* 垂直布局active bar */
|
||
.ui-tabs--left .ui-tabs__active-bar--bar,
|
||
.ui-tabs--right .ui-tabs__active-bar--bar {
|
||
@apply right-0 w-0.5 h-0.5;
|
||
}
|
||
|
||
.ui-tabs--left .ui-tabs__active-bar--line,
|
||
.ui-tabs--right .ui-tabs__active-bar--line {
|
||
@apply right-0 w-px h-0.5;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 640px) {
|
||
.ui-tabs__nav--small .ui-tab--bar,
|
||
.ui-tabs__nav--small .ui-tab--line {
|
||
@apply py-2;
|
||
}
|
||
|
||
.ui-tabs__nav--medium .ui-tab--bar,
|
||
.ui-tabs__nav--medium .ui-tab--line {
|
||
@apply py-2;
|
||
}
|
||
|
||
.ui-tab__close {
|
||
@apply ml-1;
|
||
}
|
||
}
|
||
|
||
/* Dark mode 支持 */
|
||
.ion-palette-dark {
|
||
.ui-tabs {
|
||
--ui-tabs-primary: var(--ion-color-primary, #ffffff);
|
||
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb, 255, 255, 255);
|
||
--ui-tabs-background: var(--ion-background-color, #1a1a1a);
|
||
--ui-tabs-text: var(--ion-text-color, #ffffff);
|
||
}
|
||
|
||
.ui-tabs__nav--segment {
|
||
background-color: var(--ion-color-dark, #2d2d2d);
|
||
}
|
||
|
||
.ui-tab--segment.ui-tab--active {
|
||
background-color: var(--ion-color-dark-tint, #404040);
|
||
}
|
||
}
|
||
|
||
/* 触摸设备优化 */
|
||
@media (hover: none) and (pointer: coarse) {
|
||
.ui-tab--bar,
|
||
.ui-tab--line,
|
||
.ui-tab--segment {
|
||
@apply py-3;
|
||
}
|
||
|
||
.ui-tab__close {
|
||
@apply p-2;
|
||
}
|
||
}
|
||
</style>
|