【Vue.js】タブ切り替えUIの作り方|アクティブなタブを動的に切り替える実装例

【Vue.js】タブ切り替えUIの作り方|アクティブなタブを動的に切り替える実装例 Vue.js

タブ切り替えは、複数の内容を同じ場所で切り替えて見せるUIです。見た目は簡単に作れますが、<li>にクリックイベントを付けただけでは、キーボードや読み上げソフトで使えません。この記事では、Vue 3(<script setup>)で、キーボード操作にも対応した、実用的なタブUIを作ります。

スポンサーリンク

完成コード:キーボード操作に対応したタブ

タブの情報を配列で持ち、現在選択中のタブのidをrefで管理します。クリックだけでなく、W3CのWAI-ARIAのタブのパターンに沿って、次の構造にしています。

  • タブ全体をrole="tablist"、各タブをrole="tab"、内容をrole="tabpanel"にする
  • 選択中のタブにaria-selected="true"、それ以外にfalseを付ける
  • 各タブのaria-controlsで対応するパネルを指し、パネル側はaria-labelledbyでタブを指す
  • 左右の矢印キーでタブを移動し、Home / Endで最初と最後に移動する(Home / Endは任意)
  • Tabキーでタブ一覧に入ると、選択中のタブにフォーカスが当たり、もう一度押すとパネルへ抜ける
Vue:タブのコンポーネント
<script setup>
import { ref } from 'vue'

// 各タブに固有の id を持たせる
const tabs = [
  { id: 'overview', label: '概要', content: 'これは概要の内容です。' },
  { id: 'features', label: '機能', content: 'ここでは機能について説明します。' },
  { id: 'contact', label: 'お問い合わせ', content: 'お問い合わせはこちらから。' }
]

const current = ref('overview')

// 矢印キー・Home・End でタブを移動する
function onKeydown(event, index) {
  let next = null
  if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
  else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
  else if (event.key === 'Home') next = 0
  else if (event.key === 'End') next = tabs.length - 1
  if (next === null) return

  event.preventDefault()
  current.value = tabs[next].id
  // v-for の ref は順序が保証されないので、id で要素を探してフォーカスを移す
  document.getElementById(`tab-${tabs[next].id}`)?.focus()
}
</script>

<template>
  <div class="tabs">
    <div role="tablist" aria-label="商品の情報">
      <button
        v-for="(tab, index) in tabs"
        :key="tab.id"
        :id="`tab-${tab.id}`"
        type="button"
        role="tab"
        :aria-selected="current === tab.id"
        :aria-controls="`panel-${tab.id}`"
        :tabindex="current === tab.id ? 0 : -1"
        :class="{ active: current === tab.id }"
        @click="current = tab.id"
        @keydown="onKeydown($event, index)"
      >
        {{ tab.label }}
      </button>
    </div>

    <!-- 全パネルを DOM に残し、現在のものだけを表示する -->
    <div
      v-for="tab in tabs"
      v-show="current === tab.id"
      :key="tab.id"
      :id="`panel-${tab.id}`"
      role="tabpanel"
      :aria-labelledby="`tab-${tab.id}`"
      class="tab-panel"
    >
      {{ tab.content }}
    </div>
  </div>
</template>
CSS:スタイル
<style scoped>
.tabs {
  max-width: 600px;
  margin: 0 auto;
}

[role="tablist"] {
  display: flex;
  border-bottom: 2px solid #ddd;
}

/* button の既定の見た目を、タブとして整える */
[role="tab"] {
  padding: 10px 20px;
  font: inherit;
  cursor: pointer;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
}

[role="tab"].active {
  font-weight: bold;
  border-color: #007acc;
}

[role="tab"]:focus-visible {
  outline: 2px solid #007acc;
  outline-offset: -2px;
}

.tab-panel {
  padding: 1rem;
  background: #f9f9f9;
  border: 1px solid #ddd;
}
</style>

コードのポイント

タブは<li>ではなく<button>にする

<button type="button">にすれば、フォーカスとクリックの基本の動きが標準で手に入ります。<li>では、Tabキーでフォーカスが当たらず、EnterやSpaceでも操作できません。アコーディオンでも同じ考え方です。詳しくはシンプルなアコーディオンを作成する方法をご覧ください。

選択中のタブだけがTabキーで止まる

選択中のタブにtabindex="0"、それ以外にtabindex="-1"を付けています。Tabキーが、タブ一覧の中で止まりすぎず、選択中のタブにフォーカスが当たります。タブ間の移動は、矢印キーで行います。

切り替えは「自動」にしている

矢印キーでタブにフォーカスを移すと、同時にそのタブが選択されます。これは自動アクティベーションと呼ばれ、内容がすぐに表示できる場合に向いています。内容の読み込みが重いときは、EnterやSpaceで決定する「手動」の方式も選べます。

