【アクセシビリティ】アクセシビリティを意識したリストボックスの作り方

## はじめに
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390720/a3682daa-0423-b3e3-13c9-af3fdd6338e2.png)

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

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

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

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

## アクセシビリティを意識したリストボックスの仕様
### ⚪︎ リストボックスとは？
リストボックスはオプションのリストを表示し、ユーザーがそれらの中から1つまたは複数を選択できるコンポーネントです。
また、単一のオプションを選択できるリストボックスはシングルセレクトリストボックスで、複数のオプションを選択できるものはマルチセレクトリストボックスと呼ばれます。

スクリーンリーダーがリストボックスを提示すると、各オプション名、状態、位置が表示される場合があります。
そのため、オプション名にはオプション要素のコンテンツから計算される文字列になりセマンティック情報は含まれません。
また、オプションにセマンティック要素が含まれている場合、スクリーンリーダーユーザーはそのセマンティクスにアクセスできない可能性があります。

オプション名は、長くなるとスクリーンリーダーユーザーにとって理解しやすくなる一方と知覚性を妨げることがあります。
オプションが読まれるときに単一のスピーチユニットとして発音されるため、1回のキー操作の結果として多くの情報が話されると、理解が難しくなります。

また長い名前は、中断されたスピーチの影響を増加させるため、ユーザーはオプション全体を再度読む必要があります。
そして、もしユーザーが話されている内容を理解できない場合、名前を文字、単語、フレーズ単位で読むことが、リストボックスウィジェットの文脈でスクリーンリーダーユーザーにとって難しい操作になります。


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

#### シングルセレクトリストボックス
+ リストボックスがフォーカスを受ける前に、どのオプションも選択されていない場合、最初のオプションがフォーカスを受ける
  + （任意）最初のオプションが自動的に選択されることがある
+ リストボックスがフォーカスを受ける前に、オプションが選択されている場合は、選択されているオプションがフォーカスを受ける
+ 7つ以上のオプションを持つすべてのリストボックスには、タイプアヘッド機能を推奨する
  + 文字を入力すると、入力した文字から始まるオプションにフォーカスを移動する
  + 複数文字を入力すると、入力した文字列から始まるオプションにフォーカスを移動する
+ **`↓キー`**
  + フォーカスを次のオプションに移動する
  + （任意）フォーカスとともに選択も移動する場合がある
+ **`↑キー`**
  + フォーカスを前のオプションに移動する
  + （任意）フォーカスとともに選択も移動する場合がある
+ **`Homeキー`**（任意）
  + フォーカスを最初のオプションに移動する
+ **`Endキー`**（任意）
  + フォーカスを最後のオプションに移動する


#### マルチセレクトリストボックス
+ リストボックスがフォーカスを受ける前に、どのオプションも選択されていない場合、最初のオプションがフォーカスを受ける
+ リストボックスがフォーカスを受ける前に、1個以上オプションが選択されている場合は、選択されたオプションの中で最初のオプションがフォーカスを受ける
+ 7つ以上のオプションを持つすべてのリストボックスには、タイプアヘッド機能を推奨する
  + 文字を入力すると、入力した文字から始まるオプションにフォーカスを移動する
  + 複数文字を入力すると、入力した文字列から始まるオプションにフォーカスを移動する
+ **`↓キー`**
  + フォーカスを次のオプションに移動する
+ **`↑キー`**
  + フォーカスを前のオプションに移動する
+ **`Homeキー`**（任意）
  + フォーカスを最初のオプションに移動する
+ **`Endキー`**（任意）
  + フォーカスを最後のオプションに移動する

マルチセレクトリストボックスは、リストをナビゲーションする際に装飾キーを押さなくてもいい形にするか、選択状態を失わないために修飾キーを押す必要がある代替モデルにする
+ **修飾キーを押し続ける必要はない 推奨選択モデル**
  + **`Spaceキー`**
    + フォーカスされているオプションの選択状態を変更する
  + **`Shiftキー + ↓キー`**（任意）
    + 次のオプションにフォーカスを移し、選択状態を切り替える
  + **`Shiftキー + ↑キー`**（任意）
    + 前のオプションにフォーカスを移し、選択状態を切り替える
  + **`Shiftキー + Spaceキー`**（任意）
    + 直近で選択された項目からフォーカスされた項目までの連続した項目を選択する
  + **`Controlキー + Shiftキー + Homeキー`**（任意）
    + フォーカスされているオプションと、最初のオプションまでのすべてのオプションを選択する
    + （任意）フォーカスを最初のオプションに移動する
  + **`Controlキー + Shiftキー + Endキー`**（任意）
    + フォーカスされているオプションと、最後のオプションまでのすべてのオプションを選択する
    + （任意）フォーカスを最後のオプションに移動する
  + **`Controlキー + Aキー`**（任意）
    + リスト内のすべてのオプションを選択する
    + （任意）すべてのオプションが選択されている場合、すべてのオプションの選択を解除する
