2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コンポーネントごとに考えるアクセシビリティAdvent Calendar 2023

Day 16

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

Last updated at Posted at 2023-12-15

はじめに

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

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

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

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

アクセシビリティを意識したメニューボタンの仕様

⚪︎ メニューボタンとは?

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

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

  • ボタンにフォーカスがある場合
    • Enterキー
      • メニューを開いて、最初のメニューアイテムにフォーカスを移動する
    • Spaceキー
      • メニューを開いて、最初のメニューアイテムにフォーカスを移動する
    • ↓キー(任意)
      • メニューを開いて、最初のメニューアイテムにフォーカスを移動する
    • ↑キー(任意)
      • メニューを開いて、最後のメニューアイテムにフォーカスを移動する
  • メニューが開いた後に必要なキーボードインタラクションは、こちら↓

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

  • メニューを開く要素には role="button" を設定する
  • role="button" を持つ要素は、aria-haspopupmenu 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 の役割、状態、プロパティはこちら↓

アクセシビリティを意識したメニューボタンの完成形

See the Pen Memu button Accessibility by でぐぅー | Qiita (@sp_degu) on CodePen.

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

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>

2. 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では、他のコンポーネントにも焦点を当てて、アクセシビリティについても解説しているので、ぜひ購読していてください。


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

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?