【アクセシビリティ】アクセシビリティを意識したタブの作り方

## はじめに
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/9c5639f2-b6e8-fe97-2799-198ce2f06375.png)

みなさんアクセシビリティを意識して開発できていますか？

必要なところに`role属性`を記述したり、`tabキー`でフォーカスができるようにしたりなど、意識しないといけないことも多いです。
そのため、アクセシビリティを完璧にやろうとするのは一苦労です。

ただ、コンポーネントごとに区切って、アクセシビリティを理解しておけば、実装するタイミングに思い出しやすく、アクセシビリティも意識しやすいと思います。

そのため、この記事では「**タブ**」に焦点を当てて、アクセシビリティを意識したタブの実装方法とタブで意識した方がいいアクセシビリティを解説しようと思います。

## アクセシビリティを意識したタブの仕様
### ⚪︎ タブとは？
タブは、複数のパネルを層状に重ね、一度に1つのパネルを表示するコンポーネントです。
各パネルには関連するタブ要素があり、それをアクティブにするとそのパネルが表示されます。
タブ要素のリストは、現在表示されているパネルの一辺に沿って配置されます。

タブは以下のような部分に分かれています。

+ **tab list**
  + 複数のタブをまとめている要素
+ **tab**
  + tab list内の要素で、tabに対応したtab panelを表示させるための要素
+ **tab panel**
  + タブに関連する内容を含む要素

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/a5c0e9bc-fd1f-61af-43a3-f16152e88bc0.png)

### ⚪︎ キーボードインタラクション

+ **`Tabキー`**
  + フォーカスがタブリストに移動すると、アクティブなタブ要素にフォーカスを移動する
  + タブリストにフォーカスがある時、フォーカスをtablistの外にある次の要素に移動する
+ 水平なタブリスト内のタブ要素にフォーカスがある場合
  + **`←キー`**
    + フォーカスを前のタブに移動する
    + フォーカスが最初のタブにある場合、最後のタブにフォーカスを移動する
    + 新しくフォーカスされたタブをアクティブにする（任意）
  + **`→キー`**
    + フォーカスを次のタブに移動する
    + フォーカスが最後のタブにある場合、最初のタブにフォーカスを移動する
    + 新しくフォーカスされたタブをアクティブにする（任意）
+ 垂直なタブリスト内のタブ要素にフォーカスがある場合
  + **`↑キー`**
    + フォーカスを前のタブに移動する
    + フォーカスが最初のタブにある場合、最後のタブにフォーカスを移動する
    + 新しくフォーカスされたタブをアクティブにする（任意）
  + **`↓キー`**
    + フォーカスを次のタブに移動する
    + フォーカスが最後のタブにある場合、最初のタブにフォーカスを移動する
    + 新しくフォーカスされたタブをアクティブにする（任意）
+ 水平と垂直方向のタブリストにあるタブにフォーカスがある場合
  + **`Spaceキー・Enterキー`**
    + フォーカスによって自動的にアクティブにしない場合は、タブをアクティブにする
  + **`Homeキー`**（任意）
    + フォーカスを最初のタブに移動する
    + 新しくフォーカスされたタブをアクティブにする（任意）
  + **`Endキー`**（任意）
    + フォーカスを最後のタブに移動する
    + 新しくフォーカスされたタブをアクティブにする（任意）
  + **`Shiftキー + F10キー`**
    + タブに関連するポップアップメニューがある場合、メニューを開く
  + **`Deleteキー`**（任意）
    + 削除が許可されている場合、現在のタブ要素とその関連するタブパネルを削除する（閉じる）
    + 閉じられたタブの次のタブにフォーカスを移動する
      + 新しくフォーカスされたタブをアクティブアクティブにする（任意）
    + 削除されたタブに続くタブがない場合、削除されたタブの前にあったタブにフォーカスを移動する
      + 新しくフォーカスされたタブをアクティブアクティブにする（任意）
    + アプリケーションがすべてのタブを削除することを許可し、ユーザーがタブリストの最後のタブを削除した場合、タブの次の要素にフォーカスを移動する

### ⚪︎ WAI-ARIA の役割、状態、プロパティ

