3
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 15

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

Last updated at Posted at 2023-12-14

はじめに

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

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

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

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

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

⚪︎ メニュー・メニューバーとは?

メニューは、ユーザーに対して一連の機能・アクションを提供するコンポーネントです。
一般的に、Header等にあるメニューバーからプルダウンするような見た目です。

また、メニューボタンをアクティブにしたり、サブメニューを開く 項目を選択したり、Shift + F10(windows)のようなコマンドを呼び出すことで、メニューが開かれ、表示されます。

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

前提

メニュー・メニューバーのキーボードインタラクションは、以下の前提としています。

  • 水平のメニューバーは、複数の menuitemmenuitemradiomenuitemcheckbox を含む
  • メニューバーの menuitem には、垂直に並べられたサブメニューを持つことがある
  • サブメニューの menuitem には、垂直に並べられた子サブメニューを持つことがある
  • フォーカスできる role="menuitem"role="menuitemradio"role="menuitemcheckbox" の要素をitemとしている
  • サブメニューは、role="menu" の要素である
  • 特定の場合以外は、メニューボタンで開いたメニューとメニューバーで開いたメニューは同じ振る舞いをする

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

メニューが開かれる時と、メニューバーがフォーカスを受ける時のキーボードフォーカスは、最初のアイテムに置かれます。また、メニュー要素とメニューバーは複合ウィジェットのため、tabShift + Tab でフォーカスを移動させません。そのため、以下のキーボードコマンドを使用することで、フォーカスを移動させます。

  • TabキーShift + Tabキー
    • メニューバーにフォーカスを移動する
      • メニューバーに初めてフォーカスが当たった場合、最初の menuitem にフォーカスする
      • メニューバーが以前フォーカスが当たっていた場合、以前の最後にフォーカスが当たっていた menuitem にフォーカスする(任意)
    • メニュー・メニューバーの menuitem にフォーカスがある場合、メニュー・メニューバーからフォーカスを移動させ、すべてのメニュー・サブメニューを閉じる
    • TabキーShift + Tabキーで、メニューにフォーカスが移動しないことを注意する
      + メニューは、メニューバーとは異なり、ユーザーがメニューを開いた時にフォーカスが、メニュー内のアイテムに移動させる必要があります。
  • Enterキー
    • サブメニューを持つmenuitemにフォーカスがある場合、サブメニューを開き、その最初のアイテムにフォーカスを移動する
    • それ以外の場合は、アイテムをアクティブにし、メニューを閉じる
  • Spaceキー
    • menuitemcheckbox にフォーカスがある場合、メニューは閉じずに状態を変更します。(任意)
    • チェックされていない menuitemradio にフォーカスがある場合、フォーカスがある menuitemradio をチェックし、他のチェックされていた menuitemradio のチェックを外す(任意)
    • サブメニューを持つ menuitem にフォーカスがある場合、サブメニューを開き、その最初のアイテムにフォーカスを移動する(任意)
    • サブメニューを持たない menuitem にフォーカスがある場合、menuitem をアクティブにし、メニューを閉じる(任意)
  • ↓キー
    • メニューバーの menuitem にフォーカスがあり、その menuitem にサブメニューがある場合は、サブメニューを開いて、サブメニューの最初のアイテムにフォーカスを移動する
    • メニュー内にフォーカスがある場合、次のアイテムにフォーカスを移動する
      • 最後のアイテムの場合は、最初のアイテムにフォーカスを移動する(任意)
  • ↑キー
    • メニュー内にフォーカスがある場合、前のアイテムにフォーカスを移動する
      • 最初のアイテムの場合は、最後のアイテムにフォーカスを移動する(任意)
    • メニューバーの menuitem にフォーカスがあり、その menuitem にサブメニューがある場合、サブメニューを開いて、サブメニューの最後のアイテムにフォーカスを移動する(任意)
  • →キー
    • メニューバー内にフォーカスがある場合、次のアイテムにフォーカスを移動する
      • 最後のアイテムの場合は、最初のアイテムにフォーカスを移動する(任意)
    • メニュー内にフォーカスがあり、サブメニューを持つ menuitem にフォーカスがある場合、サブメニューを開き、その最初のアイテムにフォーカスを移動する
    • メニュー内にフォーカスがあり、サブメニューを持たないアイテムにフォーカスがある場合、以下のアクションを実行する
      1. サブメニューとその親メニューを閉じる
      2. メニューバー内の次のアイテムにフォーカスを移動する
      3. フォーカスがサブメニューを持つ menuitem にある場合は、次のいずれかを実行する
      • その menuitem のサブメニューを開いてもサブメニュー内にフォーカスを移動させない
      • その menuitem のサブメニューを開いて、サブメニュー内の最初のアイテムにフォーカスを移動する
  • メニューバーが存在しない場合(メニューがメニューボタンから開かれた場合)、サブメニューを持たないアイテムにフォーカスがあるときは、右矢印キーを使っても何も起こらない
  • ←キー
    • メニューバー内にフォーカスがある場合、前のアイテムにフォーカスを移動する

      • 最初のアイテムの場合は、最後のアイテムにフォーカスを移動する(任意)
    • メニュー内のアイテムのサブメニュー内にフォーカスがある場合、サブメニューを閉じて、親のmenuitemにフォーカスを移動する

    • メニューバー内のアイテムのサブメニュー内にフォーカスがある場合、以下のアクションを実行する

      1. サブメニューを閉じる
      2. メニューバー内の前のアイテムにフォーカスを移動する
      3. フォーカスがサブメニューを持つ menuitem にある場合は、次のいずれかを実行する
      • その menuitem のサブメニューを開いても、サブメニュー内にフォーカスを移動させない
      • その menuitem のサブメニューを開いて、サブメニュー内の最初のアイテムにフォーカスを移動する
  • Homeキー
    • 矢印キー のフォーカスのラップがサポートしていない場合、メニュー・メニューバーの最初のアイテムにフォーカスを移動する
  • Endキー
    • 矢印キー のフォーカスのラップがサポートしていない場合、メニュー・メニューバーの最後のアイテムにフォーカスを移動する
  • Escapeキー
    • フォーカスを含むメニューを閉じ、メニューが開かれた要素(メニューボタンや親のmenuitem)にフォーカスを移動する
  • 印刷可能な文字に対応する任意のキー(オプション)
    • その印刷可能な文字で始まるラベルを持つメニューの次のアイテムにフォーカスを移動する

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

  • メニューとして選択肢を表すアイテムのコンテナーに role="menu" or role="menubar" を設定する
  • メニューに含まれるアイテムで、そのメニューやメニューバーの子要素なら、role="menuitem"role="menuitemcheckbox"role="menuitemradio" のいずれかのroleを設定する
  • menuitem をアクティブにし、サブメニューが開く場合、その menuitem親menuitem と呼ばれ、サブメニューのmenu要素は、以下のようにする
    • 親menuitem と同じmenu要素の内部に含める
    • 親menuitem と兄弟要素になる
  • 親menuitem は、aria-haspopuptrue or menu を設定する
  • 親menuitem は、子メニューが見える時は aria-expanded="true" を設定し、子メニューが見えない時は aria-expanded="false" を設定する
  • スクリプトを使ってメニュー内のアイテム間でフォーカスを移動するために、以下のいずれかのアプローチを使用する
    • メニューコンテナに tabindex="-1" or tabindex="0" を設定し、aria-activedescendantにフォーカスされたアイテムのIDを設定する
    • メニュー内の各アイテムに tabindex="-1" を設定し、メニューバーの最初のアイテムには、 tabindex="0" を設定する
  • menuitemcheckbox or menuitemradio がチェックされている場合、aria-checked="true" を設定する
  • メニューアイテムが無効の場合、aria-disabled="true" を設定する
  • メニュー内のアイテムは、グループ間に role="separator" を持つ要素を置くことでグループに分けられる場合がある
    • menuitemradio のアイテムのセットを含むメニューでは、この技術を使用するべきである
  • すべてのセパレーターは、セパレーターの向きと一致する aria-orientation を指定するべきである
  • メニューバーに可視ラベルがある場合、role="menubar" を持つ要素に、ラベル付け要素のIDを aria-labelledby に設定する
    • そうでない場合、メニューバー要素に aria-label を指定してラベルが設定する
  • メニューバーが垂直方向に配置されている場合、aria-orientation="vertical" を設定する
    • メニューバーの aria-orientation のデフォルトは horizontal
  • role="menu" を持つ要素は、以下のいずれかを設定する
    • 表示を制御する menuitem か、ボタンのIDを aria-labelledby に設定する
    • aria-label を指定してラベルを設定する
  • メニューが水平方向に配置されている場合、aria-orientation="horizontal" を設定する
    • メニューの aria-orientation のデフォルトは vertical

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

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

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

