アクセシブルなタブUIを実装する

アクセシブルなタブUIを実装する

WAI-ARIAの仕様に沿って、アクセシブルなタブ UI を実装した際の備忘録を残しておきます。

使用言語、ツール、仕様は以下です。

  • HTML
  • CSS(SCSS)
  • JavaScript
  • WAI-ARIA
  • markuplint (lintツール、WAI-ARIA実装時のチェックに使用)

WAI-ARIAについての参考

markuplintについての参考

デモ

実際の完成形のデモが以下です。
各パネルの展開時にJavaScriptにてaria属性値の書き換えを行っています。

支援技術による読み上げの検証にはmacOS標準搭載のVoiceOverを使用しました。

HTMLの実装

DOMを取得するための任意のidを付与した要素内にタブリストとコンテンツを設置します。

また、以下の属性に対してcss、JavaScriptを使用することでパネルの展開や属性値の書き換えを行います。

  • id属性
  • role属性
  • aria-controls属性
  • aria-selected属性
  • aria-hidden属性
<div id="js-tab" class="p-tab">
  <!-- タブ一覧 -->
  <ul role="tablist" class="p-tab__list">
    <li role="presentation" class="p-tab__item">
      <button  role="tab" aria-controls="tabPanel1" aria-selected="true" class="p-tab__button">
        Tab 1
      </button>
    </li>
    <li role="presentation" class="p-tab__item">
      <button  role="tab" aria-controls="tabPanel2" aria-selected="false" class="p-tab__button">
        Tab 2
      </button>
    </li>
    <li role="presentation" class="p-tab__item">
      <button role="tab" aria-controls="tabPanel3" aria-selected="false" class="p-tab__button">
        Tab 3
      </button>
    </li>
  </ul>
  <!--/ タブ一覧 -->
  <!-- コンテンツ -->
  <div id="tabPanel1" role="tabpanel" aria-hidden="false" class="p-tab__panel">
    <p class="p-tab__panel-text">Tab1 Content</p>
  </div>
  <div id="tabPanel2" role="tabpanel" aria-hidden="true" class="p-tab__panel">
    <p class="p-tab__panel-text">Tab2 Content</p>
  </div>
  <div id="tabPanel3" role="tabpanel" aria-hidden="true" class="p-tab__panel">
    <p class="p-tab__panel-text">Tab3 Content</p>
  </div>
  <!--/ コンテンツ -->
</div>

WAI-ARIA導入時のポイント

aria属性の指定する際、以下のことに注意しながら実装しました。

  • 暗黙のロールが適切か
  • 他に適切な暗黙のロールを持った要素があるか
  • role属性で上書きするべきか
  • ロールの必須プロパティ/ステートを設定する

ロール/プロパティ/ステートについての参考

暗黙のロールをrole属性で上書きする

メンテナンス時の可読性を考慮してタブリストの実装にはul > li > buttonタグを利用し、それぞれの暗黙のロールをrole属性で上書きして実装します。

それぞれ、

  • ul -> role="list"
  • li -> role="listitem"
  • button -> role="button"

という暗黙のロールを持っています。
これらをタブリストとして機能するように以下のようなrole属性で上書きします。

  • ul -> role="tablist"
  • li -> role="presentation"
  • button -> role="tab"
<ul role="tablist" class="p-tab__list">
  <li role="presentation" class="p-tab__item">
    <button role="tab" class="p-tab__button">
      Tab 1
    </button>
  </li>
</ul>

加えて、コンテンツパネル部分のdiv要素に対してrole="tabpanel"を指定します。

<div role="tabpanel" class="p-tab__panel">
  <p class="p-tab__panel-text">Tab1 Content</p>
</div>

プロパティ/ステートを設定する

ユーザーの操作に対してaria属性の書き換えを行う必要のある、role="tab"role="tabpanel"に対して、以下のプロパティとステートを設定します。

  • role=”tab” -> aria-controls属性aria-selected属性
  • role=”tabpanel” -> aria-controlsに紐づいたid属性aria-hidden属性
<ul role="tablist" class="p-tab__list">
  <li role="presentation" class="p-tab__item">
    <button role="tab" aria-controls="tabPanel1" aria-selected="true" class="p-tab__button">
      Tab 1
    </button>
  </li>