+ タブリストは、`role="tablist"` を設定する
+ タブは、`role="tab"` を設定し、`role="tablist"` を持つ要素に含まれる
+ タブパネルは、`role="tabpanel"` を設定する
+ タブリストに可視ラベルがある場合、`role="tablist"` を持つ要素に、可視化レベルのIDを　`aria-labelledby` に設定する
  + そうでない場合は、tablist要素に `aria-label` でラベルを提供する
+ `role="tab"` を持つ要素には、関連する `role="tabpanel"` を持つ要素のIDを `aria-controls` に設定する
+ アクティブなタブ要素には、`aria-selected="true"` を設定する
  + 他のすべてのタブ要素は、`aria-selected="false"` を設定する
+ `role="tabpanel"` を持つ各要素には、関連するタブ要素のIDを `aria-labelledby` に設定する
+ タブ要素にポップアップメニューがある場合、その要素には `aria-haspopup="menu"` か `aria-haspopup="true"` を設定する
+ tablist要素が垂直方向に配置されている場合、`aria-orientation="vertical"` を設定する
  + tablist要素の `aria-orientation` は `horizontal` がデフォルト

## アクセシビリティを意識したタブの完成形
<p class="codepen" data-height="400" data-default-tab="result" data-slug-hash="mdvKaaO" data-editable="true" data-user="sp_degu" style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/sp_degu/pen/mdvKaaO">
  Spinbutton Pattern Accessibility</a> by でぐぅー | Qiita (<a href="https://codepen.io/sp_degu">@sp_degu</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## アクセシビリティを意識したタブの作り方
### 1. HTMLを実装する
```sample.html
<div class="container">
  <div role="tablist">
    <button role="tab" aria-selected="true" aria-controls="panelA" id="tabA" tabindex="0">
      Tab A
    </button>
    <button role="tab" aria-selected="false" aria-controls="panelB" id="tabB" tabindex="-1">
      Tab B
    </button>
    <button role="tab" aria-selected="false" aria-controls="panelC" id="tabC" tabindex="-1">
      Tab C
    </button>
  </div>
  <div id="panelA" role="tabpanel" aria-labelledby="tabA" tabindex="0">
    <p>Tab A Contents</p>
  </div>
  <div id="panelB" role="tabpanel" class="is-hidden" aria-labelledby="tabB" tabindex="0">
    <p>Tab B Contents</p>
  </div>
  <div id="panelC" role="tabpanel" class="is-hidden" aria-labelledby="tabC" tabindex="0">
    <p>Tab C Contents</p>
  </div>
</div>
```

### ２. CSSを実装する
```sample.css
body {
  background-color: #212529;
  color: #fff;
  display: grid;
  height: calc(100vh - 40px);
  margin: 0;
  padding: 20px 0;
  place-items: center;
  width: 100vw;
}

.container {
  background-color: rgb(128 128 128 / .3);
  border-radius: 24px;
  display: grid;
  gap: 8px;
  padding: 16px;
  position: relative;
  min-width: 150px;
  background-blend-mode: luminosity;
  backdrop-filter: blur(15px);
  max-width: 400px;
  width: 100%;
  &::before {
    background: linear-gradient(135deg, rgb(255 255 255 / .4) 0, rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0) 60%, rgb(255 255 255 / .1) 100%);
    border: 1.4px solid transparent;
    border-radius: 24px;
    content: "";
    inset: 0;
    position: absolute;
    -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
    -webkit-mask-composite: destination-out;
    mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
    mask-composite: exclude;
    z-index: -1;
  }
}

[role="tablist"] {
  background: linear-gradient(0deg, rgba(0, 0, 0, .1) 0%, rgba(0, 0, 0, 0.10) 100%), rgba(0, 0, 0, .5);
  background-blend-mode: luminosity, color-burn;
  border-radius: 100px;
  box-shadow: 1px 1.5px 4px 0px rgba(0, 0, 0, 0.10) inset, 1px 1.5px 4px 0px rgba(0, 0, 0, 0.08) inset, 0px -0.5px 1px 0px rgba(255, 255, 255, 0.25) inset, 0px -0.5px 1px 0px rgba(255, 255, 255, 0.30) inset;
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 4px;
  padding: 4px;
}

button[role="tab"] {
  background: none;
  border: none;
  border-radius: 50px;
  cursor: pointer;
  font-size: 16px;
  font-weight: 500;
  overflow: hidden;
  padding: 8px;
  
  &[aria-selected="true"] {
    background-color: rgb(128 128 128 / .3);
    background-blend-mode: luminosity;
    backdrop-filter: blur(15px);
    color: rgb(255 255 255 / .98);
    position: relative;
    &::before {
      background: linear-gradient(135deg, rgb(255 255 255 / .4) 0, rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0) 60%, rgb(255 255 255 / .1) 100%);
      border: 1.4px solid transparent;
      border-radius: 24px;
      content: "";
      inset: 0;
      position: absolute;
      -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
      -webkit-mask-composite: destination-out;
      mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
      mask-composite: exclude;
      z-index: -1;
    }
  }
  &[aria-selected="false"] {
    color: rgb(255 255 255 / .23);
  }
}

[role="tabpanel"] {
  background: linear-gradient(0deg, rgba(0, 0, 0, .1) 0%, rgba(0, 0, 0, 0.10) 100%), rgba(0, 0, 0, .5);
  background-blend-mode: luminosity, color-burn;
  border-radius: 12px;
  box-shadow: 1px 1.5px 4px 0px rgba(0, 0, 0, 0.10) inset, 1px 1.5px 4px 0px rgba(0, 0, 0, 0.08) inset, 0px -0.5px 1px 0px rgba(255, 255, 255, 0.25) inset, 0px -0.5px 1px 0px rgba(255, 255, 255, 0.30) inset;
  padding: 32px;
  
  &.is-hidden {
    display: none;
  }
}

p {
  text-align: center;
  font-size: 14px;
}
```

### 3. JavaScriptを実装する
```sample.js
window.addEventListener("DOMContentLoaded", () => {
  const tabs = document.querySelectorAll('[role="tab"]');
  const tabList = document.querySelector('[role="tablist"]');

  tabs.forEach(tab => {
    tab.addEventListener("click", changeTabs);
    tab.addEventListener("focus", changeTabs);
  });
  
  let tabFocus = 0;
  tabList.addEventListener("keydown", e => {
    if (e.keyCode === 37 || e.keyCode === 39) {
      tabs[tabFocus].setAttribute("tabindex", -1);
      
      if (e.keyCode === 37) {
        tabFocus--;
        if (tabFocus < 0) {
          tabFocus = tabs.length - 1;
        }        
      } else if (e.keyCode === 39) {
        tabFocus++;
        if (tabFocus >= tabs.length) {
          tabFocus = 0;
        }
      }
      tabs[tabFocus].setAttribute("tabindex", 0);
      tabs[tabFocus].focus();
    }
  })
})

function changeTabs(e) {
  const target = e.target;
  const parent = target.parentNode;
  const grandparent = parent.parentNode;
  
  parent.querySelectorAll('[aria-selected="true"]').forEach((t) => {
    t.setAttribute("aria-selected", false);
  });
  
  target.setAttribute("aria-selected", true);
  
  grandparent.querySelectorAll('[role="tabpanel"]').forEach((p) => {
    p.classList.add('is-hidden');
  });
  
  grandparent.parentNode.querySelector(`#${target.getAttribute("aria-controls")}`).classList.remove('is-hidden');
}
```

## まとめ
この記事では、「**タブ**」に焦点を当てて、アクセシビリティを意識したタブの実装方法とタブで意識した方がいいアクセシビリティを解説しました。

ぜひこの記事をストックして、タブを実装する時にアクセシビリティについて思い出してもらえると嬉しいです。

Advent Calendar 2023では、他のコンポーネントにも焦点を当てて、アクセシビリティについても解説しているので、ぜひ購読していてください。

https://qiita.com/advent-calendar/2023/degudegu2510

---

最後に、普段はHTMLやCSS、アクセシビリティ、デザインといったところを中心に
Qiitaに記事を投稿しているので、ぜひQiitaのフォローとX(Twitter)のフォローをお願いします。

https://twitter.com/degudegu2510

また、「フロントエンドxデザイン」をテーマにDevトークを募集しているので、
興味がある方は、ぜひお話しましょう。
Qiitaの記事の内容でもアウトプットに関する内容でも構いません。

https://jobs.qiita.com/employers/qiita-inc/dev_talks/404?utm_source=link_share&utm_medium=social&utm_campaign=dev_talk_share&utm_content=dev_talk_show