Vue3标签页(Tabs)

简介: 该组件提供可自定义的标签页功能,支持居中显示、不同尺寸与样式,并能设置当前激活选项。其主要属性包括标签页数组、居中显示、尺寸、样式、间隙及激活键值。示例展示了基本使用、卡片式标签页、禁用选项、左右滑动等功能。组件基于Vue3开发,并利用`useResizeObserver`等技术实现。[在线预览](https://themusecatcher.github.io/vue-amazing-ui/guide/components/tabs.html)。

可自定义设置以下属性:

  • 标签页数组(tabPages),类型:Array<{key: string|number, tab: string, content?: string | slot, disabled?: boolean}>,默认 []

  • 标签是否居中展示(centered),类型:boolean,默认 false

  • 标签页大小(size),类型:'small' | 'middle' | 'large',默认 'middle'

  • 标签页的样式(type),类型:'line' | 'card',默认 'line'

  • tabs 之前的间隙大小(gutter),单位px,类型:number,默认 undefined

  • 当前激活 tab 面板的 key(v-model:activeKey),类型:string | number,默认 undefined

效果如下图:在线预览

①创建标签页组件Tabs.vue:

<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { useResizeObserver, rafTimeout, cancelRaf } from '../utils'
interface Tab {
  key: string | number // 对应 activeKey
  tab: string // 标签页显示文字
  content?: string // 标签页内容 string | slot
  disabled?: boolean // 禁用对应标签页
}
interface Props {
  tabPages?: Tab[] // 标签页数组
  centered?: boolean // 标签是否居中展示
  size?: 'small' | 'middle' | 'large' // 标签页大小
  type?: 'line' | 'card' // 标签页的样式
  gutter?: number // tabs 之前的间隙大小,单位 px
  activeKey?: string | number // (v-model) 当前激活 tab 面板的 key
}
const props = withDefaults(defineProps<Props>(), {
  tabPages: () => [],
  centered: false,
  size: 'middle',
  type: 'line',
  gutter: undefined,
  activeKey: undefined
})
const tabsRef = ref() // 所有 tabs 的 ref 模板引用
const left = ref(0)
const width = ref(0)
const wrapRef = ref()
const wrapWidth = ref()
const navRef = ref()
const navWidth = ref()
const rafId = ref()
const showWheel = ref(false) // 导航是否有滚动
const scrollMax = ref(0) // 最大滚动距离
const scrollLeft = ref(0) // 滚动距离
const activeIndex = computed(() => {
  return props.tabPages.findIndex((page) => page.key === props.activeKey)
})
watch(
  () => props.activeKey,
  () => {
    getBarDisplay()
  },
  {
    flush: 'post'
  }
)
useResizeObserver([wrapRef, navRef], () => {
  getNavWidth()
})
onMounted(() => {
  getNavWidth()
})
const emits = defineEmits(['update:activeKey', 'change'])
const transition = ref(false)
function getBarDisplay() {
  const el = tabsRef.value[activeIndex.value]
  if (el) {
    left.value = el.offsetLeft
    width.value = el.offsetWidth
    if (showWheel.value) {
      if (left.value < scrollLeft.value) {
        transition.value = true
        scrollLeft.value = left.value
        rafId.value && cancelRaf(rafId.value)
        rafId.value = rafTimeout(() => {
          transition.value = false
        }, 150)
      }
      const targetScroll = left.value + width.value - wrapWidth.value
      if (targetScroll > scrollLeft.value) {
        transition.value = true
        scrollLeft.value = targetScroll
        rafId.value && cancelRaf(rafId.value)
        rafId.value = rafTimeout(() => {
          transition.value = false
        }, 150)
      }
    }
  } else {
    left.value = 0
    width.value = 0
  }
}
function getNavWidth() {
  wrapWidth.value = wrapRef.value.offsetWidth
  navWidth.value = navRef.value.offsetWidth
  if (navWidth.value > wrapWidth.value) {
    showWheel.value = true
    scrollMax.value = navWidth.value - wrapWidth.value
    scrollLeft.value = scrollMax.value
  } else {
    showWheel.value = false
    scrollLeft.value = 0
  }
  getBarDisplay()
}
function onTab(key: string | number) {
  emits('update:activeKey', key)
  emits('change', key)
}
/*
  (触摸板滑动也会触发)监听滚轮事件,结合 transform: translate(${scrollLeft}px, 0) 模拟滚动效果
  参考文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Element/wheel_event
  WheelEvent:
  事件属性:
  WheelEvent.deltaX 只读:返回一个浮点数(double),表示水平方向的滚动量。
  WheelEvent.deltaY 只读:返回一个浮点数(double),表示垂直方向的滚动量。
  WheelEvent.deltaZ 只读:返回一个浮点数(double)表示 z 轴方向的滚动量。
  WheelEvent.deltaMode 只读:返回一个无符号长整型数(unsigned long),表示 delta* 值滚动量的单位。
*/
function onWheel(e: WheelEvent) {
  if (e.deltaX !== 0) {
    // 防止标签页处触摸板上下滚动不生效
    // e.preventDefault() // 禁止浏览器捕获触摸板滑动事件
    const scrollX = e.deltaX * 1 // 滚轮的横向滚动量
    if (scrollLeft.value + scrollX > scrollMax.value) {
      scrollLeft.value = scrollMax.value
    } else if (scrollLeft.value + scrollX < 0) {
      scrollLeft.value = 0
    } else {
      scrollLeft.value += scrollX
    }
  }
}
</script>
<template>
  <div class="m-tabs">
    <div class="m-tabs-nav">
      <div
        ref="wrapRef"
        class="tabs-nav-wrap"
        :class="{
          'tabs-center': centered,
          'before-shadow-active': showWheel && scrollLeft > 0,
          'after-shadow-active': showWheel && scrollLeft < scrollMax
        }"
      >
        <div
          ref="navRef"
          class="tabs-nav-list"
          :class="{ 'nav-transition': transition }"
          :style="`transform: translate(${-scrollLeft}px, 0)`"
          @wheel.stop.prevent="showWheel ? onWheel($event) : () => false"
        >
          <div
            ref="tabsRef"
            class="tab-item"
            :class="[
              `tab-${size}`,
              {
                'tab-card': type === 'card',
                'tab-disabled': page.disabled,
                'tab-line-active': activeKey === page.key && type === 'line',
                'tab-card-active': activeKey === page.key && type === 'card'
              }
            ]"
            :style="`margin-left: ${index !== 0 ? gutter : null}px;`"
            @click="page.disabled ? () => false : onTab(page.key)"
            v-for="(page, index) in tabPages"
            :key="index"
          >
            {
  { page.tab }}
          </div>
          <div
            class="tab-bar"
            :class="{ 'card-hidden': type === 'card' }"
            :style="`left: ${left}px; width: ${width}px;`"
          ></div>
        </div>
      </div>
    </div>
    <div class="m-tabs-page">
      <div class="tabs-content" v-show="activeKey === page.key" v-for="page in tabPages" :key="page.key">
        <slot :name="page.key">{
  { page.content }}</slot>
      </div>
    </div>
  </div>
