Files
WAI_Project_UNIX/uni_modules/cool-ui/components/cl-tabs/cl-tabs.uvue
2025-07-21 16:47:04 +08:00

486 lines
9.8 KiB
Plaintext
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.
<template>
<view
class="cl-tabs"
:class="[
{
'cl-tabs--line': showLine,
'cl-tabs--slider': showSlider,
'cl-tabs--fill': fill,
'cl-tabs--disabled': disabled,
'is-dark': isDark
},
pt.className
]"
:style="{
height: parseRpx(height!)
}"
>
<scroll-view
class="cl-tabs__scrollbar"
:scroll-with-animation="true"
:scroll-x="true"
direction="horizontal"
:scroll-left="scrollLeft"
:show-scrollbar="false"
>
<view class="cl-tabs__inner">
<view
class="cl-tabs__item"
v-for="(item, index) in list"
:key="index"
:class="[pt.item?.className]"
:style="{
padding: `0 ${parseRpx(gutter)}`
}"
@tap="change(index)"
>
<slot name="item" :item="item" :active="item.isActive">
<text
class="cl-tabs__item-label"
:class="[
{
'is-active': item.isActive,
'is-disabled': item.disabled,
'is-dark': isDark,
'is-fill': fill
},
pt.text?.className
]"
:style="getTextStyle(item)"
>{{ item.label }}</text
>
</slot>
</view>
<template v-if="lineLeft > 0">
<view
class="cl-tabs__line"
:class="[pt.line?.className]"
:style="lineStyle"
v-if="showLine"
></view>
<view
class="cl-tabs__slider"
:class="[pt.slider?.className]"
:style="sliderStyle"
v-if="showSlider"
></view>
</template>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { type PropType, computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
import { isDark, isEmpty, isHarmony, isNull, parsePt, parseRpx, rpx2px } from "@/cool";
import type { ClTabsItem, PassThroughProps } from "../../types";
// 定义标签类型
type Item = {
label: string;
value: string | number;
disabled: boolean;
isActive: boolean;
};
defineOptions({
name: "cl-tabs"
});
defineSlots<{
item(props: { item: Item; active: boolean }): any;
}>();
const props = defineProps({
// 透传属性对象,允许外部自定义样式和属性
pt: {
type: Object,
default: () => ({})
},
// v-model绑定值表示当前选中的tab
modelValue: {
type: [String, Number] as PropType<string | number>,
default: ""
},
// 标签高度
height: {
type: [String, Number] as PropType<string | number>,
default: 80
},
// 标签列表
list: {
type: Array as PropType<ClTabsItem[]>,
default: () => []
},
// 是否填充标签
fill: {
type: Boolean,
default: false
},
// 标签间隔
gutter: {
type: Number,
default: 30
},
// 选中标签的颜色
color: {
type: String,
default: ""
},
// 未选中标签的颜色
unColor: {
type: String,
default: ""
},
// 是否显示下划线
showLine: {
type: Boolean,
default: true
},
// 是否显示滑块
showSlider: {
type: Boolean,
default: false
},
// 是否禁用
disabled: {
type: Boolean,
default: false
}
});
// 定义事件发射器
const emit = defineEmits(["update:modelValue", "change"]);
// 获取当前组件实例的proxy对象
const { proxy } = getCurrentInstance()!;
// 定义透传类型,便于类型推断和扩展
type PassThrough = {
// 额外类名
className?: string;
// 文本的透传属性
text?: PassThroughProps;
// 单个item的透传属性
item?: PassThroughProps;
// 下划线的透传属性
line?: PassThroughProps;
// 滑块的透传属性
slider?: PassThroughProps;
};
// 计算透传属性,便于样式和属性扩展
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前选中的标签值
const active = ref(props.modelValue);
// 计算标签列表增加isActive和disabled属性便于渲染和状态判断
const list = computed(() =>
props.list.map((e) => {
return {
label: e.label,
value: e.value,
// 如果未传disabled则默认为false
disabled: e.disabled ?? false,
// 判断当前标签是否为激活状态
isActive: e.value == active.value
} as Item;
})
);
// 切换标签时触发,参数为索引
async function change(index: number) {
// 如果整个Tabs被禁用则不响应点击
if (props.disabled) {
return false;
}
// 获取当前点击标签的值
const { value, disabled } = list.value[index];
// 如果标签被禁用,则不响应点击
if (disabled) {
return false;
}
// 触发v-model的更新
emit("update:modelValue", value);
// 触发change事件
emit("change", value);
}
// 获取当前选中标签的下标未找到则返回0
function getIndex() {
const index = list.value.findIndex((e) => e.isActive);
return index == -1 ? 0 : index;
}
// 根据激活状态获取标签颜色
function getColor(isActive: boolean) {
let color: string;
// 选中时取props.color否则取props.unColor
if (isActive) {
color = props.color;
} else {
color = props.unColor;
}
return isEmpty(color) ? null : color;
}
// tab区域宽度
const tabWidth = ref(0);
// tab区域左侧偏移
const tabLeft = ref(0);
// 下划线左侧偏移
const lineLeft = ref(0);
// 滑块左侧偏移
const sliderLeft = ref(0);
// 滑块宽度
const sliderWidth = ref(0);
// 滚动条左侧偏移
const scrollLeft = ref(0);
// 单个标签的位置信息类型包含left和width
type ItemRect = {
left: number;
width: number;
};
// 所有标签的位置信息,响应式数组
const itemRects = ref<ItemRect[]>([]);
// 计算下划线样式
const lineStyle = computed(() => {
const style = new Map<string, string>();
style.set("transform", `translateX(${lineLeft.value}px)`);
// 获取选中颜色
const bgColor = getColor(true);
if (bgColor != null) {
style.set("backgroundColor", bgColor);
}
return style;
});
// 计算滑块样式
const sliderStyle = computed(() => {
const style = new Map<string, string>();
style.set("transform", `translateX(${sliderLeft.value}px)`);
style.set("width", sliderWidth.value + "px");
// 获取选中颜色
const bgColor = getColor(true);
if (bgColor != null) {
style.set("backgroundColor", bgColor);
}
return style;
});
// 获取文本样式
function getTextStyle(item: Item) {
const style = new Map<string, string>();
// 获取选中颜色
const color = getColor(item.isActive);
if (color != null) {
style.set("color", color);
}
return style;
}
// 更新下划线、滑块、滚动条等位置
function updatePosition() {
nextTick(() => {
if (!isEmpty(itemRects.value)) {
// 获取当前选中标签的位置信息
const item = itemRects.value[getIndex()];
// 如果标签存在
if (!isNull(item)) {
// 计算滚动条偏移,使选中项居中
let x = item.left - (tabWidth.value - item.width) / 2 - tabLeft.value;
// 防止滚动条偏移为负
if (x < 0) {
x = 0;
}
// 设置滚动条偏移
scrollLeft.value = x;
// 设置下划线偏移,使下划线居中于选中项
lineLeft.value = item.left + item.width / 2 - rpx2px(16) - tabLeft.value;
// 设置滑块左侧偏移
sliderLeft.value = item.left - tabLeft.value;
// 设置滑块宽度
sliderWidth.value = item.width;
}
}
});
}
// 获取所有标签的位置信息,便于后续计算
function getRects() {
// 创建选择器查询
uni.createSelectorQuery()
// 作用域限定为当前组件
.in(proxy)
// 选择所有标签元素
.selectAll(".cl-tabs__item")
// 获取rect和size信息
.fields({ rect: true, size: true }, () => {})
// 执行查询
.exec((nodes) => {
// 解析查询结果生成ItemRect数组
itemRects.value = (nodes[0] as NodeInfo[]).map((e) => {
return {
left: e.left ?? 0,
width: e.width ?? 0
} as ItemRect;
});
// 更新下划线、滑块等位置
updatePosition();
});
}
// 刷新tab区域的宽度和位置信息
function refresh() {
setTimeout(
() => {
// 创建选择器查询
uni.createSelectorQuery()
// 作用域限定为当前组件
.in(proxy)
// 选择tab容器
.select(".cl-tabs")
// 获取容器的left和width
.boundingClientRect((node) => {
// 设置tab左侧偏移
tabLeft.value = (node as NodeInfo).left ?? 0;
// 设置tab宽度
tabWidth.value = (node as NodeInfo).width ?? 0;
// 获取所有标签的位置信息
getRects();
})
.exec();
},
isHarmony() ? 50 : 0
);
}
// 监听modelValue变化更新active和位置
watch(
computed(() => props.modelValue!),
(val: string | number) => {
// 更新当前选中标签
active.value = val;
// 更新下划线、滑块等位置
updatePosition();
},
{
// 立即执行一次
immediate: true
}
);
// 监听标签列表变化,刷新布局
watch(
computed(() => props.list),
() => {
nextTick(() => {
refresh();
});
}
);
// 组件挂载时刷新布局,确保初始渲染正确
onMounted(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.cl-tabs {
&__scrollbar {
@apply flex flex-row w-full h-full;
}
&__inner {
@apply flex flex-row relative;
}
&__item {
@apply flex flex-row items-center justify-center h-full relative z-10;
&-label {
@apply text-surface-700 text-md;
&.is-dark {
@apply text-white;
}
&.is-active {
@apply text-primary-500;
}
&.is-disabled {
@apply text-surface-400;
}
}
}
&__line {
@apply bg-primary-500 rounded-md absolute;
height: 4rpx;
width: 16px;
bottom: 2rpx;
left: 0;
transition-property: transform;
transition-duration: 0.3s;
}
&__slider {
@apply bg-primary-500 rounded-lg absolute h-full w-full;
top: 0;
left: 0;
transition-property: transform;
transition-duration: 0.3s;
}
&--slider {
@apply bg-surface-50 rounded-lg;
.cl-tabs__item-label {
&.is-active {
@apply text-white;
}
}
&.is-dark {
@apply bg-surface-700;
}
}
&--fill {
.cl-tabs__inner {
@apply w-full;
}
.cl-tabs__item {
flex: 1;
}
.cl-tabs__item-label {
@apply text-center;
}
}
&--disabled {
@apply opacity-50;
}
}
</style>