+ **修飾子を押したままフォーカスを移動する 別の選択モデル**
  + **`Shiftキー + ↓キー`**
    + 次のオプションにフォーカスを移し、選択状態を切り替える
  + **`Shiftキー + ↑キー`**
    + 前のオプションにフォーカスを移し、選択状態を切り替える
  + **`Controlキー + ↑キー`**
    + 選択状態を変えずに次のオプションにフォーカスを移動する
  + **`Controlキー + ↓キー`**
    + 選択状態を変えずに前のオプションにフォーカスを移動する
  + **`Controlキー + Spaceキー`**
    + フォーカスされているオプションの選択状態を変更する
  + **`Shiftキー + Spaceキー`**（任意）
    + 最近選択された項目からフォーカスされた項目までの連続した項目を選択する
  + **`Controlキー + Shiftキー + Homeキー`**（任意）
    + フォーカスされているオプションと、最初のオプションまでのすべてのオプションを選択する
    + （任意）フォーカスを最初のオプションに移動する
  + **`Controlキー + Shiftキー + Endキー`**（任意）
    + フォーカスされているオプションと、最後のオプションまでのすべてのオプションを選択する
    + （任意）フォーカスを最後のオプションに移動する
  + **`Controlキー + Aキー`**（任意）
    + リスト内のすべてのオプションを選択する
    + （任意）すべてのオプションが選択されている場合、すべてのオプションの選択を解除する

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

+ リストボックスのオプションを含む要素には、`role="listbox"` を設定する
+ リストボックスのオプションは、`role="option"` を設定し、以下の要素の子要素になる
  1. `role="listbox"` を持つ要素
  2. `role="listbox"` を持つ要素に含まれる、`role="group"` の役割がある要素
+ グループに含まれるオプションは以下の条件が適応される
  1. すべてのオプショングループは、少なくとも1つのオプションが含まれる
  2. 各オプショングループには、`aria-label`・`aria-labelledby` でラベルを提供する
+ `role="listbox"` を持つ要素が、他のコンポーネントの一部でない場合、`aria-labelledby` で参照される可視ラベルか、`aria-label` を設定する
+ リストボックスが1つ以上のオプションの選択をサポートしている場合、`role="listbox"` を持つ要素に `aria-multiselectable="true"` を設定する
  + それ以外の場合は、`aria-multiselectable="false"` を設定するか、デフォルト値 `false` を適応する
+ 各選択可能なオプションの選択状態は、`aria-selected` か `aria-checked` を設定する
  + `aria-selected` が設定している場合は、`aria-checked` は設定されない
  + `aria-checked` が設定している場合は、`aria-selected` は設定されない
+ オプションが選択されている場合、各選択されたオプションには、`aria-selected="true"` か `aria-checked="true"` を設定する
  + ストボックスに `aria-multiselectable="true"` を設定していない場合、１つのオプションしか選択されない
+ オプションが選択されていない場合、オプションには、`aria-selected="false"` か `aria-checked="false"` を設定する

<font color="gray">※ シングルセレクトリストボックスには `aria-selected`、マルチセレクトリストボックスには、`aria-checked`</font>