1. HTMLを実装する

sample.html
<nav aria-label="Mythical University">
  <ul class="menubar-navigation" role="menubar" aria-label="Mythical University">
    <li role="none">
      <a role="menuitem" aria-haspopup="true" aria-expanded="false">
        ユーザー
        <span class="material-symbols-outlined">expand_more</span>
      </a>
      <ul role="menu" aria-label="About">
        <li role="none">
          <a role="menuitem">
            <span class="material-symbols-outlined">person</span>
            ユーザーA
          </a>
        </li>
        <li role="none">
          <a role="menuitem">
            <span class="material-symbols-outlined">person</span>
            ユーザーB
          </a>
        </li>
        <li role="none">
          <a role="menuitem">
            <span class="material-symbols-outlined">person</span>
            ユーザーC
          </a>
        </li>
      </ul>
    </li>
    <li role="none">
      <a role="menuitem" aria-haspopup="true" aria-expanded="false">
        デバイス
        <span class="material-symbols-outlined">expand_more</span>
      </a>
      <ul role="menu" aria-label="Admissions">
        <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" aria-haspopup="true" aria-expanded="false">
            <span class="material-symbols-outlined">smartphone</span>
            モバイル
            <span class="material-symbols-outlined">chevron_right</span>
          </a>
          <ul role="menu" aria-label="Tuition">
            <li role="none">
              <a role="menuitem">iPhone</a>
            </li>
            <li role="none">
              <a role="menuitem">Google Pixel</a>
            </li>
            <li role="none">
              <a role="menuitem">Galaxy</a>
            </li>
          </ul>
        </li>
      </ul>
    </li>
  </ul>
