![[為你自己寫 Vue Component] AtomicTabs](https://ithelp.ithome.com.tw/upload/images/20240917/20120484zzS6wpYSkc.png)
頁籤(Tabs)是一個很常見的 UI 元件,它可以讓使用者在內容上有關聯但屬於不同類別的資料或畫面之間切換。例如,我們可以用它來切換「娛樂」、「科技」、「股市」等不同分類的新聞列表;也可以用它來切換「走勢圖」、「技術分析」、「籌碼」等不同類別的股市資訊。

在開始實作前,我們先研究各個 UI Library 的 Tab 元件是如何設計的。
Element Plus

<template>
  <ElTabs v-model="tab" :before-leave="handleBeforeLeave">
    <ElTabPane label="User" name="first">User</ElTabPane>
    <ElTabPane label="Config" name="second">Config</ElTabPane>
    <ElTabPane label="Role" name="third">Role</ElTabPane>
    <ElTabPane label="Task" name="fourth">Task</ElTabPane>
  </ElTabs>
</template>
Element Plus 的設計是直接透過 <ElTabPane> 設定 Tab 與內容,如果要自定義 Tab 的細節,可以透過 label 這個 slot 來設定。
<ElTabPane> 的設定方式保證了 Tab 與對應的內容會成雙成對地出現,好處是新增或刪除時更容易且不容易漏改、漏刪。但這種「由子層元件決定上層元件渲染」的設計在 SSR(Server Side Rendering)的場景上可能會遇到一些問題,如果有 SSR 需求的專案可能需要斟酌使用。
例如以 element-plus@2.7.8 的版本來說,上面的範例在 SSR 傳過來的 HTML 結構只有 TabPanel 的內容沒有 Tab。

Element Plus 在
element-plus@2.8.0後的版本使用特殊的 HTML 結構解決了 SSR 的問題,但在鍵盤操作上衍生了其他麻煩。
關於實作 Component 應該注意的 SSR 問題,在系列文的最後我們會再詳細地說明。
Vuetify

<template>
  <VCard>
    <VTabs v-model="tab">
      <VTab value="one">Item One</VTab>
      <VTab value="two">Item Two</VTab>
      <VTab value="three">Item Three</VTab>
    </VTabs>
    <VCardText>
      <VTabsWindow v-model="tab">
        <VTabsWindowItem value="one">
          One
        </VTabsWindowItem>
        <VTabsWindowItem value="two">
          Two
        </VTabsWindowItem>
        <VTabsWindowItem value="three">
          Three
        </VTabsWindowItem>
      </VTabsWindow>
    </VCardText>
  </VCard>
</template>
Vuetify 的結構包含 <VTabs>、<VTab>、<VTabsWindow> 和 <VTabsWindowItem> 四個元件。它將 <VTabs> 與 <VTabsWindow> 分成兩個元件的好處是提供更高的彈性,但也增加了開發人員使用時需要學習的元件數量。
Nuxt UI

<template>
  <UTabs :items="items" />
</template>
Nuxt UI 的設計是透過 items 來設定 Tab 的內容,不用一個個手動設定 Tab 按鈕。而如果需要自定義 Tab 或內容的樣式需要透過 slot 來設定。
const items = [
  { slot: 'content1', label: 'Tab 1'  },
  { slot: 'content2', label: 'Tab 2' },
];
<template>
  <UTabs v-model="item" :items="items">
    <template #content1>
      <div>
        <h1>Content 1</h1>
        <p>This is the content shown for Tab 1</p>
      </div>
    </template>
    <template #content2>
      <div>
        <h1>Content 2</h1>
        <p>This is the content shown for Tab 2</p>
      </div>
    </template>
  </UTabs>
</template>
綜合以上並結合自身經驗,我們統整出 <AtomicTabs> 的功能:
items 來設定 Tab 按鈕的清單。items 裡面的每個物件都可以獨立設定 disabled 來禁用該 Tab 按鈕。disabled 來禁用整個元件。onBeforeChange 事件讓使用者在切換 Tab 前做一些事情。<AtomicTabPanel> 顯示內容並傳入 value 來決定對應到的 Tab 按鈕。使用結構如下:
<template>
  <AtomicTabs v-model="tab" :items="items">
    <AtomicTabPanel value="tab1">
      <div>
        <h1>Tab1</h1>
        <p>This is the content shown for Tab1</p>
      </div>
    </AtomicTabPanel>
    <AtomicTabPanel value="tab2">
      <div>
        <h1>Tab2</h1>
        <p>This is the content shown for Tab2</p>
      </div>
    </AtomicTabPanel>
  </AtomicTabs>
</template>
只有 value 與 tab 相等的 <AtomicTabPanel> 會顯示,其他都會隱藏起來。
雖然期望每次切換都會顯示不同的 <AtomicTabPanel> 元件內容,但有時候 Tab 會被拿來作為條件篩選功能,切換 Tab 只是希望切換下方資料的篩選條件。因此,我們希望 <AtomicTabs> 可以獨立使用,不一定要搭配 <AtomicTabPanel> 使用。
另外,onBeforeChange 可以用於切換頁籤之前的事件處理,例如:當使用者點擊 Tab 按鈕時切換表單時,我們可以檢查使用者是否有未儲存的資料,如果有的話可以提示使用者是否要儲存,或是直接阻止使用者切換 Tab 按鈕。
例如:
const onBeforeChange = (value, done) => {
  done(confirm('什麼!你要換頁籤了嗎!'))
}
這樣每次切換頁籤時都會跳出一個提示框,讓使用者選擇是否要切換頁籤,使用者按下確定後才會真正地更換頁籤。
<AtomicTabs> 元件首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| modelValue | any | 當前選中的 Tab 按鈕的 value | |
| items | AtomicTabsItem[] | Tab 按鈕的清單 | |
| disabled | boolean | false | 是否禁用所有 Tab 按鈕 | 
| onBeforeChange | (value: any, done: (valid: boolean) => void) => void | 切換 Tab 按鈕前的事件 | 
interface AtomicTabsItem {
  value: any;
  label: string;
  disabled?: boolean;
}
interface AtomicTabsProps = {
  modelValue: any;
  items: AtomicTabsItem[];
  disabled?: boolean;
  onBeforeChange?(value: any, done: (valid?: boolean) => void): void;
};
const props = withDefaults(defineProps<AtomicTabsProps>(), {
  onBeforeChange: undefined,
});
我們將使用者傳入的 items 資料與 modelValue 跟 disabled 整理過,這樣我們就可以直接將資料套到模板上使用。
const tabs = computed(() => {
  return props.items.map(item => {
    const disabled = props.disabled || item.disabled;
    const selected = item.value === props.modelValue;
    return {
      ...item,
      disabled,
      selected,
      class: {
        'atomic-tabs__tab--selected': selected
      }
    }
  });
});
接下來處理元件模板部分,模板結構很簡單。
<template>
  <div class="atomic-tabs">
    <div class="atomic-tabs__tablist">
      <template
        v-for="tab in tabs"
        :key="tab.value"
      >
        <button
          class="atomic-tabs__tab"
          :class="tab.class"
          :disabled="tab.disabled"
          role="tab"
          type="button"
        >
          <slot
            :tab="tab"
            name="tab"
          >
            {{ tab.label }}
          </slot>
        </button>
      </template>
    </div>
    <!-- 這裡讓使用者帶入 `<AtomicTabPanel>` -->
    <slot name="default" />
  </div>
</template>
切換的功能也很簡單,只要當使用者點擊了 Tab 按鈕,我們就通知外層更新 modelValue 即可。
const onTabClick = (value: T) => {
  if (props.modelValue === value) return;
  emit('update:modelValue', value);
};
將這個 callback 函數綁定到 <button> 的 click 事件上。
<template
  v-for="tab in tabs"
  :key="tab.value"
>
  <button
    type="button"
    @click="onTabClick(tab.value)"
  >
    <slot
      :tab="tab"
      name="tab"
    >
      {{ tab.label }}
    </slot>
  </button>
</template>
接著我們加入 onBeforeChange,就像前面提到的,它會在 Tab 切換前觸發,並且可以透過呼叫 done 來決定是否要阻止切換。
這功能很容易實現,我們只需要在 onTabClick 裡面加入 onBeforeChange 的呼叫。
const onTabClick = (value: any) => {
  if (props.modelValue === value) return;
  const { onBeforeChange } = props;
  if (onBeforeChange) {
    onBeforeChange(value, (valid) => {
      if (valid === false) return;
      emit('update:modelValue', value);
    });
    return;
  }
  emit('update:modelValue', value);
};
因為我們希望僅當使用者明確傳入 false 時才阻止切換,所以我們在 valid 的判斷上是用 valid === false 而不是 !valid。
const onBeforeChange = (value, done) => {
  done();      // 更換 Tab
  done(true);  // 更換 Tab
  done(false); // 阻止更換 Tab
};
雖然已經成功加入了 onBeforeChange 的功能,但目前有個限制:一旦使用了 onBeforeChange 就必須手動呼叫 done 才能切換。這可能有些麻煩。有沒有可能如果使用者沒有使用 done,那麼 Tab 就照常切換呢?
像是這樣的 onBeforeChange 一旦執行結束就切換 Tab:
const onBeforeChange = (value) => {
  console.log(`切換 Tab 到 ${value}`);
};
我們只要判斷使用者傳入的 onBeforeChange 接收幾個參數即可,如果只有一個參數,則執行完直接切換 Tab。我們可以用 function.length 來判斷 function 的參數數量。
const foo = (value) => {};
const bar = (value, done) => {};
foo.length; // 1
bar.length; // 2
知道如何判斷後,我們就可以進一步增強 onTabClick 的邏輯。
const onTabClick = (value: any) => {
  if (props.modelValue === value) return;
  const { onBeforeChange } = props;
  if (!onBeforeChange || onBeforeChange.length <= 1) {
    onBeforeChange?.(value);
    emit('update:modelValue', value);
    return;
  }
  onBeforeChange(value, (valid) => {
    if (valid === false) return;
    emit('update:modelValue', value);
  });
};
到這裡我們也可以解釋為什麼雖然說 onBeforeChange 是一個事件,但這裡卻把它歸納在 props 的型別定義裡面。如果我們把它定義在 emit 裡面,使用上如同大家熟悉的這樣:
emit('before-change', value, done);
這樣我們無法判斷使用者是否需要使用 done,在我們想要實作的這個功能上會比較困難。
另外在 Vue 3 中,只要是 on 開頭並緊接著以大寫字母為第一個字的 props 都會被視為事件,所以 onBeforeChange 可以這樣使用:
<template>
  <AtomicTabs
    v-model="tab"
    :items="items"
    @before-change="handleBeforeChange"
  >
    <!-- 略 -->
  </AtomicTabs>
</template>
所以對於使用者來說,他們仍然是用事件的方式使用,但我們在內部可以更細節地控制這個事件的行為。
<AtomicTabPanel> 元件回顧一下我們希望的使用方式:
<template>
  <AtomicTabs v-model="tab" :items="items">
    <AtomicTabPanel value="tab1">
      <div>
        <h1>Tab1</h1>
        <p>This is the content shown for Tab1</p>
      </div>
    </AtomicTabPanel>
    <AtomicTabPanel value="tab2">
      <div>
        <h1>Tab2</h1>
        <p>This is the content shown for Tab2</p>
      </div>
    </AtomicTabPanel>
  </AtomicTabs>
</template>
每個 <AtomicTabPanel> 都需要知道當下的 tab 是否與自己的 value 相等,因此我們需要透過 v-if 或 v-show 來判斷是否顯示?太麻煩了!還是每個 <AtomicTabPanel> 都傳入 tab 來判斷是否顯示?這樣也太麻煩了!
用 provide / inject 來解決這個問題或許還不錯。首先我們要在 <AtomicTabs> 元件裡提供當前選中的 value。
// Script
interface AtomicTabsContext {
  modelValue: Ref<any>;
}
export const TABS_INJECT_KEY: InjectionKey<AtomicTabsContext> = Symbol();
// Setup Script
provide(TABS_INJECT_KEY, {
  modelValue: toRef(() => props.modelValue),
});
接著在 <AtomicTabPanel> 元件裡透過 inject 將 tab 注入進來。
import { TABS_INJECT_KEY } from './AtomicTabs.vue';
interface AtomicTabPanelProps {
  value: any;
}
const props = withDefaults(defineProps<AtomicTabPanelProps>(), {
  value: undefined,
});
const context = inject(TABS_INJECT_KEY, null);
判斷當前的 tab 是否與自己的 value 相等,不相等則需要隱藏。
const hidden = computed(() => {
  if (isNullOrUndefined(context)) return false;
  return props.value !== context.modelValue.value;
});
模板部分就非常簡單了,在這裡我習慣使用 hidden 來隱藏元素,如果要選擇使用 v-show 或是 v-if 也是可以的。
<template>
  <div :hidden="hidden">
    <slot name="default" />
  </div>
</template>
這樣我們就完成了 <AtomicTabs> 與 <AtomicTabPanel> 元件的實作。

使用網頁時,我們會先找到我們要的 Tab,移動滑鼠到 Tab 上點擊後開始瀏覽、操作這個 Tab 下的內容。但如果現在貓咪趴在滑鼠上,我們捨不得趕走牠怎麼辦呢?
這時如果能只使用鍵盤就可以切換頁籤並且瀏覽、操作該頁籤下的內容,那就太好了!
在無障礙實作指南裡面其實有提到 Tab 應該如何被鍵盤操作,我們可以參考這個指南來實作。

最後一點我們不用特別處理,因為我們使用的 <button> 元素本身就支援這個功能。
首先最簡單的情況是,當按下 Tab 焦點移入 TabList 時,焦點應該在當前選中的 Tab 上;以及此時再按下 Tab 焦點應該移動到當前選中的 Tab 對應的 TabPanel 內。
作法很簡單,我們只要讓被選中的 Tab 上面有 tabindex="0",其他的 Tab 設定 tabindex="-1" 就可以了。
<template>
  <template
    v-for="tab in tabs"
    :key="tab.value"
  >
    <button
      class="atomic-tabs__tab"
      :class="{
        'atomic-tabs__tab--selected': tab.selected
      }"
      :tabindex="tab.selected ? 0 : -1"
    >
      <slot
        :item="tab"
        name="tab"
      >
        {{ tab.label }}
      </slot>
    </button>
  </template>
