JS 实现:横向 Tabs

32 阅读2分钟

效果

2025-11-28 16.51.00.gif

实现

<template>
  <div class="tabs-wrapper">
    <div v-show="showLeftArrow" class="nav-arrow left-arrow" @click="scrollList('left')">
      <slot name="left-arrow">
        <i class="el-icon-arrow-left">&lt;</i>
      </slot>
    </div>

    <div class="scroll-container" ref="scrollContainer" @scroll="handleScroll">
      <div class="tab-list" ref="tabList">
        <div v-for="(item, index) in tabList" :key="index" :ref="'tabItem' + index" class="tab-item"
          :class="{ active: activeIndex === index }" @click="handleTabClick(item, index)">
          {{ item.label }}
        </div>
      </div>
    </div>

    <div v-show="showRightArrow" class="nav-arrow right-arrow" @click="scrollList('right')">
      <slot name="right-arrow">
        <i class="el-icon-arrow-right">&gt;</i>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'HorizontalTabs',
  props: {
    // 数据列表,需包含 label 字段,或自行修改模板
    tabList: {
      type: Array,
      required: true,
      default: () => [
        { "label": "首页", "value": "home" },
        { "label": "很长很长很长的标签A", "value": "long_label_a" },
        { "label": "产品", "value": "products" },
        { "label": "一个超级无敌长的标签B", "value": "super_long_label_b" },
        { "label": "解决方案", "value": "solutions" },
        { "label": "短", "value": "short" },
        { "label": "中等长度的标签", "value": "medium_label" },
        { "label": "This is an English Label", "value": "english_label" },
        { "label": "标签", "value": "tag" },
        { "label": "最后的一个非常非常非常长的标签", "value": "last_very_long_label" },
        { "label": "首页🔥", "value": "home" },
        { "label": "产品&服务", "value": "product_service" },
        { "label": "价格¥999", "value": "price_999" },
        { "label": "FAQ?", "value": "faq" },
        { "label": "关于|我们", "value": "about_us" },
        { "label": "Contact✈️", "value": "contact" },
        { "label": "技术📚文档", "value": "tech_docs" },
        { "label": "解决方案#1", "value": "solution_1" },
        { "label": "解决方案#2", "value": "solution_2" },
        { "label": "测试&验证", "value": "test_verify" },
        { "label": "用户@反馈", "value": "user_feedback" },
        { "label": "活动🎉中心", "value": "activity_center" },
        { "label": "公告📢", "value": "notice" },
        { "label": "帮助💡", "value": "help" },
        { "label": "设置⚙️", "value": "setting" },
        { "label": "数据📊分析", "value": "data_analysis" },
        { "label": "营销📈推广", "value": "marketing" },
        { "label": "运营⚡管理", "value": "operation" },
        { "label": "财务💰报表", "value": "finance" },
        { "label": "人事💼管理", "value": "hr" }
      ]
    },
    // 默认选中的索引
    defaultActiveIndex: {
      type: Number,
      default: 0
    },
    // 点击箭头每次滚动的距离,默认为 null,会自动计算为 2 个 tab 的宽度
    scrollStep: {
      type: Number,
      default: null
    }
  },
  data() {
    return {
      activeIndex: this.defaultActiveIndex,
      showLeftArrow: false,
      showRightArrow: false,
    };
  },
  mounted() {
    // 初始化检查箭头状态
    this.$nextTick(() => {
      this.checkArrows();
      // 如果默认选中的不是第一个,需要初始化居中
      if (this.activeIndex > 0) {
        this.centerActiveTab(this.activeIndex, false); // false 表示不需要平滑滚动初始化
      }
    });
    // 监听窗口大小变化,重新计算箭头显隐
    window.addEventListener('resize', this.checkArrows);
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.checkArrows);
  },
  methods: {
    /**
     * 点击 Tab 处理
     */
    handleTabClick(item, index) {
      this.activeIndex = index;
      this.$emit('change', item, index);
      this.centerActiveTab(index);
    },

    /**
     * 核心逻辑:将选中的 Tab 移动到视图中间
     */
    centerActiveTab(index, smooth = true) {
      const container = this.$refs.scrollContainer;
      // 获取对应的 tab DOM 元素(注意:Vue2 中 v-for 的 ref 是一个数组)
      const tabElement = this.$refs['tabItem' + index] && this.$refs['tabItem' + index][0];

      if (container && tabElement) {
        // 计算公式:滚动距离 = (Tab的左偏移 + Tab宽度/2) - (容器宽度/2)
        const targetLeft = tabElement.offsetLeft + tabElement.offsetWidth / 2 - container.offsetWidth / 2;

        container.scrollTo({
          left: targetLeft,
          behavior: smooth ? 'smooth' : 'auto'
        });
      }
    },

    /**
     * 监听滚动事件,控制箭头显隐
     */
    handleScroll() {
      // 使用 requestAnimationFrame 或简单的节流可以优化性能,这里为了简洁直接调用
      this.checkArrows();
    },

    /**
     * 计算箭头是否需要显示
     */
    checkArrows() {
      const container = this.$refs.scrollContainer;
      if (!container) return;

      const { scrollLeft, scrollWidth, clientWidth } = container;

      // 容差值,避免小数精度问题
      const tolerance = 1;

      // 左侧是否有隐藏内容
      this.showLeftArrow = scrollLeft > tolerance;

      // 右侧是否有隐藏内容 (滚动条位置 + 可视宽度 < 总滚动宽度)
      this.showRightArrow = scrollLeft + clientWidth < scrollWidth - tolerance;
    },

    /**
     * 点击箭头滚动
     */
    scrollList(direction) {
      const container = this.$refs.scrollContainer;
      if (!container) return;

      // 如果未传入具体步长,尝试获取第一个 tab 的宽度 * 2,如果没有 tab 则默认 200
      let step = this.scrollStep;
      if (!step) {
        const firstTab = this.$refs['tabItem0'] && this.$refs['tabItem0'][0];
        step = firstTab ? firstTab.offsetWidth * 2 : 200;
      }

      const currentScroll = container.scrollLeft;
      const targetScroll = direction === 'left' ? currentScroll - step : currentScroll + step;

      container.scrollTo({
        left: targetScroll,
        behavior: 'smooth'
      });
    }
  }
};
</script>