## アクセシビリティを意識したリストボックスの完成形
<p class="codepen" data-height="400" data-default-tab="result" data-slug-hash="ExrgpMx" 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/ExrgpMx">
  Listbox 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="listbox-area">
  <ul id="elem_list" tabindex="0" role="listbox" aria-label="元素"> 
    <li id="elem_Np" role="option">
      <span class="material-symbols-outlined" aria-hidden="true">check</span>
      ネプツニウム
    </li>
    <li id="elem_Pu" role="option">
      <span class="material-symbols-outlined" aria-hidden="true">check</span>
      プルトニウム
    </li>
    <li id="elem_Am" role="option">
      <span class="material-symbols-outlined" aria-hidden="true">check</span>
      アメリシウム
    </li>
    <li id="elem_Cm" role="option">
      <span class="material-symbols-outlined" aria-hidden="true">check</span>
      キュリウム
    </li>
    <li id="elem_Bk" role="option">
      <span class="material-symbols-outlined" aria-hidden="true">check</span>
      バークリウム
    </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;
}

.listbox-area {
  background-color: rgb(128 128 128 / .3);
  border-radius: 24px;
  padding: 16px;
  position: relative;
  min-width: 300px;
  background-blend-mode: luminosity;
  backdrop-filter: blur(50px);
  &::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;
  }
}

#elem_list {
  margin: 0;
  padding: 0;
  list-style: none;
}

li[role="option"] {
  align-items: center;
  display: flex;
  gap: 8px;
  padding: 8px;
  cursor: pointer;
  border-radius: 8px;
  &: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;
  }
  
  & > span {
    visibility: hidden;
  }
  
  &[aria-selected="true"] > span {
    visibility: visible;
  }
}


button {
  background: none;
  border: none;
  color: #ffffff;
}

.accordion-trigger {
  font-size: 16px;
  font-weight: bold;
  justify-content: space-between;
  padding: 16px 16px 12px;
  cursor: pointer;
  width: 100%;
}

.accordion-panel-item {
  cursor: pointer;
  gap: 8px;
  margin: 0 8px;
  width: calc(100% - 16px);
}


.material-symbols-outlined {
  font-variation-settings:
  'FILL' 1,
  'wght' 400,
  'GRAD' 0,
  'opsz' 24
}
```

### 3. JavaScriptを実装する
```sample.js
var aria = aria || {};