</template>
接著我們處理 Left Arrow、Right Arrow、Home 與 End 的功能。我們可以在 TabList 上監聽 keydown 事件,並且透過鍵盤事件的 key 來判斷使用者按下的是哪個鍵。
const onTabKeydown = (event: KeyboardEvent) => {
  const tablist = event.currentTarget as HTMLElement;
  const currentFocus = document.activeElement as HTMLElement;
  if (isNullOrUndefined(tablist)) return;
  switch (event.key) {
    case 'ArrowRight':
      event.preventDefault();
      // 移動焦點到下一個 Tab
      break;
    case 'ArrowLeft':
      event.preventDefault();
      // 移動焦點到上一個 Tab
      break;
    case 'Home':
      event.preventDefault();
      // 移動焦點到第一個 Tab
      break;
    case 'End':
      event.preventDefault();
      // 移動焦點到最後一個 Tab
      break;
  }
};
我們需要三個 utils function 協助我們處理這些功能。首先是負責找到下一個元素的 function 跟找到上一個元素的 function。
nextItem
export function nextItem(
  container: HTMLElement,
  item: HTMLElement | null
): HTMLElement | null {
  if (item && item.nextElementSibling) {
    return item.nextElementSibling as HTMLElement;
  }
  return container.firstChild as HTMLElement | null;
}
previousItem
export function previousItem(
  container: HTMLElement,
  item: HTMLElement | null
): HTMLElement | null {
  if (item && item.previousElementSibling) {
    return item.previousElementSibling as HTMLElement;
  }
  return container.lastChild as HTMLElement | null;
}
簡單解釋,如果有興趣了解更多可以在 MDN 上看到更完整的說明。
nextElementSibling 會回傳下一個同層級的元素,如果沒有就會回傳 null;firstChild 會回傳第一個子元素,如果沒有就會回傳 null。previousElementSibling 會回傳上一個同層級的元素,如果沒有就會回傳 null;lastChild 會回傳最後一個子元素,如果沒有就會回傳 null。
我們應用了這四個 Element 上的屬性就可以輕易找到同層級的下一個或上一個元素或是容器的第一個或最後一個子元素。
但我們還需要一個 function 來處理焦點的移動。我們需要透過 nextItem 或 previousItem 這兩個 function 來找到下一個或上一個元素,並且判斷這個元素是否可以被 focus,如果不能就繼續往下一個或上一個元素找。
不能 focus 的情況有兩種,一種是有 disabled 的元素,另一種是元素沒有 tabindex。如果找到的屬於這兩種其中之一,我們就繼續往下一個或上一個元素找。
interface TraversalFunction {
  (
    container: HTMLElement,
    currentFocus: HTMLElement | null
  ): HTMLElement | null;
}
export function moveFocus(
  container: HTMLElement,
  currentFocus: HTMLElement | null,
  traversalFn: TraversalFunction
) {
  let nextFocus = traversalFn(container, currentFocus);
  while (nextFocus) {
    const nextFocusDisabled =
      (nextFocus as any).disabled || nextFocus.getAttribute?.('aria-disabled') === 'true';
    if (!nextFocus.hasAttribute?.('tabindex') || nextFocusDisabled) {
      // Move to the next element.
      nextFocus = traversalFn(container, nextFocus);
    } else {
      nextFocus.focus();
      return true;
    }
  }
  return false;
}
這樣我們就可以在 onTabKeydown 裡面使用這個 function 來處理焦點的移動。
const onTabKeydown = (event: KeyboardEvent) => {
  const tablist = event.currentTarget as HTMLElement | null;
  const currentFocus = document.activeElement as HTMLElement;
  if (isNullOrUndefined(tablist)) return;
  switch (event.key) {
    case 'ArrowRight':
      event.preventDefault();
      moveFocus(tablist, currentFocus, nextItem);
      break;
    case 'ArrowLeft':
      event.preventDefault();
      moveFocus(tablist, currentFocus, previousItem);
      break;
    case 'Home':
      event.preventDefault();
      moveFocus(tablist, null, nextItem);
      break;
    case 'End':
      event.preventDefault();
      moveFocus(tablist, null, previousItem);
      break;
  }
};
前面實作鍵盤操作部分已經涵蓋了無障礙的一部分,但我們還可以再加入一些設定來讓元件更符合無障礙標準。
在 HTML 元素上添加 role 屬性會讓網頁對於使用輔助技術(如螢幕閱讀器)的使用者更加友善,特別是對於像 <div>、<span> 等非語義化的元素。
在這裡我們要加上 tablist、tab、tabpanel 這三個 Role。
<AtomicTabs>
<template>
  <div class="atomic-tabs">
    <div
      class="atomic-tabs__tablist"
      role="tablist"
    >
      <button role="tab">
        <!-- 略 -->
      </button>
    </div>
  </div>