</template>
<style lang="less" scoped>
.m-tabs {
  display: flex;
  color: rgba(0, 0, 0, 0.88);
  line-height: 1.5714285714285714;
  flex-direction: column; // 子元素将垂直显示,正如一个列一样。
  .m-tabs-nav {
    position: relative;
    display: flex;
    flex: none;
    align-items: center;
    margin: 0 0 16px 0;
    &::before {
      position: absolute;
      right: 0;
      left: 0;
      bottom: 0;
      border-bottom: 1px solid rgba(5, 5, 5, 0.06);
      content: '';
    }
    .tabs-nav-wrap {
      position: relative;
      display: flex;
      flex: auto;
      align-self: stretch;
      overflow: hidden;
      white-space: nowrap;
      transform: translate(0);
      .shadow {
        position: absolute;
        z-index: 1;
        opacity: 0;
        transition: opacity 0.3s;
        content: '';
        pointer-events: none;
        top: 0;
        bottom: 0;
        width: 32px;
      }
      &::before {
        .shadow();
        left: 0;
        box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.08);
      }
      &::after {
        .shadow();
        right: 0;
        box-shadow: inset -10px 0 8px -8px rgba(0, 0, 0, 0.08);
      }
      .tabs-nav-list {
        position: relative;
        display: flex;
        .tab-item {
          position: relative;
          display: inline-flex;
          align-items: center;
          padding: 12px 0;
          font-size: 14px;
          background: transparent;
          border: 0;
          outline: none;
          cursor: pointer;
          transition: all 0.3s;
          &:not(:first-child) {
            margin-left: 32px;
          }
          &:hover {
            color: @themeColor;
          }
        }
        .tab-small {
          font-size: 14px;
          padding: 8px 0;
        }
        .tab-large {
          font-size: 16px;
          padding: 16px 0;
        }
        .tab-card {
          border-radius: 8px 8px 0 0;
          padding: 8px 16px;
          background: rgba(0, 0, 0, 0.02);
          border: 1px solid rgba(5, 5, 5, 0.06);
          transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
          &:not(:first-child) {
            margin-left: 2px;
          }
        }
        .tab-line-active {
          color: @themeColor;
          text-shadow: 0 0 0.25px currentcolor;
        }
        .tab-card-active {
          border-bottom-color: #ffffff;
          color: @themeColor;
          background: #ffffff;
          text-shadow: 0 0 0.25px currentcolor;
        }
        .tab-disabled {
          color: rgba(0, 0, 0, 0.25);
          cursor: not-allowed;
          &:hover {
            color: rgba(0, 0, 0, 0.25);
          }
        }
        .tab-bar {
          position: absolute;
          background: @themeColor;
          pointer-events: none;
          height: 2px;
          border-radius: 2px;
          transition:
            width 0.3s,
            left 0.3s,
            right 0.3s;
          bottom: 0;
        }
        .card-hidden {
          visibility: hidden;
        }
      }
      .nav-transition {
        transition: all 0.15s;
      }
    }
    .tabs-center {
      justify-content: center;
    }
    .before-shadow-active {
      &::before {
        opacity: 1;
      }
    }
    .after-shadow-active {
      &::after {
        opacity: 1;
      }
    }
  }
  .m-tabs-page {
    font-size: 14px;
    flex: auto;
    min-width: 0;
    min-height: 0;
    .tabs-content {
      position: relative;
      width: 100%;
      height: 100%;
    }
  }
}
</style>

