【アクセシビリティ】アクセシビリティを意識したメニューボタンの作り方

## はじめに
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/2971cfe8-9a3c-9d8f-7939-b88b9b5bc030.png)

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

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

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

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

## アクセシビリティを意識したメニューボタンの仕様
### ⚪︎ メニューボタンとは？
メニューボタンは、メニューを開くボタンです。
ボタンをアクティブにすると、メニューが表示されます。
また、押すタイプのボタンの中に下向きの三角形がヒントとしてデザインされていることが一般的です。

### ⚪︎ キーボードインタラクション
+ ボタンにフォーカスがある場合
  + **`Enterキー`**
    + メニューを開いて、最初のメニューアイテムにフォーカスを移動する
  + **`Spaceキー`**
    + メニューを開いて、最初のメニューアイテムにフォーカスを移動する
  + **`↓キー`**（任意）
    + メニューを開いて、最初のメニューアイテムにフォーカスを移動する
  + **`↑キー`**（任意）
    + メニューを開いて、最後のメニューアイテムにフォーカスを移動する
+ メニューが開いた後に必要なキーボードインタラクションは、こちら↓
  + [【アクセシビリティ】アクセシビリティを意識したメニュー・メニューバーの作り方]()

### ⚪︎ WAI-ARIA の役割、状態、プロパティ
+ メニューを開く要素には `role="button"` を設定する
+ `role="button"` を持つ要素は、`aria-haspopup` に `menu` or `true` を設定する
+ メニューが表示されているとき、`role="button"` を持つ要素に `aria-expanded="true"` を設定する
+ メニューが隠れているとき、`role="button"` を持つ要素に `aria-expanded` を設定しないか、`aria-expanded="false"` を設定する
+ ボタンをアクティブにすることで表示される `menuitem` を含む要素には `role="menu"` を設定する
+ `role="button"` を持つ要素は、`role="menu"`を持つ要素のIDを　`aria-controls` に設定する
+ メニュー要素に必要な WAI-ARIA の役割、状態、プロパティはこちら↓
  + [【アクセシビリティ】アクセシビリティを意識したメニュー・メニューバーの作り方]()

## アクセシビリティを意識したメニューボタンの完成形
<p class="codepen" data-height="400" data-default-tab="result" data-slug-hash="JjxZJLZ" 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/JjxZJLZ">
  Memu button 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="menu-button-links">
  <button type="button" id="menubutton" aria-haspopup="true" aria-controls="menu2">
    デバイス
    <span class="material-symbols-outlined">expand_more</span>
  </button>
  <ul id="menu2" role="menu" aria-labelledby="menubutton">
    <li role="none">
      <a role="menuitem">
        <span class="material-symbols-outlined">computer</span>
        パソコン
      </a>
    </li>
    <li role="none">
      <a role="menuitem">
        <span class="material-symbols-outlined">tablet_android</span>
        タブレット
      </a>
    </li>
    <li role="none">
      <a role="menuitem">
        <span class="material-symbols-outlined">smartphone</span>
        モバイル
      </a>
    </li>
  </ul>
</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;
  align-items: start;
}

.menu-button-links {
  backdrop-filter: blur(50px);
  background-color: rgb(128 128 128 / .3);
  background-blend-mode: luminosity;
  border-radius: 24px;
  padding: 12px;
  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;
  }
}

.menu-button-links button {
  align-items: center;
  background: none;
  border: none;
  color: #ffffff;
  display: flex;
  font-size: 17px;
  font-weight: 600;
  gap: 8px;
  padding: 8px 16px;
  text-decoration: none;
  white-space: nowrap;
  &:hover, &:focus, &.focused {
    border-radius: 8px;
    background: radial-gradient(34.12% 136.61% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(50% 164.29% at 50% 100%, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.00) 60.33%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.06);
    background-blend-mode: color-dodge, normal, color-dodge, lighten;
  }
}

.menu-button-links [role="menu"] {
  background-blend-mode: luminosity;
  background-color: rgb(128 128 128 / .3);
  backdrop-filter: blur(50px);
  border-radius: 24px;
  display: none;
  list-style: none;
  position: absolute;
  padding: 12px;
  &::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;
  }
}