</template>
<AtomicTabPanel>
<template>
  <div role="tabpanel">
    <slot name="default" />
  </div>
</template>
aria-selected 用來表示當前選中的 Tab。aria-controls 用來指定被控制元素(TabPanel)的 ID。aria-labelledby 用來指定可代表當前 TabPanel 的標題元素 ID。所以我們需要讓 TabPanel 知道自己對應的 Tab 是哪一個,以及 Tab 知道自己對應的 TabPanel 是哪一個。我們可以利用前面寫的 tabs 與 provide 來提供這些資訊。
const id = `tab-${Math.round(Math.random() * 1e5)}`;
const tabs = computed(() => {
  return props.items.map(item => {
    return {
      ...item,
      id: `tab-${id}-${item.value}`,
      tabpanelId: `tabpanel-${id}-${item.value}`,
      // 略
    };
  });
});
const lookup = computed(() => {
  return Object.fromEntries(tabs.value.map(item => [item.value, item]));
});
provide(TABS_INJECT_KEY, {
  modelValue: toRef(() => props.modelValue),
  lookup,
});
接著我們在 <AtomicTabs> 與 <AtomicTabPanel> 上加上 aria-selected、aria-controls 與 aria-labelledby。
<AtomicTabs>
<template>
  <div class="atomic-tabs__tablist">
    <template
      v-for="tab in tabs"
      :key="tab.value"
    >
      <button
        :id="tab.id"
        :aria-controls="tab.tabpanelId"
        :aria-selected="`${tab.selected}`"
      >
        <slot
          :item="tab"
          name="tab"
        >
          {{ tab.label }}
        </slot>
      </button>
    </template>
  </div>
</template>
<AtomicTabPanel>
const tab = computed(() => {
  if (isNullOrUndefined(context)) return null;
  return context.lookup.value[props.value];
});
<template>
  <div
    :id="tab?.tabpanelId"
    :aria-labelledby="tab?.id"
  >
    <slot name="default" />
  </div>
</template>
Tab 元件的基本概念很簡單,我們只要讓使用者點擊到 Tab 按鈕後執行切換 TabPanel 內容就可以了。不過為了讓開發人員能夠在 Tab 切換之前進行一些必要的檢查獲確認,我們加入了 onBeforeChange 事件,讓開發人員能更好的控制 Tab 切換時的行為。
另外我們也得考慮到鍵盤操作的功能,這不僅僅是為了無障礙設計,也是為了讓使用者能夠更方便地操作 Tab 元件。
最後我們也考量了像是螢幕閱讀器的使用者應該如何使用這個元件,我們透過 role 與 aria-* 屬性來讓螢幕閱讀器更好地理解這個元件,這些都是我們平常自己在設計元件時容易忽略的部分。
<AtomicTabs> 原始碼:AtomicTabs.vue