UI 框架-Tabs 组件

大多数网站都有的标签页


需求分析

惯例先行需求分析

  1. 可以选择标签页排列的方向
  2. 选中的标签页应当有下划线显示
  3. 切换选中时,下划线应当有动画效果
  4. 应当允许更换颜色

那么可以整理出以下参数表格

参数 含义 类型 可选值 默认值
direction 方向 string row / column row
selected 默认选中 string 子项的 name 必填
color 颜色 string 任意合法颜色值 #f3678e

通过为子项设置 name 属性,来指定默认值

骨架

本体

容易得到如下骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
<div
class="laby-tabs"
:style="{ '--color': color }"
ref="container"
:direction="direction"
>
<div class="laby-tabs-titles">
<button
v-for="(title, index) in titles"
:key="index"
class="laby-tabs-title"
:class="{ selected: names[index] === selected }"
@click="select(index)"
:ref="
(el) => {
if (names[index] === selected) {
selectedItem = el;
}
}
"
>
{{ title }}
</button>
<div class="laby-tabs-indicator" ref="indicator"></div>
</div>
<div class="laby-tabs-divider"></div>
<div class="laby-tabs-content">
<component :is="content" :key="selected" />
</div>
</div>
</template>

使用一个单独的 div 来充当下划线,并且使用一个新的 component 来显示用户输入的内容

那么既然标签页叫做 Tabs,那我们就新建一个 Tab,命名为标签,用来当做子组件吧

子组件 Tab

容易得到骨架

1
2
3
4
5
<template>
<div>
<slot></slot>
</div>
</template>

显然只有一个参数,就是标题,所以有如下 script

1
2
3
4
5
6
7
8
9
10
11
12
13
declare const props: {
title: string;
};

export default {
name: "LabyTab",
props: {
title: {
type: String,
default: "标签页",
},
},
};

功能

先 ts 声明

1
2
3
4
5
6
declare const props: {
direction?: "row" | "column";
selected: String;
color: String;
};
declare const context: SetupContext;

然后在 export default 中,写入我们的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default {
name: "LabyTabs",
props: {
direction: {
type: String,
default: "row",
},
selected: {
type: String,
required: true,
},
color: {
type: String,
default: "#f3678e",
},
},
}

再补全 setup 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
setup(props, context) {
if (!["row", "column"].includes(props.direction)) {
throw new Error("错误的方向");
}
const container = ref<HTMLDivElement>(null); // 获取容器
const selectedItem = ref<HTMLButtonElement>(null); // 获取选中项
const indicator = ref<HTMLDivElement>(null); // 获取下划线
const slots = context.slots.default(); // 通过上下文的slots属性,获得具体内容
slots.forEach((slot) => {
if (slot.type !== LabyTab) {
throw new Error("一级子标签必须是 LabyTab");
}
if (!slot.props) {
throw new Error("存在 LabyTab 属性列为空");
}
if (!("title" in slot.props)) {
throw new Error("LabyTab 缺少属性 title");
}
if (!("name" in slot.props)) {
throw new Error("LabyTab 缺少属性 name");
}
});
const titles = slots.map((slot) => slot.props.title);
const names = slots.map((slot) => slot.props.name);
if (!names.includes(props.selected)) {
throw new Error("指定了不存在的 selected 值");
}
const content = computed(() =>
slots.find((slot) => slot.props.name === props.selected)
); // 设置当前显示的内容
onMounted(() => {
watchEffect(
() => {
if (props.direction === "row") {
const { height } = selectedItem.value.getBoundingClientRect();
indicator.value.style.top = height + "px";
const { width } = selectedItem.value.getBoundingClientRect();
indicator.value.style.width = width + "px";
const left1 = container.value.getBoundingClientRect().left;
const left2 = selectedItem.value.getBoundingClientRect().left;
const left = left2 - left1;
indicator.value.style.left = left + "px";
} else {
const { height } = selectedItem.value.getBoundingClientRect();
indicator.value.style.height = height + "px";
const { width } = selectedItem.value.getBoundingClientRect();
indicator.value.style.left = width + "px";
const top1 = container.value.getBoundingClientRect().top;
const top2 = selectedItem.value.getBoundingClientRect().top;
const top = top2 - top1;
indicator.value.style.top = top + "px";
}
},
{ flush: "post" }
);
}); // 设置监听,用来修改下划线的位置
// 注意watchEffect的第二个参数,默认是pre,会导致变化发生在渲染之前,导致下划线错位
const select = (index) => {
context.emit("update:selected", names[index]);
}; // 选择新的标签

return {
container,
selectedItem,
indicator,
slots,
titles,
names,
content,
select,
};
}

注意,除了定义之外,还附加了很多验证,详情见注释

样式表

然后补全样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
$theme-color: var(--color);
.laby-tabs {
display: flex;
flex-direction: column;
position: relative;
&-titles {
display: flex;
}
&-title {
padding: 4px 6px;
border: none;
cursor: pointer;
outline: none;
background: white;
&:focus {
outline: none;
}
&:hover {
color: $theme-color;
}
&.selected {
color: $theme-color;
}
}
&-indicator {
position: absolute;
transition: all 250ms;
border: 1px solid $theme-color;
}
&-divider {
border: 1px solid rgb(184, 184, 184);
}
&-content {
padding: 8px 4px;
}
}
.laby-tabs[direction="column"] {
flex-direction: row;
> .laby-tabs-titles {
flex-direction: column;
}
> .laby-tabs-content {
padding: 2px 10px;
}
}

测试

引入文档页看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/components/Tabs.vue
<template>
<div>Tabs 文档</div>
<laby-tabs v-model:selected="selected" direction="column" color="blue">
<laby-tab title="标签页1" name="first"> 我是第一页的内容 </laby-tab>
<laby-tab title="标签页2" name="second"> 我是第二页的内容 </laby-tab>
</laby-tabs>
</template>
<script lang="ts">
import LabyTab from "../lib/Tab.vue";
import LabyTabs from "../lib/Tabs.vue";

import { ref } from "vue";
export default {
components: {
LabyTab,
LabyTabs,
},
setup() {
const selected = ref("first");
return { selected };
},
};
</script>

效果如图

效果图


感谢阅读

--It's the end.Thanks for your read.--