🍦

Vanilla TypeScriptとWAI-ARIAで作るタブUI(タブ切り替え)

2021/12/30に公開

タブ UI を素の TypeScript(JavaScript)と WAI-ARIA で実装する方法についてまとめました。
主に HTML ベースでサイトのコンテンツを運用更新したい場合や特定のライブラリやフレームワークに依存したくない場合で参考にしていただけるかと思います。

この記事で行うこと

この記事では以下の言語や仕様を使ってタブ UI を作成します。特に WAI-ARIA の利用が実装のコアになります。WAI-ARIA に対応することで、アクセシブルなコンポーネント実装を目指します。

  • HTML
  • CSS
  • TypeScript(JavaScript)
  • WAI-ARIA

https://developer.mozilla.org/ja/docs/Learn/Accessibility/WAI-ARIA_basics

https://twitter.com/tim_yone/status/1477935178912010241?s=20

HTML マークアップ

HTML ファイルを作成し、以下のようにマークアップします。コンポーネントのラッパーに特定の ID を付与します。ここではsample-tabとしました。また、パネルコンテンツID, role, aria-controls, aria-selected, aria-hidden, tabindexの利用が必須となります。

https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Roles/Tab_Role

<div id="sample-tab">
  <!-- タブ一覧 -->
  <ul role="tablist">
    <li role="presentation">
      <button
        role="tab"
        aria-controls="panel1"
        aria-selected="true"
        tabindex="0"
      >
        Tab 1
      </button>
    </li>
    <li role="presentation">
      <button
        role="tab"
        aria-controls="panel2"
        aria-selected="false"
        tabindex="-1"
      >
        Tab 2
      </button>
    </li>
    <li role="presentation">
      <button
        role="tab"
        aria-controls="panel3"
        aria-selected="false"
        tabindex="-1"
      >
        Tab 3
      </button>
    </li>
  </ul>
  <!--// タブ一覧 -->
  <!-- タブパネルコンテンツ -->
  <div id="panel1" role="tabpanel" aria-hidden="false" tabindex="0">
    <p>Panel1 Content</p>
  </div>
  <div id="panel2" role="tabpanel" aria-hidden="true" tabindex="0">
    <p>Panel2 Content</p>
  </div>
  <div id="panel3" role="tabpanel" aria-hidden="true" tabindex="0">
    <p>Panel3 Content</p>
  </div>
  <!--// タブパネルコンテンツ -->
</div>

aria-controls の指定

aria-controlsの値はパネルコンテンツの ID 属性と紐づくように記述します。

aria-controls="panel1"

aria-selected の指定

aria-selectedはタブの選択状態を管理するために利用します。
aria-selectedはブール値で指定します。選択済みのタブにはtrueを設定します。

aria-selected="true"

tabindex の指定

tabindexはキーボード移動の順番を制御するために利用します。
tabindexには0もしくは−1を設定します。タブ一覧部分は選択済みのタブに0を指定し、他は-1にします。タブパネルコンテンツではtabindexをすべて0にします。

tabindex="0"

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/tabindex

aria-hidden の指定

aria-hiddenはタブパネルコンテンツの表示状態を管理するために利用します。
展開されているコンテンツにはfalseを指定し、非表示のコンテンツにはtrueを指定します。

aria-hidden="false"

https://developers.google.com/web/fundamentals/accessibility/semantics-aria/hiding-and-updating-content?hl=ja

CSS による表示制御

視覚情報のコンテンツ表示制御には CSS を用います。
以下のようにaria-*を利用してスタイリングします。

[aria-hidden="true"] {
  display: none;
}
[aria-hidden="false"] {
  display: block;
}

TypeScript でのコンポーネント制御

ユーザー操作に応じてaria-*を切り替えられるようにプログラムを記述します。ここでは再利用可能な処理にするため、tab 関数を作成しました。

引数にコンポーネントを特定するためにラッパーで設定した ID を指定します。

toggle-tab.ts
/**
 * タブ切り替え関数
 * @param { String } wrapperId - ラッパーID
 * ex: tab('sample-tab');
 */

