Files
riwa-ionic/src/components/ui/tabs/index.vue

625 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>