feat: 添加 Tabs 和 TabPane 组件,支持动态标签页管理

This commit is contained in:
2025-12-17 18:04:40 +07:00
parent 61cbcce579
commit f3b7931d78
5 changed files with 820 additions and 43 deletions

58
components.d.ts vendored
View File

@@ -12,46 +12,40 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Avatar: typeof import('./src/components/ui/avatar/index.vue')['default']
Collapse: typeof import('./src/components/ui/collapse/index.vue')['default']
Datetime: typeof import('./src/components/ui/datetime/index.vue')['default']
Default: typeof import('./src/components/layout/default.vue')['default']
Divider: typeof import('./src/components/ui/divider/index.vue')['default']
IIcBaselineDataSaverOff: typeof import('~icons/ic/baseline-data-saver-off')['default']
IIcBaselineDownloading: typeof import('~icons/ic/baseline-downloading')['default']
IIcRoundArrowForwardIos: typeof import('~icons/ic/round-arrow-forward-ios')['default']
Input: typeof import('./src/components/ui/input/index.vue')['default']
InputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
IonApp: typeof import('@ionic/vue')['IonApp']
IonAvatar: typeof import('@ionic/vue')['IonAvatar']
IonBackButton: typeof import('@ionic/vue')['IonBackButton']
IonBreadcrumb: typeof import('@ionic/vue')['IonBreadcrumb']
IonBreadcrumbs: typeof import('@ionic/vue')['IonBreadcrumbs']
IonButton: typeof import('@ionic/vue')['IonButton']
IonButtons: typeof import('@ionic/vue')['IonButtons']
IonContent: typeof import('@ionic/vue')['IonContent']
IonDatetime: typeof import('@ionic/vue')['IonDatetime']
IonDatetimeButton: typeof import('@ionic/vue')['IonDatetimeButton']
IonHeader: typeof import('@ionic/vue')['IonHeader']
IonIcon: typeof import('@ionic/vue')['IonIcon']
IonInputOtp: typeof import('@ionic/vue')['IonInputOtp']
IonItem: typeof import('@ionic/vue')['IonItem']
IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList']
IonModal: typeof import('@ionic/vue')['IonModal']
IonNote: typeof import('@ionic/vue')['IonNote']
IonPage: typeof import('@ionic/vue')['IonPage']
IonRadio: typeof import('@ionic/vue')['IonRadio']
IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
IonRippleEffect: typeof import('@ionic/vue')['IonRippleEffect']
IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
IonSearchbar: typeof import('@ionic/vue')['IonSearchbar']
IonSegment: typeof import('@ionic/vue')['IonSegment']
IonSegmentButton: typeof import('@ionic/vue')['IonSegmentButton']
IonSelect: typeof import('@ionic/vue')['IonSelect']
IonSelectOption: typeof import('@ionic/vue')['IonSelectOption']
IonTabBar: typeof import('@ionic/vue')['IonTabBar']
IonTabButton: typeof import('@ionic/vue')['IonTabButton']
IonTabs: typeof import('@ionic/vue')['IonTabs']
IonText: typeof import('@ionic/vue')['IonText']
IonTitle: typeof import('@ionic/vue')['IonTitle']
IonToolbar: typeof import('@ionic/vue')['IonToolbar']
LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
Result: typeof import('./src/components/ui/result/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
Tabs: typeof import('./src/components/ui/tabs/index.vue')['default']
UiAvatar: typeof import('./src/components/ui/avatar/index.vue')['default']
UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default']
UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default']
@@ -59,51 +53,48 @@ declare module 'vue' {
UiInput: typeof import('./src/components/ui/input/index.vue')['default']
UiInputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
UiResult: typeof import('./src/components/ui/result/index.vue')['default']
UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default']
UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default']
UiTabsTabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
}
}
// For TSX support
declare global {
const Avatar: typeof import('./src/components/ui/avatar/index.vue')['default']
const Collapse: typeof import('./src/components/ui/collapse/index.vue')['default']
const Datetime: typeof import('./src/components/ui/datetime/index.vue')['default']
const Default: typeof import('./src/components/layout/default.vue')['default']
const Divider: typeof import('./src/components/ui/divider/index.vue')['default']
const IIcBaselineDataSaverOff: typeof import('~icons/ic/baseline-data-saver-off')['default']
const IIcBaselineDownloading: typeof import('~icons/ic/baseline-downloading')['default']
const IIcRoundArrowForwardIos: typeof import('~icons/ic/round-arrow-forward-ios')['default']
const Input: typeof import('./src/components/ui/input/index.vue')['default']
const InputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
const IonApp: typeof import('@ionic/vue')['IonApp']
const IonAvatar: typeof import('@ionic/vue')['IonAvatar']
const IonBackButton: typeof import('@ionic/vue')['IonBackButton']
const IonBreadcrumb: typeof import('@ionic/vue')['IonBreadcrumb']
const IonBreadcrumbs: typeof import('@ionic/vue')['IonBreadcrumbs']
const IonButton: typeof import('@ionic/vue')['IonButton']
const IonButtons: typeof import('@ionic/vue')['IonButtons']
const IonContent: typeof import('@ionic/vue')['IonContent']
const IonDatetime: typeof import('@ionic/vue')['IonDatetime']
const IonDatetimeButton: typeof import('@ionic/vue')['IonDatetimeButton']
const IonHeader: typeof import('@ionic/vue')['IonHeader']
const IonIcon: typeof import('@ionic/vue')['IonIcon']
const IonInputOtp: typeof import('@ionic/vue')['IonInputOtp']
const IonItem: typeof import('@ionic/vue')['IonItem']
const IonLabel: typeof import('@ionic/vue')['IonLabel']
const IonList: typeof import('@ionic/vue')['IonList']
const IonModal: typeof import('@ionic/vue')['IonModal']
const IonNote: typeof import('@ionic/vue')['IonNote']
const IonPage: typeof import('@ionic/vue')['IonPage']
const IonRadio: typeof import('@ionic/vue')['IonRadio']
const IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
const IonRippleEffect: typeof import('@ionic/vue')['IonRippleEffect']
const IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
const IonSearchbar: typeof import('@ionic/vue')['IonSearchbar']
const IonSegment: typeof import('@ionic/vue')['IonSegment']
const IonSegmentButton: typeof import('@ionic/vue')['IonSegmentButton']
const IonSelect: typeof import('@ionic/vue')['IonSelect']
const IonSelectOption: typeof import('@ionic/vue')['IonSelectOption']
const IonTabBar: typeof import('@ionic/vue')['IonTabBar']
const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
const IonTabs: typeof import('@ionic/vue')['IonTabs']
const IonText: typeof import('@ionic/vue')['IonText']
const IonTitle: typeof import('@ionic/vue')['IonTitle']
const IonToolbar: typeof import('@ionic/vue')['IonToolbar']
const LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
const Result: typeof import('./src/components/ui/result/index.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const TabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
const Tabs: typeof import('./src/components/ui/tabs/index.vue')['default']
const UiAvatar: typeof import('./src/components/ui/avatar/index.vue')['default']
const UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default']
const UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default']
@@ -111,4 +102,7 @@ declare global {
const UiInput: typeof import('./src/components/ui/input/index.vue')['default']
const UiInputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
const UiResult: typeof import('./src/components/ui/result/index.vue')['default']
const UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default']
const UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default']
const UiTabsTabPane: typeof import('./src/components/ui/tabs/tab-pane.vue')['default']
}

View File

@@ -0,0 +1,84 @@
<script lang='ts' setup>
interface TabPaneProps {
name: string | number;
title: string;
disabled?: boolean;
closable?: boolean;
lazy?: boolean;
}
const props = defineProps<TabPaneProps>();
// 注入父组件上下文
const tabsContext = inject<any>("tabs-context");
if (!tabsContext) {
throw new Error("TabPane must be used inside Tabs");
}
// 当前是否为活跃状态
const isActive = computed(() => tabsContext.currentTab.value === props.name);
// 是否已经渲染过用于lazy加载
const hasRendered = ref(false);
// 监听active状态变化
watch(isActive, (active) => {
if (active && !hasRendered.value) {
hasRendered.value = true;
}
}, { immediate: true });
// 注册到父组件
onMounted(() => {
tabsContext.registerTabPane({
name: props.name,
title: props.title,
disabled: props.disabled,
closable: props.closable,
});
});
// 卸载时注销
onUnmounted(() => {
tabsContext.unregisterTabPane({
name: props.name,
title: props.title,
disabled: props.disabled,
closable: props.closable,
});
});
// 计算pane样式类
const paneClasses = computed(() => [
"ui-tab-pane",
{
"ui-tab-pane--active": isActive.value,
"ui-tab-pane--animated": tabsContext.animated.value,
},
]);
</script>
<template>
<!-- Tab Content -->
<div
v-if="!lazy || hasRendered"
v-show="isActive"
:class="paneClasses"
:style="tabsContext.paneStyle.value"
>
<slot />
</div>
</template>
<style scoped>
@reference "tailwindcss";
/* Tab Panes */
.ui-tab-pane {
@apply w-full;
}
.ui-tab-pane--animated {
@apply transition-opacity duration-200;
}
</style>

View File

@@ -0,0 +1,602 @@
<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";
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",
});
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,
},
]);
</script>
<template>
<div :class="tabsClasses">
<!-- 标签头部 -->
<div class="ui-tabs__nav-wrapper" :class="[`ui-tabs__nav-wrapper--${placement}`]">
<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, #007aff);
--ui-tabs-primary-rgb: var(--ion-color-primary-rgb, 0, 122, 255);
--ui-tabs-background: var(--ion-background-color, #ffffff);
--ui-tabs-text: var(--ion-text-color, #000000);
--ui-tabs-text-rgb: var(--ion-text-color-rgb, 0, 0, 0);
}
/* 导航包装器 */
.ui-tabs__nav-wrapper {
@apply relative;
}
.ui-tabs__nav-wrapper--top {
}
.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 {
color: var(--ion-color-dark, #374151);
}
.ui-tab--active {
@apply font-medium;
color: var(--ui-tabs-primary);
}
.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(--ui-tabs-primary);
}
.ui-tab--bar:hover:not(.ui-tab--disabled):not(.ui-tab--active) {
background-color: rgba(var(--ui-tabs-primary-rgb), 0.05);
}
/* Line 类型 */
.ui-tab--line {
@apply py-3;
}
.ui-tab--line:hover:not(.ui-tab--disabled):not(.ui-tab--active) {
background-color: rgba(var(--ui-tabs-primary-rgb), 0.05);
}
/* 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--segment:hover:not(.ui-tab--disabled):not(.ui-tab--active) {
background-color: rgba(var(--ui-tabs-primary-rgb), 0.1);
}
/* 尺寸变化 */
.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 支持 */
@media (prefers-color-scheme: dark) {
.ui-tabs {
--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>

View File

@@ -0,0 +1,97 @@
export interface TabItem {
name: string | number;
title: string;
disabled?: boolean;
closable?: boolean;
lazy?: boolean;
}
export interface TabsProps {
/** 当前激活的标签页名称 */
modelValue?: string | number;
/** 标签页类型 */
type?: "bar" | "line" | "segment";
/** 标签页尺寸 */
size?: "small" | "medium" | "large";
/** 是否开启切换动画 */
animated?: boolean;
/** 是否可关闭 */
closable?: boolean;
/** 是否可添加 */
addable?: boolean;
/** 标签页位置 */
placement?: "top" | "bottom" | "left" | "right";
/** 标签样式 */
tabStyle?: string | Record<string, any>;
/** 标签类名 */
tabClass?: string;
/** 面板样式 */
paneStyle?: string | Record<string, any>;
/** 面板类名 */
paneClass?: string;
}
export 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;
}
export interface TabPaneProps {
/** 标签页标识,必须唯一 */
name: string | number;
/** 标签页标题 */
title: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否可关闭 */
closable?: boolean;
/** 是否懒加载 */
lazy?: boolean;
}
export interface TabsContext {
/** 当前激活的标签页 */
currentTab: Readonly<Ref<string | number | undefined>>;
/** 标签页类型 */
type: Readonly<Ref<"bar" | "line" | "segment">>;
/** 标签页尺寸 */
size: Readonly<Ref<"small" | "medium" | "large">>;
/** 是否可关闭 */
closable: Readonly<Ref<boolean>>;
/** 是否开启动画 */
animated: Readonly<Ref<boolean>>;
/** 标签页位置 */
placement: Readonly<Ref<"top" | "bottom" | "left" | "right">>;
/** 标签样式 */
tabStyle: Readonly<Ref<string | Record<string, any> | undefined>>;
/** 标签类名 */
tabClass: Readonly<Ref<string | undefined>>;
/** 面板样式 */
paneStyle: Readonly<Ref<string | Record<string, any> | undefined>>;
/** 面板类名 */
paneClass: Readonly<Ref<string | undefined>>;
/** 激活标签页方法 */
activateTab: (key: string | number, event?: Event) => void;
/** 关闭标签页方法 */
closeTab: (key: string | number) => void;
/** 注册标签页 */
registerTabPane: (pane: TabItem) => void;
/** 注销标签页 */
unregisterTabPane: (pane: TabItem) => void;
}
/** Tabs 组件实例类型 */
export interface TabsInstance {
/** 注册标签页 */
registerTabPane: (pane: TabItem) => void;
/** 注销标签页 */
unregisterTabPane: (pane: TabItem) => void;
/** 更新活跃指示条位置 */
updateActiveBar: () => void;
}

View File

@@ -1,22 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["ESNext", "DOM"],
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["./components"]
"resolveJsonModule": true,
"types": ["./components"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts", "auto-imports.d.ts", "types/*.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
"references": [{ "path": "./tsconfig.node.json" }],
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts", "auto-imports.d.ts", "types/*.d.ts"]
}