U-Code

シンプルなタブメニュー

コーディング

アクセシビリティに配慮した、シンプルなタブ切り替えメニューです。
ページ内の複数箇所や、タブの入れ子構造にも対応しています。

デモです

https://u-coded.github.io/tab/

HTML

注意点

  • button要素のidと、対応するtabpanelのaria-labelledbyを一致させる
  • button要素のaria-controlsと、対応するtabpanelのidを一致させる
  • 初期表示するタブには、aria-selected="true"とtabindex="0"を設定し、それ以外のタブにはaria-selected="false"とtabindex="-1"を設定する
  • 非表示のtabpanelにはhidden属性を付ける
<div data-tabset>
  <div role="tablist">
    <button id="tab1-1" data-tab role="tab" aria-selected="true" aria-controls="panel1-1" tabindex="0">タブ1-1</button>
    <button id="tab1-2" data-tab role="tab" aria-selected="false" aria-controls="panel1-2" tabindex="-1">タブ1-2</button>
    <button id="tab1-3" data-tab role="tab" aria-selected="false" aria-controls="panel1-3" tabindex="-1">タブ1-3</button>
  </div>
  <div id="panel1-1" data-tabpanel role="tabpanel" aria-labelledby="tab1-1">
    <p>タブ1-1のコンテンツ</p>
  </div>
  <div id="panel1-2" data-tabpanel role="tabpanel" aria-labelledby="tab1-2" hidden>
    <p>タブ1-2のコンテンツ</p>
  </div>
  <div id="panel1-3" data-tabpanel role="tabpanel" aria-labelledby="tab1-3" hidden>
    <p>タブ1-3のコンテンツ</p>
  </div>
</div>

JavaScript

処理の内容については、ソース内のコメントを参考にしてください。

// セレクターの定義
const TAB_SEL = "[data-tab]"; // タブのセレクター
const TABLIST_SEL = "[data-tablist]"; // タブリストのセレクター
const TABPANEL_SEL = "[data-tabpanel]"; // タブパネルのセレクター
const TABSET_SEL = "[data-tabset]"; // タブセット(親要素)のセレクター
const SHOW_CLASS = "is-show"; // 表示中のタブパネルに付与するクラス

const tabTriggers = document.querySelectorAll(TAB_SEL); // すべてのタブを取得

// タブをアクティブにする関数
const activateTab = (newTab) => {
  const tabSet = newTab.closest(TABSET_SEL); // 現在のタブが属する親タブセットを取得
  const tabs = tabSet.querySelectorAll(TAB_SEL); // 親タブセット内のすべてのタブを取得
  const tabPanels = tabSet.querySelectorAll(TABPANEL_SEL); // 対応するタブパネルを取得

  // すべてのタブとパネルの状態を更新
  tabs.forEach((tab) => {
    tab.setAttribute("aria-selected", tab === newTab ? "true" : "false"); // 選択状態を更新
    tab.setAttribute("tabindex", tab === newTab ? "0" : "-1"); // フォーカス可能かどうかを設定
  });

  tabPanels.forEach((panel) => {
    if (panel.getAttribute("aria-labelledby") === newTab.id) {
      panel.hidden = false; // 対応するパネルを表示
      panel.classList.add(SHOW_CLASS); // 表示中のパネルにクラスを追加

      // 子タブセットの処理
      const childTabSet = panel.querySelector(TABSET_SEL);
      if (childTabSet) {
        const firstChildTab = childTabSet.querySelector(TAB_SEL);
        if (firstChildTab) {
          firstChildTab.setAttribute("aria-selected", "true"); // 最初の子タブを選択状態にする
          firstChildTab.setAttribute("tabindex", "0"); // フォーカス可能にする

          // 子タブに対応するパネルを表示
          const childTabPanels = childTabSet.querySelectorAll(TABPANEL_SEL);
          childTabPanels.forEach((childPanel) => {
            if (
              childPanel.getAttribute("aria-labelledby") === firstChildTab.id
            ) {
              childPanel.hidden = false; // 子タブパネルを表示
              childPanel.classList.add(SHOW_CLASS);
            } else {
              childPanel.hidden = true; // 他の子タブパネルを非表示
              childPanel.classList.remove(SHOW_CLASS);
            }
          });
        }
      }
    } else {
      panel.hidden = true; // 他のパネルを非表示
      panel.classList.remove(SHOW_CLASS);
    }
  });
};

// 親タブの切り替え時に子タブのアクティブ状態をリセットする
const resetChildTabs = (tabSet) => {
  const childTabSets = tabSet.querySelectorAll(TABSET_SEL); // 子タブセットを取得
  childTabSets.forEach((childTabSet) => {
    const defaultTab =
      childTabSet.querySelector(`${TAB_SEL}[aria-selected="true"]`) ||
      childTabSet.querySelector(TAB_SEL); // デフォルトの子タブを取得
    if (defaultTab) activateTab(defaultTab); // 子タブをアクティブ化
  });
};

// タブのクリック時の処理
const handleTabClick = (e) => {
  const newTab = e.currentTarget;
  activateTab(newTab); // クリックされたタブをアクティブ化

  // 親タブを切り替えた際に子タブの状態をリセット
  const tabSet = newTab.closest(TABSET_SEL);
  resetChildTabs(tabSet);
};

// キーボード操作時の処理
const handleTabKeydown = (e) => {
  const key = e.key;
  const currentTab = e.currentTarget;
  const tabSet = currentTab.closest(TABSET_SEL); // 現在のタブセットを取得
  const tabs = Array.from(tabSet.querySelectorAll(TAB_SEL));
  let newIndex;

  if (key === "ArrowRight") {
    newIndex = (tabs.indexOf(currentTab) + 1) % tabs.length; // 右矢印で次のタブへ
  } else if (key === "ArrowLeft") {
    newIndex = (tabs.indexOf(currentTab) - 1 + tabs.length) % tabs.length; // 左矢印で前のタブへ
  } else if (key === "Enter" || key === " ") {
    activateTab(currentTab); // Enterまたはスペースでアクティブ化
  }

  if (newIndex !== undefined) {
    e.preventDefault(); // デフォルトのフォーカス移動をキャンセル
    tabs[newIndex].focus(); // 次のタブにフォーカスを移動
    activateTab(tabs[newIndex]); // 次のタブをアクティブ化

    // 子タブセットの最初のタブをアクティブ化
    const tabPanels = tabSet.querySelectorAll(TABPANEL_SEL);
    const activePanel = tabPanels[newIndex];
    if (activePanel) {
      const childTabSet = activePanel.querySelector(TABSET_SEL);
      if (childTabSet) {
        const firstChildTab = childTabSet.querySelector(TAB_SEL);
        if (firstChildTab) {
          activateTab(firstChildTab); // 子タブの最初をアクティブ化
        }
      }
    }
  }
};

// すべてのタブにイベントリスナーを追加
tabTriggers.forEach((tab) => {
  tab.addEventListener("click", handleTabClick); // クリックイベント
  tab.addEventListener("keydown", handleTabKeydown); // キーボード操作イベント
});