.menu-button-links [role="menuitem"] {
  align-items: center;
  background: none;
  border: none;
  color: #ffffff;
  display: flex;
  font-size: 17px;
  gap: 8px;
  padding: 8px 16px;
  text-decoration: none;
  white-space: nowrap;
  &:hover, &:focus, &.focused {
    border-radius: 8px;
    background: radial-gradient(34.12% 136.61% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(50% 164.29% at 50% 100%, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.00) 60.33%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.06);
    background-blend-mode: color-dodge, normal, color-dodge, lighten;
  }
}
```

### 3. JavaScriptを実装する
```sample.js
class MenuButtonLinks {
  constructor(domNode) {
    this.domNode = domNode;
    this.buttonNode = domNode.querySelector('button');
    this.menuNode = domNode.querySelector('[role="menu"]');
    this.menuitemNodes = [];
    this.firstMenuitem = false;
    this.lastMenuitem = false;
    this.firstChars = [];

    this.buttonNode.addEventListener(
      'keydown',
      this.onButtonKeydown.bind(this)
    );
    this.buttonNode.addEventListener('click', this.onButtonClick.bind(this));

    var nodes = domNode.querySelectorAll('[role="menuitem"]');

    for (var i = 0; i < nodes.length; i++) {
      var menuitem = nodes[i];
      this.menuitemNodes.push(menuitem);
      menuitem.tabIndex = -1;
      this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase());

      menuitem.addEventListener('keydown', this.onMenuitemKeydown.bind(this));

      menuitem.addEventListener(
        'mouseover',
        this.onMenuitemMouseover.bind(this)
      );

      if (!this.firstMenuitem) {
        this.firstMenuitem = menuitem;
      }
      this.lastMenuitem = menuitem;
    }

    domNode.addEventListener('focusin', this.onFocusin.bind(this));
    domNode.addEventListener('focusout', this.onFocusout.bind(this));