</nav>

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;
}

nav {
  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;
  }
}

.menubar-navigation {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}

.menubar-navigation li {
  list-style: none;
  margin: 0;
  padding: 0;
}

.menubar-navigation > li {
  position: relative;
}

li > [role="menuitem"] {
  align-items: center;
  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;
  }
}

[role="menu"] {
  background-blend-mode: luminosity;
  background-color: rgb(128 128 128 / .3);
  backdrop-filter: blur(50px);
  border-radius: 24px;
  display: 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;
  }
}

[role="menu"] > li > [role="menuitem"] {
  font-weight: 400;
}

3. JavaScriptを実装する

sample.js
class MenubarNavigation {
  constructor(domNode) {
    var linkURL, linkTitle;

    this.domNode = domNode;

    this.menuitems = [];
    this.popups = [];
    this.menuitemGroups = {};
    this.menuOrientation = {};
    this.isPopup = {};
    this.isPopout = {};
    this.openPopups = false;

    this.firstChars = {};
    this.firstMenuitem = {};
    this.lastMenuitem = {};

    this.initMenu(domNode, 0);

    domNode.addEventListener('focusin', this.onMenubarFocusin.bind(this));
    domNode.addEventListener('focusout', this.onMenubarFocusout.bind(this));

    window.addEventListener(
      'pointerdown',
      this.onBackgroundPointerdown.bind(this),
      true
    );

    domNode.querySelector('[role=menuitem]').tabIndex = 0;
  }

  getParentMenuitem(menuitem) {
    var node = menuitem.parentNode;
    if (node) {
      node = node.parentNode;
      if (node) {
        node = node.previousElementSibling;
        if (node) {
          if (node.getAttribute('role') === 'menuitem') {
            return node;
          }
        }
      }
    }
    return false;
  }

  updateContent(linkURL, linkName, moveFocus) {
    var h1Node, paraNodes, pathNode;

    if (typeof moveFocus !== 'boolean') {
      moveFocus = true;
    }
  }

  getMenuitems(domNode, depth) {
    var nodes = [];

    var initMenu = this.initMenu.bind(this);
    var popups = this.popups;

    function findMenuitems(node) {
      var role, flag;

      while (node) {
        flag = true;
        role = node.getAttribute('role');

        if (role) {
          role = role.trim().toLowerCase();
        }

        switch (role) {
          case 'menu':
            node.tabIndex = -1;
            initMenu(node, depth + 1);
            flag = false;
            break;

          case 'menuitem':
            if (node.getAttribute('aria-haspopup') === 'true') {
              popups.push(node);
            }
            nodes.push(node);
            break;

          default:
            break;
        }

        if (
          flag &&
          node.firstElementChild &&
          node.firstElementChild.tagName !== 'svg'
        ) {
          findMenuitems(node.firstElementChild);
        }
        node = node.nextElementSibling;
      }
    }
    findMenuitems(domNode.firstElementChild);
    return nodes;
  }