aria.Listbox = class Listbox {
  constructor(listboxNode) {
    this.listboxNode = listboxNode;
    this.activeDescendant = this.listboxNode.getAttribute(
      'aria-activedescendant'
    );
    this.multiselectable = this.listboxNode.hasAttribute(
      'aria-multiselectable'
    );
    this.moveUpDownEnabled = false;
    this.siblingList = null;
    this.startRangeIndex = 0;
    this.upButton = null;
    this.downButton = null;
    this.moveButton = null;
    this.keysSoFar = '';
    this.handleFocusChange = function () {};
    this.handleItemChange = function () {};
    this.registerEvents();
  }

  registerEvents() {
    this.listboxNode.addEventListener('focus', this.setupFocus.bind(this));
    this.listboxNode.addEventListener('keydown', this.checkKeyPress.bind(this));
    this.listboxNode.addEventListener('click', this.checkClickItem.bind(this));

    if (this.multiselectable) {
      this.listboxNode.addEventListener(
        'mousedown',
        this.checkMouseDown.bind(this)
      );
    }
  }

  setupFocus() {
    if (this.activeDescendant) {
      const listitem = document.getElementById(this.activeDescendant);
      listitem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
    }
  }

  focusFirstItem() {
    var firstItem = this.listboxNode.querySelector('[role="option"]');

    if (firstItem) {
      this.focusItem(firstItem);
    }
  }

  focusLastItem() {
    const itemList = this.listboxNode.querySelectorAll('[role="option"]');

    if (itemList.length) {
      this.focusItem(itemList[itemList.length - 1]);
    }
  }

  checkKeyPress(evt) {
    const lastActiveId = this.activeDescendant;
    const allOptions = this.listboxNode.querySelectorAll('[role="option"]');
    const currentItem =
      document.getElementById(this.activeDescendant) || allOptions[0];
    let nextItem = currentItem;

    if (!currentItem) {
      return;
    }

    switch (evt.key) {
      case 'PageUp':
      case 'PageDown':
        evt.preventDefault();
        if (this.moveUpDownEnabled) {
          if (evt.key === 'PageUp') {
            this.moveUpItems();
          } else {
            this.moveDownItems();
          }
        }

        break;
      case 'ArrowUp':
      case 'ArrowDown':
        evt.preventDefault();
        if (!this.activeDescendant) {
          // focus first option if no option was previously focused, and perform no other actions
          this.focusItem(currentItem);
          break;
        }

        if (this.moveUpDownEnabled && evt.altKey) {
          evt.preventDefault();
          if (evt.key === 'ArrowUp') {
            this.moveUpItems();
          } else {
            this.moveDownItems();
          }
          this.updateScroll();
          return;
        }

        if (evt.key === 'ArrowUp') {
          nextItem = this.findPreviousOption(currentItem);
        } else {
          nextItem = this.findNextOption(currentItem);
        }

        if (nextItem && this.multiselectable && event.shiftKey) {
          this.selectRange(this.startRangeIndex, nextItem);
        }

        if (nextItem) {
          this.focusItem(nextItem);
        }

        break;

      case 'Home':
        evt.preventDefault();
        this.focusFirstItem();

        if (this.multiselectable && evt.shiftKey && evt.ctrlKey) {
          this.selectRange(this.startRangeIndex, 0);
        }
        break;

      case 'End':
        evt.preventDefault();
        this.focusLastItem();

        if (this.multiselectable && evt.shiftKey && evt.ctrlKey) {
          this.selectRange(this.startRangeIndex, allOptions.length - 1);
        }
        break;

      case 'Shift':
        this.startRangeIndex = this.getElementIndex(currentItem, allOptions);
        break;

      case ' ':
        evt.preventDefault();
        this.toggleSelectItem(nextItem);
        break;

      case 'Backspace':
      case 'Delete':
      case 'Enter':
        if (!this.moveButton) {
          return;
        }

        var keyshortcuts = this.moveButton.getAttribute('aria-keyshortcuts');
        if (evt.key === 'Enter' && keyshortcuts.indexOf('Enter') === -1) {
          return;
        }
        if (
          (evt.key === 'Backspace' || evt.key === 'Delete') &&
          keyshortcuts.indexOf('Delete') === -1
        ) {
          return;
        }

        evt.preventDefault();

        var nextUnselected = nextItem.nextElementSibling;
        while (nextUnselected) {
          if (nextUnselected.getAttribute('aria-selected') != 'true') {
            break;
          }
          nextUnselected = nextUnselected.nextElementSibling;
        }
        if (!nextUnselected) {
          nextUnselected = nextItem.previousElementSibling;
          while (nextUnselected) {
            if (nextUnselected.getAttribute('aria-selected') != 'true') {
              break;
            }
            nextUnselected = nextUnselected.previousElementSibling;
          }
        }

        this.moveItems();

        if (!this.activeDescendant && nextUnselected) {
          this.focusItem(nextUnselected);
        }
        break;

      case 'A':
      case 'a':
        // handle control + A
        if (evt.ctrlKey || evt.metaKey) {
          if (this.multiselectable) {
            this.selectRange(0, allOptions.length - 1);
          }
          evt.preventDefault();
          break;
        }
      // fall through
      default:
        if (evt.key.length === 1) {
          const itemToFocus = this.findItemToFocus(evt.key.toLowerCase());
          if (itemToFocus) {
            this.focusItem(itemToFocus);
          }
        }
        break;
    }

    if (this.activeDescendant !== lastActiveId) {
      this.updateScroll();
    }
  }

  findItemToFocus(character) {
    const itemList = this.listboxNode.querySelectorAll('[role="option"]');
    let searchIndex = 0;

    if (!this.keysSoFar) {
      for (let i = 0; i < itemList.length; i++) {
        if (itemList[i].getAttribute('id') == this.activeDescendant) {
          searchIndex = i;
        }
      }
    }

    this.keysSoFar += character;
    this.clearKeysSoFarAfterDelay();

    let nextMatch = this.findMatchInRange(
      itemList,
      searchIndex + 1,
      itemList.length
    );

    if (!nextMatch) {
      nextMatch = this.findMatchInRange(itemList, 0, searchIndex);
    }
    return nextMatch;
  }

  /* Return the index of the passed element within the passed array, or null if not found */
  getElementIndex(option, options) {
    const allOptions = Array.prototype.slice.call(options); // convert to array
    const optionIndex = allOptions.indexOf(option);

    return typeof optionIndex === 'number' ? optionIndex : null;
  }

  /* Return the next listbox option, if it exists; otherwise, returns null */
  findNextOption(currentOption) {
    const allOptions = Array.prototype.slice.call(
      this.listboxNode.querySelectorAll('[role="option"]')
    ); // get options array
    const currentOptionIndex = allOptions.indexOf(currentOption);
    let nextOption = null;

    if (currentOptionIndex > -1 && currentOptionIndex < allOptions.length - 1) {
      nextOption = allOptions[currentOptionIndex + 1];
    }

    return nextOption;
  }

  /* Return the previous listbox option, if it exists; otherwise, returns null */
  findPreviousOption(currentOption) {
    const allOptions = Array.prototype.slice.call(
      this.listboxNode.querySelectorAll('[role="option"]')
    ); // get options array
    const currentOptionIndex = allOptions.indexOf(currentOption);
    let previousOption = null;

    if (currentOptionIndex > -1 && currentOptionIndex > 0) {
      previousOption = allOptions[currentOptionIndex - 1];
    }

    return previousOption;
  }

  clearKeysSoFarAfterDelay() {
    if (this.keyClear) {
      clearTimeout(this.keyClear);
      this.keyClear = null;
    }
    this.keyClear = setTimeout(
      function () {
        this.keysSoFar = '';
        this.keyClear = null;
      }.bind(this),
      500
    );
  }

  findMatchInRange(list, startIndex, endIndex) {
    // Find the first item starting with the keysSoFar substring, searching in
    // the specified range of items
    for (let n = startIndex; n < endIndex; n++) {
      const label = list[n].innerText;
      if (label && label.toLowerCase().indexOf(this.keysSoFar) === 0) {
        return list[n];
      }
    }
    return null;
  }

  checkClickItem(evt) {
    if (evt.target.getAttribute('role') !== 'option') {
      return;
    }

    this.focusItem(evt.target);
    this.toggleSelectItem(evt.target);
    this.updateScroll();

    if (this.multiselectable && evt.shiftKey) {
      this.selectRange(this.startRangeIndex, evt.target);
    }
  }

  checkMouseDown(evt) {
    if (
      this.multiselectable &&
      evt.shiftKey &&
      evt.target.getAttribute('role') === 'option'
    ) {
      evt.preventDefault();
    }
  }

  toggleSelectItem(element) {
    if (this.multiselectable) {
      element.setAttribute(
        'aria-selected',
        element.getAttribute('aria-selected') === 'true' ? 'false' : 'true'
      );

      this.updateMoveButton();
    }
  }

  defocusItem(element) {
    if (!element) {
      return;
    }
    if (!this.multiselectable) {
      element.removeAttribute('aria-selected');
    }
    element.classList.remove('focused');
  }

  focusItem(element) {
    this.defocusItem(document.getElementById(this.activeDescendant));
    if (!this.multiselectable) {
      element.setAttribute('aria-selected', 'true');
    }
    element.classList.add('focused');
    this.listboxNode.setAttribute('aria-activedescendant', element.id);
    this.activeDescendant = element.id;

    if (!this.multiselectable) {
      this.updateMoveButton();
    }

    this.checkUpDownButtons();
    this.handleFocusChange(element);
  }

  checkInRange(index, start, end) {
    const rangeStart = start < end ? start : end;
    const rangeEnd = start < end ? end : start;

    return index >= rangeStart && index <= rangeEnd;
  }

  selectRange(start, end) {
    // get start/end indices
    const allOptions = this.listboxNode.querySelectorAll('[role="option"]');
    const startIndex =
      typeof start === 'number'
        ? start
        : this.getElementIndex(start, allOptions);
    const endIndex =
      typeof end === 'number' ? end : this.getElementIndex(end, allOptions);

    for (let index = 0; index < allOptions.length; index++) {
      const selected = this.checkInRange(index, startIndex, endIndex);
      allOptions[index].setAttribute('aria-selected', selected + '');
    }

    this.updateMoveButton();
  }

  updateMoveButton() {
    if (!this.moveButton) {
      return;
    }

    if (this.listboxNode.querySelector('[aria-selected="true"]')) {
      this.moveButton.setAttribute('aria-disabled', 'false');
    } else {
      this.moveButton.setAttribute('aria-disabled', 'true');
    }
  }

  updateScroll() {
    const selectedOption = document.getElementById(this.activeDescendant);
    if (selectedOption) {
      const scrollBottom =
        this.listboxNode.clientHeight + this.listboxNode.scrollTop;
      const elementBottom =
        selectedOption.offsetTop + selectedOption.offsetHeight;
      if (elementBottom > scrollBottom) {
        this.listboxNode.scrollTop =
          elementBottom - this.listboxNode.clientHeight;
      } else if (selectedOption.offsetTop < this.listboxNode.scrollTop) {
        this.listboxNode.scrollTop = selectedOption.offsetTop;
      }
      selectedOption.scrollIntoView({ block: 'nearest', inline: 'nearest' });
    }
  }

  checkUpDownButtons() {
    const activeElement = document.getElementById(this.activeDescendant);

    if (!this.moveUpDownEnabled) {
      return;
    }

    if (!activeElement) {
      this.upButton.setAttribute('aria-disabled', 'true');
      this.downButton.setAttribute('aria-disabled', 'true');
      return;
    }

    if (this.upButton) {
      if (activeElement.previousElementSibling) {
        this.upButton.setAttribute('aria-disabled', false);
      } else {
        this.upButton.setAttribute('aria-disabled', 'true');
      }
    }

    if (this.downButton) {
      if (activeElement.nextElementSibling) {
        this.downButton.setAttribute('aria-disabled', false);
      } else {
        this.downButton.setAttribute('aria-disabled', 'true');
      }
    }
  }

  addItems(items) {
    if (!items || !items.length) {
      return;
    }

    items.forEach(
      function (item) {
        this.defocusItem(item);
        this.toggleSelectItem(item);
        this.listboxNode.append(item);
      }.bind(this)
    );

    if (!this.activeDescendant) {
      this.focusItem(items[0]);
    }

    this.handleItemChange('added', items);
  }

  deleteItems() {
    let itemsToDelete;

    if (this.multiselectable) {
      itemsToDelete = this.listboxNode.querySelectorAll(
        '[aria-selected="true"]'
      );
    } else if (this.activeDescendant) {
      itemsToDelete = [document.getElementById(this.activeDescendant)];
    }

    if (!itemsToDelete || !itemsToDelete.length) {
      return [];
    }

    itemsToDelete.forEach(
      function (item) {
        item.remove();

        if (item.id === this.activeDescendant) {
          this.clearActiveDescendant();
        }
      }.bind(this)
    );

    this.handleItemChange('removed', itemsToDelete);

    return itemsToDelete;
  }

  clearActiveDescendant() {
    this.activeDescendant = null;
    this.listboxNode.setAttribute('aria-activedescendant', null);

    this.updateMoveButton();
    this.checkUpDownButtons();
  }

  moveUpItems() {
    if (!this.activeDescendant) {
      return;
    }

    const currentItem = document.getElementById(this.activeDescendant);
    const previousItem = currentItem.previousElementSibling;

    if (previousItem) {
      this.listboxNode.insertBefore(currentItem, previousItem);
      this.handleItemChange('moved_up', [currentItem]);
    }

    this.checkUpDownButtons();
  }

  moveDownItems() {
    if (!this.activeDescendant) {
      return;
    }

    var currentItem = document.getElementById(this.activeDescendant);
    var nextItem = currentItem.nextElementSibling;

    if (nextItem) {
      this.listboxNode.insertBefore(nextItem, currentItem);
      this.handleItemChange('moved_down', [currentItem]);
    }

    this.checkUpDownButtons();
  }

  moveItems() {
    if (!this.siblingList) {
      return;
    }

    var itemsToMove = this.deleteItems();
    this.siblingList.addItems(itemsToMove);
  }

  enableMoveUpDown(upButton, downButton) {
    this.moveUpDownEnabled = true;
    this.upButton = upButton;
    this.downButton = downButton;
    upButton.addEventListener('click', this.moveUpItems.bind(this));
    downButton.addEventListener('click', this.moveDownItems.bind(this));
  }

  setupMove(button, siblingList) {
    this.siblingList = siblingList;
    this.moveButton = button;
    button.addEventListener('click', this.moveItems.bind(this));
  }

  setHandleItemChange(handlerFn) {
    this.handleItemChange = handlerFn;
  }

  setHandleFocusChange(focusChangeHandler) {
    this.handleFocusChange = focusChangeHandler;
  }
};

window.addEventListener('load', function () {
  new aria.Listbox(document.getElementById('elem_list'));
});
```

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

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

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

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

---

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

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

https://twitter.com/degudegu2510