</ul>
<div id="tabPanel1" role="tabpanel" aria-hidden="false" class="p-tab__panel">
  <p class="p-tab__panel-text">Tab1 Content</p>
</div>

それぞれ、以下のような役割があります。

  • aria-controls属性 -> タブとなるbutton要素とパネルのdiv要素の関連性を示す
  • aria-selected属性 -> 真偽値によってbutton要素の選択状態を示す
  • aria-hidden属性 -> タブパネル部分となるdiv要素の表示・非表示状態を示す

これらの属性を利用して、cssとJavaScriptで表示の制御や属性値の書き換えを行います。

CSSの実装

基本的なスタイリングに加えて、aria-*属性 * を利用して表示制御を行います。

.p-tab {
  max-width: 780px;
  margin: 0 auto;
  padding: 50px 20px;
}

.p-tab__list {
  display: flex;
}

.p-tab__item {
  &:not(:first-child) {
    margin-left: 2px;
  }
}

.p-tab__button {
  font-size: 18px;
  background: #fff;
  border: none;
  padding: 5px 20px;
  cursor: pointer;
  border-radius: 3px 3px  0 0;

  &[aria-selected="true"] {
    font-weight: bold;
    background-color: #80c0c0;
    color: #fff;
  }
}

.p-tab__panel {
  background-color: #fff;
  padding: 40px 20px;
  &[aria-hidden="true"] {
    display: none;
  }
  &[aria-hidden="false"] {
    display: block;
  }
}

.p-tab__panel-text {
  font-size: 16px;
  line-height: 1.4;
}

JavaScriptの実装

JavaScriptを用いてイベントの登録、aria属性値の書き換えを行います。

関数の定義

複数タブ要素を設置する場合を想定してinitTab関数として定義。
クリックイベントに対してsetAttributeを使用した属性値の書き換えを行います。

/**
 * Tab Function
 * @param { String } elementId タブ要素のid
 */

function initTab(elementId) {
  // タブ要素のidを取得
  const element = document.getElementById(elementId);
  // タブ要素内の[role="tab"]要素を取得
  const tabList = element?.querySelectorAll('[role="tab"]');

  /**
   * aria属性の書き換え
   * @param { String } event イベントハンドラへ登録するターゲット要素
   */
  const toggleTab = (event) => {
    // ターゲット要素をイベントハンドラへ登録
    const eventTarget = event.currentTarget;
    // イベントハンドラへ登録したターゲット要素のaria-controls属性を取得
    const targetPanel = eventTarget.getAttribute('aria-controls');
    // タブ要素内の[aria-selected="true"]を取得
    const activeTab = element?.querySelector('[aria-selected="true"]');
    // タブ要素内の[aria-hidden="false"]を取得
    const activeContent = element?.querySelector('[aria-hidden="false"]');

    // aria-selected属性の書き換え
    activeTab?.setAttribute('aria-selected', 'false');
    eventTarget?.setAttribute('aria-selected', 'true');

    // aria-hidden属性の書き換え
    activeContent?.setAttribute('aria-hidden', 'true');
    element?.querySelector(`#${targetPanel || 'untethered'}`)?.setAttribute('aria-hidden', 'false');

    // ターゲット要素のデフォルト動作をキャンセル
    event.preventDefault();
  };

  // clickイベントの登録
  tabList.forEach((tab) => {
    tab.addEventListener('click', toggleTab);
  });
}

// 関数の呼び出し
initTab('js-tab');

role="tab"を持ったtabに対して以下のようなクリックイベントを定義し、コールバック関数にtoggleTabを指定して格属性値の書き換えを行います。

// clickイベントの登録
tabList.forEach((tab) => {
  tab.addEventListener('click', toggleTab);
});

関数の呼び出し

関数の呼び出し時、引数にタブ要素のid属性を指定します。

//関数の呼び出し
initTab('js-tab');

まとめ

WAI-ARIAを導入する際は、markuplintなどのlinterで構文チェックを行ったり、サンプルを複数作成してVoiceOverなどの支援技術で実際に検証しながら実装するのが良いかと思いました。

また、他にもドロワーやアコーディオンなど頻出のUIに関しては別途記事にしたいと思います。