  initMenu(menu, depth) {
    var menuitems, menuitem, role;

    var menuId = this.getMenuId(menu);

    menuitems = this.getMenuitems(menu, depth);
    this.menuOrientation[menuId] = this.getMenuOrientation(menu);

    this.isPopup[menuId] = menu.getAttribute('role') === 'menu' && depth === 1;
    this.isPopout[menuId] = menu.getAttribute('role') === 'menu' && depth > 1;

    this.menuitemGroups[menuId] = [];
    this.firstChars[menuId] = [];
    this.firstMenuitem[menuId] = null;
    this.lastMenuitem[menuId] = null;

    for (var i = 0; i < menuitems.length; i++) {
      menuitem = menuitems[i];
      role = menuitem.getAttribute('role');

      if (role.indexOf('menuitem') < 0) {
        continue;
      }

      menuitem.tabIndex = -1;
      this.menuitems.push(menuitem);
      this.menuitemGroups[menuId].push(menuitem);
      this.firstChars[menuId].push(
        menuitem.textContent.trim().toLowerCase()[0]
      );

      menuitem.addEventListener('keydown', this.onKeydown.bind(this));
      menuitem.addEventListener('click', this.onMenuitemClick.bind(this), {
        capture: true,
      });

      menuitem.addEventListener(
        'pointerover',
        this.onMenuitemPointerover.bind(this)
      );

      if (!this.firstMenuitem[menuId]) {
        if (this.hasPopup(menuitem)) {
          menuitem.tabIndex = 0;
        }
        this.firstMenuitem[menuId] = menuitem;
      }
      this.lastMenuitem[menuId] = menuitem;
    }
  }

  setFocusToMenuitem(menuId, newMenuitem) {
    this.closePopupAll(newMenuitem);

    if (this.menuitemGroups[menuId]) {
      this.menuitemGroups[menuId].forEach(function (item) {
        if (item === newMenuitem) {
          item.tabIndex = 0;
          newMenuitem.focus();
        } else {
          item.tabIndex = -1;
        }
      });
    }
  }

  setFocusToFirstMenuitem(menuId) {
    this.setFocusToMenuitem(menuId, this.firstMenuitem[menuId]);
  }

  setFocusToLastMenuitem(menuId) {
    this.setFocusToMenuitem(menuId, this.lastMenuitem[menuId]);
  }

  setFocusToPreviousMenuitem(menuId, currentMenuitem) {
    var newMenuitem, index;

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

    this.setFocusToMenuitem(menuId, newMenuitem);

    return newMenuitem;
  }

  setFocusToNextMenuitem(menuId, currentMenuitem) {
    var newMenuitem, index;

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

    return newMenuitem;
  }

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

    char = char.toLowerCase();

    start = this.menuitemGroups[menuId].indexOf(currentMenuitem) + 1;
    if (start >= this.menuitemGroups[menuId].length) {
      start = 0;
    }

    index = this.getIndexFirstChars(menuId, start, char);

    if (index === -1) {
      index = this.getIndexFirstChars(menuId, 0, char);
    }