<style scoped>
.tabs-wrapper {
  position: relative;
  width: 100%;
  height: 50px;
  /* 可根据需求调整 */
  display: flex;
  align-items: center;
  background-color: #f5f7fa;
  overflow: hidden;
  /* 防止溢出 */
}

/* 箭头通用样式 */
.nav-arrow {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.9);
  cursor: pointer;
  z-index: 10;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  font-weight: bold;
  user-select: none;
}

.left-arrow {
  left: 0;
}

.right-arrow {
  right: 0;
}

/* 滚动区域 */
.scroll-container {
  flex: 1;
  height: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  white-space: nowrap;
  /* 隐藏滚动条 */
  scrollbar-width: none;
  /* Firefox */
  -ms-overflow-style: none;
  /* IE 10+ */
  scroll-behavior: smooth;
}

.scroll-container::-webkit-scrollbar {
  display: none;
  /* Chrome Safari */
}

.tab-list {
  display: inline-flex;
  height: 100%;
  align-items: center;
  padding: 0 10px;
  /* 给两端留一点空隙 */
}

/* 单个 Tab 样式 */
.tab-item {
  display: inline-block;
  padding: 8px 20px;
  margin: 0 5px;
  cursor: pointer;
  border-radius: 4px;
  color: #606266;
  transition: all 0.3s;
  font-size: 14px;
}

.tab-item:hover {
  color: #409EFF;
}

.tab-item.active {
  color: #fff;
  background-color: #409EFF;
  font-weight: bold;
}
</style>