feat: 添加 Tabs 和 TabPane 组件,支持动态标签页管理
This commit is contained in:
58
components.d.ts
vendored
58
components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
84
src/components/ui/tab-pane/index.vue
Normal file
84
src/components/ui/tab-pane/index.vue
Normal 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>
|
||||
602
src/components/ui/tabs/index.vue
Normal file
602
src/components/ui/tabs/index.vue
Normal 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>
|
||||
97
src/components/ui/tabs/types.ts
Normal file
97
src/components/ui/tabs/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user