function tab(wrapperId: string): void {
  const element = document.getElementById(wrapperId);
  const tabList = element?.querySelector('[role="tablist"]');
  const tabButtonList = element?.querySelectorAll('[role="tab"]');
  const tabArrayList = [].slice.call(tabButtonList);

  // Initialize tabFocus
  const activeTab = element?.querySelector('[aria-selected="true"]') as HTMLButtonElement;
  const indexNum = (tabArrayList as HTMLButtonElement[]).indexOf(activeTab);
  let tabFocus = indexNum || 0;

  // Toggle function
  const toggleTab = (event: Event): void => {
    const eventTarget = event.currentTarget as HTMLButtonElement;
    const targetPanel = eventTarget.getAttribute('aria-controls');
    const activeTab = element?.querySelector('[aria-selected="true"]');
    const activeContent = element?.querySelector('[aria-hidden="false"]');

    // Toggle tab's aria-selected
    activeTab?.setAttribute('aria-selected', 'false');
    activeTab?.setAttribute('tabindex', '-1');
    eventTarget?.setAttribute('aria-selected', 'true');
    eventTarget?.setAttribute('tabindex', '0');
    const indexNum = (tabArrayList as HTMLButtonElement[]).indexOf(eventTarget);
    tabFocus = indexNum;

    // Toggle content's aria-hidden
    activeContent?.setAttribute('aria-hidden', 'true');
    element?.querySelector(`#${targetPanel || 'not-supplied'}`)?.setAttribute('aria-hidden', 'false');
    event.preventDefault();
  };

  // Tab click EventListener
  tabButtonList?.forEach((item) => {
    item.addEventListener('click', toggleTab);
  });

  // Keydown function
  const keydownFocus = (event: KeyboardEventInit) => {
    // Detect arrow direction
    if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') {
      // Reset tabindex
      tabButtonList && tabButtonList[tabFocus].setAttribute('tabindex', '-1');
      // Move Right
      if (tabButtonList && event.code === 'ArrowRight') {
        tabFocus += 1;
        // If you are at the end, go back to the start
        if (tabFocus >= tabButtonList.length) {
          tabFocus = 0;
        }
      } else if (tabButtonList && event.code === 'ArrowLeft') {
        tabFocus -= 1;
        // If you are at the start, move to the end
        if (tabFocus < 0) {
          tabFocus = tabButtonList.length - 1;
        }
      }
      // Change tabindex
      const tabFocused = tabButtonList && (tabButtonList[tabFocus] as HTMLButtonElement);
      tabFocused && tabFocused.setAttribute('tabindex', '0');
      tabFocused && tabFocused.focus();
    }
  };

  // Tab keydown EventListener
  tabList?.addEventListener('keydown', keydownFocus);
}

タブ選択操作による制御

タブクリック時にaria-selected, tabindex, aria-hiddenを切り替えるためにclickイベントを登録します。属性値の切り替えにはsetAttributeを利用します。

// Tab click EventListener
tabButtonList?.forEach((item) => {
  item.addEventListener("click", toggleTab);
});

https://developer.mozilla.org/ja/docs/Web/API/Element/setAttribute

キーボード操作による制御

右矢印、左矢印のキーボード操作でフォーカスの移動ができるようkeydownのイベントを登録します。

// Tab keydown EventListener
tabList?.addEventListener("keydown", keydownFocus);

KeyboardEventは以下のようにcodeプロパティで取得できます。
https://developer.mozilla.org/ja/docs/Web/API/KeyboardEvent/code

if (event.code === "ArrowRight" || event.code === "ArrowLeft") {
  // 省略
}

https://www.w3.org/TR/uievents-code/#key-arrowpad-section

右にフォーカスを移動する事にtabFocusの値を 1 ずつ増やし、左に移動したさいには 1 ずつ減らします。また、タブのフォーカス移動が先頭もしくは末尾を超えた場合はループでフォーカス移動するよう設定しています。

tab 関数の実行

以下のように関数を実行すればロジックの制御まで実装完了です。

tab("sample-tab");

デモ

以上の実装にコンポーネントの装飾を少し加えたものが次のデモになります。
タブクリックでのパネル展開はもちろんのこと、左右のキーボード操作でフォーカス移動できることを確認いただけます。

まとめ

受託 Web 制作の現場だとクライアントが HTML を更新するケースも多く、JSX などで TypeScript(JavaScript)にコンテンツ要素を入れることが難しくなります。
そのような場合には、今回紹介したアプローチを検討いただけると幸いです。

TAM

Discussion