我们先做一个简单的需求分析
可以选择标签页排列的方向
选中的标签页应当有下划线高亮显示
切换选中时,下划线应当有动画效果
应当允许更换颜色
那么可以整理出以下参数表格
参数 含义 类型 可选值 默认值direction 方向 string row / column row
selected 默认选中 string 子项的 name 必填
color 颜色 string 任意合法颜色值 #d3c8f5
通过为子项设置 name 属性,来指定默认值
骨架 本体通过需求分析我们可以得到如下骨架:
<template> <div :style="{ '--color': color }" ref="container" :direction="direction" > <div> <button v-for="(title, index) in titles" :key="index" :class="{ selected: names[index] === selected }" @click="select(index)" :ref=" (el) => { if (names[index] === selected) { selectedItem = el; } } " > {{ title }} </button> <div ref="indicator"></div> </div> <div></div> <div> <component :is="content" :key="selected" /> </div> </div> </template>注意
这里我们用一个 div 来充当下划线,再使用一个新的 component 来显示用户输入的内容
我们还需要为标签页创建子组件,即 Tab 组件
子组件通过之前的分析,可以得出子组件 Tab 的骨架如下:
<template> <div> <slot></slot> </div> </template>另外,我们还需要定义一个参数,也就是标签的标题,所以还应该有如下声明与导出:
declare const props: { title: string; }; export default { install: function (Vue) { Vue.component(this.name, this); }, name: "JeremyTab", props: { title: { type: String, default: "标签页", }, }, }; 功能首先,我们先在 TypeScript 中声明:
declare const props: { direction?: "row" | "column"; selected: String; color: String; }; declare const context: SetupContext;其次,再在 export default 中,写入我们的参数:
export default { name: "JeremyTabs", props: { direction: { type: String, default: "row", }, selected: { type: String, required: true, }, color: { type: String, default: "#8c6fef", }, }, };再次,再补全 setup 方法:
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.forEach((slot) => { if (slot.type !== JeremyTab) { throw new Error("一级子标签必须是 JeremyTab"); } if (!slot.props) { throw new Error("存在 JeremyTab 属性列为空"); } if (!("title" in slot.props)) { throw new Error("JeremyTab 缺少属性 title"); } if (!("name" in slot.props)) { throw new Error("JeremyTab 缺少属性 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" } ); }); const select = (index) => { context.emit("update:selected", names[index]); }; return { container, selectedItem, indicator, slots, titles, names, content, select, }; }, 样式表最后,再补全样式表
$theme-color: var(--color); .jeremy-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; } } .jeremy-tabs[direction="column"] { flex-direction: row; > .jeremy-tabs-titles { flex-direction: column; } > .jeremy-tabs-content { padding: 2px 10px; } } 测试将 JeremyTabs 组件引入到测试文档,查看一下运行效果