②在要使用的页面引入:

其中引入使用了以下组件:

<script setup lang="ts">
import Tabs from './Tabs.vue'
import { ref, watchEffect } from 'vue'
const tabPages = ref([
  {
    key: '1',
    tab: 'Tab 1',
    content: 'Content of Tab Pane 1'
  },
  {
    key: '2',
    tab: 'Tab 2',
    content: 'Content of Tab Pane 2'
  },
  {
    key: '3',
    tab: 'Tab 3',
    content: 'Content of Tab Pane 3'
  },
  {
    key: '4',
    tab: 'Tab 4',
    content: 'Content of Tab Pane 4'
  },
  {
    key: '5',
    tab: 'Tab 5',
    content: 'Content of Tab Pane 5'
  },
  {
    key: '6',
    tab: 'Tab 6',
    content: 'Content of Tab Pane 6'
  }
])
const tabPagesDisabled = ref([
  {
    key: '1',
    tab: 'Tab 1',
    content: 'Content of Tab Pane 1'
  },
  {
    key: '2',
    tab: 'Tab 2',
    content: 'Content of Tab Pane 2'
  },
  {
    key: '3',
    tab: 'Tab 3',
    disabled: true,
    content: 'Content of Tab Pane 3'
  },
  {
    key: '4',
    tab: 'Tab 4',
    content: 'Content of Tab Pane 4'
  },
  {
    key: '5',
    tab: 'Tab 5',
    content: 'Content of Tab Pane 5'
  },
  {
    key: '6',
    tab: 'Tab 6',
    content: 'Content of Tab Pane 6'
  }
])
const activeKey = ref('1')
watchEffect(() => {
  // 回调立即执行一次,同时会自动跟踪回调中所依赖的所有响应式依赖
  console.log('activeKey:', activeKey.value)
})
const options = ref([
  {
    label: 'Small',
    value: 'small'
  },
  {
    label: 'Middle',
    value: 'middle'
  },
  {
    label: 'Large',
    value: 'large'
  }
])
const size = ref('middle')
function onChange(key: string | number) {
  console.log('key:', key)
}
</script>
<template>
  <div>
    <h1>{
  { $route.name }} {
  { $route.meta.title }}</h1>
    <h2 class="mt30 mb10">基本使用</h2>
    <Tabs :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <h2 class="mt30 mb10">卡片式标签页</h2>
    <Tabs type="card" :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <h2 class="mt30 mb10">禁用某一项</h2>
    <Tabs :tab-pages="tabPagesDisabled" v-model:active-key="activeKey" @change="onChange" />
    <br />
    <Tabs type="card" :tab-pages="tabPagesDisabled" v-model:active-key="activeKey" @change="onChange" />
    <h2 class="mt30 mb10">居中展示</h2>
    <Tabs centered :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <br />
    <Tabs centered type="card" :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <h2 class="mt30 mb10">左右滑动,容纳更多标签</h2>
    <Tabs style="width: 320px" :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <br />
    <Tabs style="width: 320px" type="card" :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <h2 class="mt30 mb10">三种尺寸</h2>
    <Radio :options="options" v-model:value="size" button />
    <br />
    <Tabs :size="size" :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <br />
    <Tabs type="card" :size="size" :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange" />
    <h2 class="mt30 mb10">自定义内容</h2>
    <Tabs :tab-pages="tabPages" v-model:active-key="activeKey" @change="onChange">
      <template #1>
        <p>key: 1 的 slot 内容</p>
      </template>
      <template #2>
        <p>key: 2 的 slot 内容</p>
      </template>
      <template #3>
        <p>key: 3 的 slot 内容</p>
      </template>
    </Tabs>
  </div>