    window.addEventListener(
      'mousedown',
      this.onBackgroundMousedown.bind(this),
      true
    );
  }

  setFocusToMenuitem(newMenuitem) {
    this.menuitemNodes.forEach(function (item) {
      if (item === newMenuitem) {
        item.tabIndex = 0;
        newMenuitem.focus();
      } else {
        item.tabIndex = -1;
      }
    });
  }

  setFocusToFirstMenuitem() {
    this.setFocusToMenuitem(this.firstMenuitem);
  }

  setFocusToLastMenuitem() {
    this.setFocusToMenuitem(this.lastMenuitem);
  }

  setFocusToPreviousMenuitem(currentMenuitem) {
    var newMenuitem, index;

    if (currentMenuitem === this.firstMenuitem) {
      newMenuitem = this.lastMenuitem;
    } else {
      index = this.menuitemNodes.indexOf(currentMenuitem);
      newMenuitem = this.menuitemNodes[index - 1];
    }

    this.setFocusToMenuitem(newMenuitem);

    return newMenuitem;
  }

  setFocusToNextMenuitem(currentMenuitem) {
    var newMenuitem, index;

    if (currentMenuitem === this.lastMenuitem) {
      newMenuitem = this.firstMenuitem;
    } else {
      index = this.menuitemNodes.indexOf(currentMenuitem);
      newMenuitem = this.menuitemNodes[index + 1];
    }
    this.setFocusToMenuitem(newMenuitem);

    return newMenuitem;
  }

  setFocusByFirstCharacter(currentMenuitem, char) {
    var start, index;

    if (char.length > 1) {
      return;
    }

    char = char.toLowerCase();

    start = this.menuitemNodes.indexOf(currentMenuitem) + 1;
    if (start >= this.menuitemNodes.length) {
      start = 0;
    }

    index = this.firstChars.indexOf(char, start);

    if (index === -1) {
      index = this.firstChars.indexOf(char, 0);
    }

    if (index > -1) {
      this.setFocusToMenuitem(this.menuitemNodes[index]);
    }
  }

  getIndexFirstChars(startIndex, char) {
    for (var i = startIndex; i < this.firstChars.length; i++) {
      if (char === this.firstChars[i]) {
        return i;
      }
    }
    return -1;
  }

  openPopup() {
    this.menuNode.style.display = 'block';
    this.menuNode.style.left = '0';
    this.menuNode.style.top = '58px';
    this.buttonNode.setAttribute('aria-expanded', 'true');
  }

  closePopup() {
    if (this.isOpen()) {
      this.buttonNode.removeAttribute('aria-expanded');
      this.menuNode.style.display = 'none';
    }
  }

  isOpen() {
    return this.buttonNode.getAttribute('aria-expanded') === 'true';
  }

  onFocusin() {
    this.domNode.classList.add('focus');
  }

  onFocusout() {
    this.domNode.classList.remove('focus');
  }

  onButtonKeydown(event) {
    var key = event.key,
      flag = false;

    switch (key) {
      case ' ':
      case 'Enter':
      case 'ArrowDown':
      case 'Down':
        this.openPopup();
        this.setFocusToFirstMenuitem();
        flag = true;
        break;

      case 'Esc':
      case 'Escape':
        this.closePopup();
        this.buttonNode.focus();
        flag = true;
        break;

      case 'Up':
      case 'ArrowUp':
        this.openPopup();
        this.setFocusToLastMenuitem();
        flag = true;
        break;

      default:
        break;
    }

    if (flag) {
      event.stopPropagation();
      event.preventDefault();
    }
  }

  onButtonClick(event) {
    if (this.isOpen()) {
      this.closePopup();
      this.buttonNode.focus();
    } else {
      this.openPopup();
      this.setFocusToFirstMenuitem();
    }

    event.stopPropagation();
    event.preventDefault();
  }

  onMenuitemKeydown(event) {
    var tgt = event.currentTarget,
      key = event.key,
      flag = false;

    function isPrintableCharacter(str) {
      return str.length === 1 && str.match(/\S/);
    }

    if (event.ctrlKey || event.altKey || event.metaKey) {
      return;
    }

    if (event.shiftKey) {
      if (isPrintableCharacter(key)) {
        this.setFocusByFirstCharacter(tgt, key);
        flag = true;
      }

      if (event.key === 'Tab') {
        this.buttonNode.focus();
        this.closePopup();
        flag = true;
      }
    } else {
      switch (key) {
        case ' ':
          window.location.href = tgt.href;
          break;

        case 'Esc':
        case 'Escape':
          this.closePopup();
          this.buttonNode.focus();
          flag = true;
          break;

        case 'Up':
        case 'ArrowUp':
          this.setFocusToPreviousMenuitem(tgt);
          flag = true;
          break;

        case 'ArrowDown':
        case 'Down':
          this.setFocusToNextMenuitem(tgt);
          flag = true;
          break;

        case 'Home':
        case 'PageUp':
          this.setFocusToFirstMenuitem();
          flag = true;
          break;

        case 'End':
        case 'PageDown':
          this.setFocusToLastMenuitem();
          flag = true;
          break;

        case 'Tab':
          this.closePopup();
          break;

        default:
          if (isPrintableCharacter(key)) {
            this.setFocusByFirstCharacter(tgt, key);
            flag = true;
          }
          break;
      }
    }

    if (flag) {
      event.stopPropagation();
      event.preventDefault();
    }
  }

  onMenuitemMouseover(event) {
    var tgt = event.currentTarget;
    tgt.focus();
  }

  onBackgroundMousedown(event) {
    if (!this.domNode.contains(event.target)) {
      if (this.isOpen()) {
        this.closePopup();
        this.buttonNode.focus();
      }
    }
  }
}

window.addEventListener('load', function () {
  var menuButtons = document.querySelectorAll('.menu-button-links');
  for (let i = 0; i < menuButtons.length; i++) {
    new MenuButtonLinks(menuButtons[i]);
  }
});
```

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

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

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

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

---

最後まで読んでくださってありがとうございます！

普段はデザインやフロントエンドを中心にQiitaに記事を投稿しているので、ぜひQiitaのフォローとX(Twitter)のフォローをお願いします。

https://twitter.com/degudegu2510