    if (index > -1) {
      this.setFocusToMenuitem(menuId, this.menuitemGroups[menuId][index]);
    }
  }

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

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

  getIdFromAriaLabel(node) {
    var id = node.getAttribute('aria-label');
    if (id) {
      id = id.trim().toLowerCase().replace(' ', '-').replace('/', '-');
    }
    return id;
  }

  getMenuOrientation(node) {
    var orientation = node.getAttribute('aria-orientation');

    if (!orientation) {
      var role = node.getAttribute('role');

      switch (role) {
        case 'menubar':
          orientation = 'horizontal';
          break;

        case 'menu':
          orientation = 'vertical';
          break;

        default:
          break;
      }
    }

    return orientation;
  }

  getMenuId(node) {
    var id = false;
    var role = node.getAttribute('role');

    while (node && role !== 'menu' && role !== 'menubar') {
      node = node.parentNode;
      if (node) {
        role = node.getAttribute('role');
      }
    }

    if (node) {
      id = role + '-' + this.getIdFromAriaLabel(node);
    }

    return id;
  }

  getMenu(menuitem) {
    var menu = menuitem;
    var role = menuitem.getAttribute('role');

    while (menu && role !== 'menu' && role !== 'menubar') {
      menu = menu.parentNode;
      if (menu) {
        role = menu.getAttribute('role');
      }
    }

    return menu;
  }

  isAnyPopupOpen() {
    for (var i = 0; i < this.popups.length; i++) {
      if (this.popups[i].getAttribute('aria-expanded') === 'true') {
        return true;
      }
    }
    return false;
  }

  setMenubarDataExpanded(value) {
    this.domNode.setAttribute('data-menubar-item-expanded', value);
  }

  isMenubarDataExpandedTrue() {
    return this.domNode.getAttribute('data-menubar-item-expanded') === 'true';
  }

  openPopup(menuId, menuitem) {
    var popupMenu = menuitem.nextElementSibling;

    if (popupMenu) {
      var rect = menuitem.getBoundingClientRect();

      if (this.isPopup[menuId]) {
        popupMenu.parentNode.style.position = 'relative';
        popupMenu.style.display = 'block';
        popupMenu.style.position = 'absolute';
        popupMenu.style.left = rect.width + 20 + 'px';
        popupMenu.style.top = '-8px';
        popupMenu.style.zIndex = 100;
      } else {
        popupMenu.style.display = 'block';
        popupMenu.style.position = 'absolute';
        popupMenu.style.left = '-12px';
        popupMenu.style.top = rect.height + 20 + 'px';
        popupMenu.style.zIndex = 100;
      }

      menuitem.setAttribute('aria-expanded', 'true');
      this.setMenubarDataExpanded('true');
      return this.getMenuId(popupMenu);
    }

    return false;
  }

  closePopout(menuitem) {
    var menu,
      menuId = this.getMenuId(menuitem),
      cmi = menuitem;

    while (this.isPopup[menuId] || this.isPopout[menuId]) {
      menu = this.getMenu(cmi);
      cmi = menu.previousElementSibling;
      menuId = this.getMenuId(cmi);
      menu.style.display = 'none';
    }
    cmi.focus();
    return cmi;
  }

  closePopup(menuitem) {
    var menu,
      menuId = this.getMenuId(menuitem),
      cmi = menuitem;

    if (this.isMenubar(menuId)) {
      if (this.isOpen(menuitem)) {
        menuitem.setAttribute('aria-expanded', 'false');
        menuitem.nextElementSibling.style.display = 'none';
      }
    } else {
      menu = this.getMenu(menuitem);
      cmi = menu.previousElementSibling;
      cmi.setAttribute('aria-expanded', 'false');
      cmi.focus();
      menu.style.display = 'none';
    }

    return cmi;
  }

  doesNotContain(popup, menuitem) {
    if (menuitem) {
      return !popup.nextElementSibling.contains(menuitem);
    }
    return true;
  }

  closePopupAll(menuitem) {
    if (typeof menuitem !== 'object') {
      menuitem = false;
    }
    for (var i = 0; i < this.popups.length; i++) {
      var popup = this.popups[i];
      if (this.doesNotContain(popup, menuitem) && this.isOpen(popup)) {
        var cmi = popup.nextElementSibling;
        if (cmi) {
          popup.setAttribute('aria-expanded', 'false');
          cmi.style.display = 'none';
        }
      }
    }
  }

  hasPopup(menuitem) {
    return menuitem.getAttribute('aria-haspopup') === 'true';
  }

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

  isMenubar(menuId) {
    return !this.isPopup[menuId] && !this.isPopout[menuId];
  }

  isMenuHorizontal(menuitem) {
    return this.menuOrientation[menuitem] === 'horizontal';
  }

  hasFocus() {
    return this.domNode.classList.contains('focus');
  }

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

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

  onKeydown(event) {
    var tgt = event.currentTarget,
      key = event.key,
      flag = false,
      menuId = this.getMenuId(tgt),
      id,
      popupMenuId,
      mi;

    switch (key) {
      case ' ':
      case 'Enter':
        if (this.hasPopup(tgt)) {
          this.openPopups = true;
          popupMenuId = this.openPopup(menuId, tgt);
          this.setFocusToFirstMenuitem(popupMenuId);
        } else {
          if (tgt.href !== '#') {
            this.closePopupAll();
            this.updateContent(tgt.href, tgt.textContent.trim());
            this.setMenubarDataExpanded('false');
          }
        }
        flag = true;
        break;

      case 'Esc':
      case 'Escape':
        this.openPopups = false;
        mi = this.closePopup(tgt);
        id = this.getMenuId(mi);
        this.setMenubarDataExpanded('false');
        flag = true;
        break;

      case 'Up':
      case 'ArrowUp':
        if (this.isMenuHorizontal(menuId)) {
          if (this.hasPopup(tgt)) {
            this.openPopups = true;
            popupMenuId = this.openPopup(menuId, tgt);
            this.setFocusToLastMenuitem(popupMenuId);
          }
        } else {
          this.setFocusToPreviousMenuitem(menuId, tgt);
        }
        flag = true;
        break;

      case 'ArrowDown':
      case 'Down':
        if (this.isMenuHorizontal(menuId)) {
          if (this.hasPopup(tgt)) {
            this.openPopups = true;
            popupMenuId = this.openPopup(menuId, tgt);
            this.setFocusToFirstMenuitem(popupMenuId);
          }
        } else {
          this.setFocusToNextMenuitem(menuId, tgt);
        }
        flag = true;
        break;

      case 'Left':
      case 'ArrowLeft':
        if (this.isMenuHorizontal(menuId)) {
          mi = this.setFocusToPreviousMenuitem(menuId, tgt);
          if (this.isAnyPopupOpen() || this.isMenubarDataExpandedTrue()) {
            this.openPopup(menuId, mi);
          }
        } else {
          if (this.isPopout[menuId]) {
            mi = this.closePopup(tgt);
            id = this.getMenuId(mi);
            mi = this.setFocusToMenuitem(id, mi);
          } else {
            mi = this.closePopup(tgt);
            id = this.getMenuId(mi);
            mi = this.setFocusToPreviousMenuitem(id, mi);
            this.openPopup(id, mi);
          }
        }
        flag = true;
        break;

      case 'Right':
      case 'ArrowRight':
        if (this.isMenuHorizontal(menuId)) {
          mi = this.setFocusToNextMenuitem(menuId, tgt);
          if (this.isAnyPopupOpen() || this.isMenubarDataExpandedTrue()) {
            this.openPopup(menuId, mi);
          }
        } else {
          if (this.hasPopup(tgt)) {
            popupMenuId = this.openPopup(menuId, tgt);
            this.setFocusToFirstMenuitem(popupMenuId);
          } else {
            mi = this.closePopout(tgt);
            id = this.getMenuId(mi);
            mi = this.setFocusToNextMenuitem(id, mi);
            this.openPopup(id, mi);
          }
        }
        flag = true;
        break;

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

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

      case 'Tab':
        this.openPopups = false;
        this.setMenubarDataExpanded('false');
        this.closePopup(tgt);
        break;

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

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

  onMenuitemClick(event) {
    var tgt = event.currentTarget;
    var menuId = this.getMenuId(tgt);

    if (this.hasPopup(tgt)) {
      if (this.isOpen(tgt)) {
        this.closePopup(tgt);
      } else {
        this.closePopupAll(tgt);
        this.openPopup(menuId, tgt);
      }
    } else {
      this.updateContent(tgt.href, tgt.textContent.trim());
      this.closePopupAll();
    }
    event.stopPropagation();
    event.preventDefault();
  }

  onMenuitemPointerover(event) {
    var tgt = event.currentTarget;
    var menuId = this.getMenuId(tgt);

    if (this.hasFocus()) {
      this.setFocusToMenuitem(menuId, tgt);
    }

    if (this.isAnyPopupOpen() || this.hasFocus()) {
      this.closePopupAll(tgt);
      if (this.hasPopup(tgt)) {
        this.openPopup(menuId, tgt);
      }
    }
  }

  onBackgroundPointerdown(event) {
    if (!this.domNode.contains(event.target)) {
      this.closePopupAll();
    }
  }
}

window.addEventListener('load', function () {
  var menubarNavs = document.querySelectorAll('.menubar-navigation');
  for (var i = 0; i < menubarNavs.length; i++) {
    new MenubarNavigation(menubarNavs[i]);
  }
});

まとめ

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

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

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


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

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

3
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
3
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?