10 - Vue3 UI Framework - Tabs 元件

Jeremy.Wu發表於2021-12-20

標籤頁是非常常用的元件,接下來我們來製作一個簡單的 Tabs 元件

返回閱讀列表點選 這裡

需求分析

我們先做一個簡單的需求分析

  1. 可以選擇標籤頁排列的方向
  2. 選中的標籤頁應當有下劃線高亮顯示
  3. 切換選中時,下劃線應當有動畫效果
  4. 應當允許更換顏色

那麼可以整理出以下參數列格

引數 含義 型別 可選值 預設值
direction 方向 string row / column row
selected 預設選中 string 子項的 name 必填
color 顏色 string 任意合法顏色值 #d3c8f5

通過為子項設定 name 屬性,來指定預設值

骨架

本體

通過需求分析我們可以得到如下骨架:

<template>
  <div
    class="jeremy-tabs"
    :style="{ '--color': color }"
    ref="container"
    :direction="direction"
  >
    <div class="jeremy-tabs-titles">
      <button
        v-for="(title, index) in titles"
        :key="index"
        class="jeremy-tabs-title"
        :class="{ selected: names[index] === selected }"
        @click="select(index)"
        :ref="
          (el) => {
            if (names[index] === selected) {
              selectedItem = el;
            }
          }
        "
      >
        {{ title }}
      </button>
      <div class="jeremy-tabs-indicator" ref="indicator"></div>
    </div>
    <div class="jeremy-tabs-divider"></div>
    <div class="jeremy-tabs-content">
      <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 元件引入到測試文件,檢視一下執行效果

tabs

專案地址 ?

GitHub: https://github.com/JeremyWu917/jeremy-ui

官網地址 ?

JeremyUI: https://ui.jeremywu.top

感謝閱讀 ☕

相關文章