keyはindexではなくid

:keyには、タブごとのidを使います。インデックスをkeyにしない理由はv-forで配列やオブジェクトをループして表示する方法で解説しています。

フォーカスは「id」で探す

矢印キーで次のタブにフォーカスを移すとき、v-forのrefの配列を使うこともできます。ただし、公式が、その配列は元の配列と同じ順序とは限らないと注意しているため、ここではidで要素を探しています。

パネルの表示方法:v-showとv-if

上のコードは、すべてのパネルをDOMに残して、v-showで表示を切り替えます。パネルの中の入力値などの状態が保たれ、aria-controlsが指す要素も常に存在します。パネルの数が多く、中身が重い場合は、v-ifで選択中のパネルだけを描画する方法もあります。違いはv-ifで条件分岐させる方法をご覧ください。

タブの中身を別コンポーネントにする:KeepAlive

タブごとの内容が大きくなったら、コンポーネントに分けます。<component :is="…">で切り替えますが、そのままでは、切り替えのたびにコンポーネントが破棄されて、入力途中のフォームなどの状態が消えます。<KeepAlive>で包むと、非アクティブなコンポーネントがキャッシュされ、状態が保たれます。

Vue:componentとKeepAliveで切り替える
<script setup>
import { ref, computed } from 'vue'
import TabOverview from './TabOverview.vue'
import TabContact from './TabContact.vue'

const tabs = [
  { id: 'overview', label: '概要', component: TabOverview },
  { id: 'contact', label: 'お問い合わせ', component: TabContact }
]

const current = ref('overview')
const currentComponent = computed(() => tabs.find(t => t.id === current.value).component)
</script>

<template>
  <!-- タブのボタンは、先ほどと同じ -->

  <div role="tabpanel" :id="`panel-${current}`" :aria-labelledby="`tab-${current}`">
    <!-- KeepAlive で包むと、切り替えても入力途中の状態が保たれる -->
    <KeepAlive>
      <component :is="currentComponent" />
    </KeepAlive>
  </div>
</template>

<KeepAlive>には、キャッシュの対象を絞るinclude / excludeと、キャッシュする数の上限maxがあります。キャッシュされたコンポーネントの表示と非表示には、onActivatedとonDeactivatedのフックが呼ばれます。

選択中のタブをURLに持たせる

タブの選択をURLのクエリ(?tab=features)に持たせると、リロードしても選択が消えず、特定のタブを開いたURLを共有できます。Vue Routerのroute.queryと同期するcomputedで、簡単に作れます。タブを切り替えるたびに履歴を増やしたくない場合は、pushではなくreplaceを使います。不正な値のときは、先頭のタブにします。

Vue:URLクエリと同期する
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// URL の ?tab=features と同期する。不正な値のときは、先頭のタブにする
const current = computed({
  get: () => (tabs.some(t => t.id === route.query.tab) ? route.query.tab : tabs[0].id),
  set: (id) => router.replace({ query: { ...route.query, tab: id } })
})
</script>

Vue Routerの基本はルーティングによる画面切り替えをご覧ください。

アニメーションを付ける

パネルの切り替えにフェードなどを付けたい場合は、<Transition>が使えます。基礎はtransitionコンポーネントを使ってアニメーションを実装する方法をご覧ください。

よくある質問(FAQ)

Qタブを<li>ではなく<button>にするのはなぜですか?
Aキーボードで操作でき、読み上げソフトにも、操作できる要素だと伝わるためです。<li>のクリックイベントだけでは、TabキーでもEnterキーでも操作できません。
Qタブを切り替えると、入力途中の内容が消えてしまいます。
Av-ifや<component :is>で切り替えていると、コンポーネントが破棄されるためです。v-showで全パネルを残すか、<KeepAlive>で包んでください。
Qリロードしても、選択中のタブを保ちたいです。
A選択中のタブを、URLのクエリ(?tab=…)に持たせるのが簡単です。共有できるURLにもなります。保存先をlocalStorageにする方法もありますが、URLを共有したい場合には向きません。
Q矢印キーで、フォーカスと同時に切り替わるのは必須ですか?
A必須ではありません。内容がすぐ表示できるときは、自動で切り替わるほうが自然ですが、読み込みが重いときは、EnterやSpaceで決定する方式にできます。

まとめ

タブUIは、<button role="tab">とaria-selected、aria-controlsを付け、矢印キーで移動できるようにすると、キーボードと読み上げソフトでも使えます。状態を保ちたいときはv-showか<KeepAlive>を使い、リロードや共有に備えるなら、選択中のタブをURLのクエリに持たせてください。