</template>
相关文章
|
12天前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
115 64
|
12天前
|
JavaScript 前端开发 API
Vue 3 中 v-model 与 Vue 2 中 v-model 的区别是什么?
总的来说,Vue 3 中的 `v-model` 在灵活性、与组合式 API 的结合、对自定义组件的支持等方面都有了明显的提升和改进,使其更适应现代前端开发的需求和趋势。但需要注意的是,在迁移过程中可能需要对一些代码进行调整和适配。
|
1月前
|
存储 JavaScript 前端开发
vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
【10月更文挑战第21天】 vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
vue3的脚手架模板你真的了解吗?里面有很多值得我们学习的地方!
|
1月前
|
JavaScript 前端开发 开发者
Vue 3中的Proxy
【10月更文挑战第23天】Vue 3中的`Proxy`为响应式系统带来了更强大、更灵活的功能,解决了Vue 2中响应式系统的一些局限性,同时在性能方面也有一定的提升,为开发者提供了更好的开发体验和性能保障。
63 7
|
1月前
|
前端开发 数据库
芋道框架审批流如何实现(Cloud+Vue3)
芋道框架审批流如何实现(Cloud+Vue3)
71 3
|
1月前
|
JavaScript 数据管理 Java
在 Vue 3 中使用 Proxy 实现数据双向绑定的性能如何?
【10月更文挑战第23天】Vue 3中使用Proxy实现数据双向绑定在多个方面都带来了性能的提升,从更高效的响应式追踪、更好的初始化性能、对数组操作的优化到更优的内存管理等,使得Vue 3在处理复杂的应用场景和大量数据时能够更加高效和稳定地运行。
52 1
|
1月前
|
JavaScript 开发者
在 Vue 3 中使用 Proxy 实现数据的双向绑定
【10月更文挑战第23天】Vue 3利用 `Proxy` 实现了数据的双向绑定,无论是使用内置的指令如 `v-model`,还是通过自定义事件或自定义指令,都能够方便地实现数据与视图之间的双向交互,满足不同场景下的开发需求。
56 1
|
1月前
|
前端开发 JavaScript
简记 Vue3(一)—— setup、ref、reactive、toRefs、toRef
简记 Vue3(一)—— setup、ref、reactive、toRefs、toRef
|
1月前
Vue3 项目的 setup 函数
【10月更文挑战第23天】setup` 函数是 Vue3 中非常重要的一个概念,掌握它的使用方法对于开发高效、灵活的 Vue3 组件至关重要。通过不断的实践和探索,你将能够更好地利用 `setup` 函数来构建优秀的 Vue3 项目。
|
2月前
|
JavaScript 前端开发 API
vue3知识点:Vue3.0中的响应式原理和 vue2.x的响应式
vue3知识点:Vue3.0中的响应式原理和 vue2.x的响应式
27 0