LoginSignup
4
3

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

Last updated at Posted at 2023-12-07

はじめに

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

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

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

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

アクセシビリティを意識したコンボボックスの仕様

⚪︎ コンボボックスとは?

コンボボックスは、ポップアップが関連づいた入力要素です。
コンボボックスでは、特定の値しか受け入れない場合と、入力する値をユーザーへ提案する場合に役立ちます。

ポップアップは、リストボックスグリットツリーダイアログ 要素で入力をサポートします。
ポップアップによって表示される入力可能な値の性質とその表示方法は、オートコンプリートと呼び、コンボボックスでは、次の4つの形式のオートコンプリートのいずれかを指定できます。

  • オートコンプリートなし
    • Textfieldに入力した内容に関係なく、ポップアップで表示する内容が変わらない
  • 手動選択によるリストのオートコンプリート
    • Textfieldに入力した内容を考慮して、ポップアップで表示する内容を変える
  • 自動選択によるリストのオートコンプリート
    • Textfieldに入力した内容を補完するか、考慮して、ポップアップで表示する内容を変える
    • ポップアップの内容を選択しないでTextfieldのフォーカスを外すと、フォーカス前と値を変えない
  • インラインオートコンプリートを備えたリスト
    • Textfieldに入力した内容を補完するか、考慮して、ポップアップで表示する内容を変える
    • ポップアップの内容を選択しないでTextfieldのフォーカスを外すと、フォーカス前と値を変えない
    • 選択した候補のうちユーザーが入力してない部分をTextfield内のカーソルの後にインラインで表示する。

また、コンボボックスは以下の6つの種類があります。

  • セレクトのみのコンボボックス
  • リストとオートコンプリートを両方備えた編集可能なコンボボックス
  • リストオートコンプリートを備えた編集可能なコンボボックス
  • オートコンプリートなしの編集可能なコンボボックス
  • グリッドポップアップ付きの編集可能なコンボボックス
  • 日付ピッカー コンボボックス

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

コンボボックスのキーボードインタラクション

  • ↓キー
    • ポップアップが表示されている場合、フォーカスをポップアップに移動する
      • ↓キー が押される前にオートコンプリートで提案された選択肢が自動的に選択された場合、自動的に選択された選択肢に続く選択肢にフォーカスが移動する
  • ↑キー(任意)
    • ポップアップが表示されている場合、フォーカスをポップアップの最後のポップアップ可能な要素に移動する
  • Escapeキー
    • ポップアップが表示されている場合、ポップアップを非表示にします
    • ポップアップが非表示の場合、コンボボックスをクリアします(任意)
  • Enterキー
    • コンボボックスが編集可能で、オートコンプリートの選択肢がポップアップで選択した場合、入力カーソルをコンボボックスの文末におくか、選択肢を受け入れる
  • Altキー + ↓キー(任意)
    • ポップアップが利用可能で、表示されていない場合、フォーカスを移動させずにポップアップを表示する
  • Altキー + ↑キー(任意)
    • ポップアップが表示されている場合
      • ポップアップにフォーカスがある場合、コンボボックスにフォーカスを移動する
      • ポップアップを閉じる

リストボックスポップアップのキーボードインタラクション

  • Enterキー
    • ポップアップを非表示に、選択した値をコンボボックスに表示する
    • コンボボックスが編集可能ならカーソルをコンボボックスの文末におくことで、選択したリストボックスの値を受け入れる
  • Escapeキー
    • ポップアップを非表示にし、コンボボックスにフォーカスを戻す
    • コンボボックスが編集可能な場合、コンボボックスをクリアする
  • ↓キー
    • 次の選択肢に移動する。
    • 最後の選択肢だった場合、コンボボックスにフォーカスをするか何もしない。
  • ↑キー
    • 前の選択肢に移動する。
    • 最初の選択肢だった場合、コンボボックスにフォーカスをするか何もしない。
  • →キー
    • コンボボックスが編集可能な場合、ポップアップを表示したまま、コンボボックスにフォーカスを戻す
      • その後、入力カーソルを右に移動する
      • 入力カーソルが右端だった場合、カーソルは移動しない
  • ←キー
    • コンボボックスが編集可能な場合、ポップアップを表示したまま、コンボボックスにフォーカスを戻す
      • その後、入力カーソルを左に移動する
      • 入力カーソルが左端だった場合、カーソルは移動しない
  • Homeキー(任意)
    • コンボボックスが編集可能な場合、コンボボックスにフォーカスを戻し、最初の文字にカーソルを移動する
  • Endキー(任意)
    • コンボボックスが編集可能な場合、コンボボックスにフォーカスを戻し、最後の文字にカーソルを移動する
  • Backspaceキー(任意)
    • コンボボックスが編集可能な場合、コンボボックスにフォーカスを戻し、カーソルの前の文字を削除する
  • Deleteキー(任意)
    • コンボボックスが編集可能な場合、コンボボックスにフォーカスを戻す
      • オートコンプリートの選択肢が選択されている場合、選択を解除する
      • サジェストされた選択肢が選択されている場合、選択を解除する

グリッドポップアップのキーボードインタラクション

  • Enterキー
    • ポップアップを非表示に、選択した値をコンボボックスに表示する
    • コンボボックスが編集可能ならカーソルをコンボボックスの文末におくことで、選択したリストボックスの値を受け入れる
  • Escapeキー
    • ポップアップを非表示にし、コンボボックスにフォーカスを戻す
    • コンボボックスが編集可能な場合、コンボボックスをクリアする
  • →キー
    • フォーカスを右のセルに移動する。
    • フォーカスが行の一番右のセルにある場合、フォーカスは次の行の最初のセルに移動する。
    • フォーカスがグリッドの最後のセルにある場合は、何もしないか、フォーカスをコンボボックスに戻す
  • ←キー
    • フォーカスを左のセルに移動する。
    • フォーカスが行の一番左のセルにある場合、フォーカスは前の行の最後のセルに移動する
    • フォーカスがグリッドの最最初のセルにある場合は、何もしないか、フォーカスをコンボボックスに戻す
  • ↓キー
    • フォーカスを 1つ下のセルに移動する。
    • フォーカスがグリッドの最後の行にある場合、何もしないか、コンボボックスにフォーカスを戻す
  • ↑キー
    • フォーカスを 1つ上のセルに移動する。
    • フォーカスがグリッドの最初の行にある場合、何もしないか、コンボボックスにフォーカスを戻す
  • Page Downキー(任意)
    • 現在表示されている行の一番下の行を、表示される行の1つ上になるようにスクロールする
    • フォーカスがグリッドの最後の行にある場合、フォーカスは移動しない
  • Page UPキー(任意)
    • 現在表示されている行の一番上の行を、表示される行の1つ下になるようにスクロールする
    • フォーカスがグリッドの最初の行にある場合、フォーカスは移動しない
  • Homeキー(任意)
    • 以下のどちらか
      • フォーカスを含む行の最初のセルにフォーカスを移動する
        • グリッドが1行につき3セル未満、または1行につき複数の推奨値を持つ場合は、フォーカスはグリッドの最初のセルに移動する
      • コンボボックスが編集可能な場合は、コンボボックスにフォーカスを戻し、最初の文字にカーソルを置く
  • Endキー(任意)
    • 以下のどちらか
      • フォーカスを含む行の最後のセルにフォーカスを移動する
        • グリッドが1行につき3セル未満、または1行につき複数の推奨値を持つ場合は、フォーカスはグリッドの最後のセルに移動する
      • コンボボックスが編集可能な場合は、コンボボックスにフォーカスを戻し、最後の文字にカーソルを置く
  • Controlキー + Homeキー(任意)
    • 最初の行にフォーカスを移す
  • Controlキー + Endキー(任意)
    • 最後の行にフォーカスを移す
  • Backspaceキー(任意)
    • コンボボックスが編集可能な場合、コンボボックスにフォーカスを戻し、カーソルの前の文字を削除する
  • Deleteキー(任意)
    • コンボボックスが編集可能な場合、コンボボックスにフォーカスを戻す
      • オートコンプリートの選択肢が選択されている場合、選択を解除する
      • サジェストされた選択肢が選択されている場合、選択を解除する

ツリーポップアップのキーボードインタラクション

  • Enterキー
    • ポップアップを非表示に、選択した値をコンボボックスに表示する
    • コンボボックスが編集可能ならカーソルをコンボボックスの文末におくことで、選択したリストボックスの値を受け入れる
  • Escapeキー
    • ポップアップを非表示にし、コンボボックスにフォーカスを戻す
    • コンボボックスが編集可能な場合、コンボボックスをクリアする
  • →キー
    • フォーカスが閉じたノードにある場合、ノードを開き、フォーカスと選択範囲は移動しない
    • フォーカスが開いたノードにある場合、フォーカスを最初の子ノードに移動し、選択可能であれば選択する
    • 終端ノードにフォーカスがある場合、何もしない
  • ←キー
    • フォーカスが開いたノードにある場合、ノードを閉る
    • 終端ノード or 閉じたノードの子ノードにフォーカスがある場合、親ノードにフォーカスを移動し、選択可能であれば選択する
    • 端ノード or 閉じたノードのルートノードにフォーカスが当たっている場合、何もしない
  • ↓キー
    • ノードを開いたり、閉じたりすることなく、フォーカス可能な次のノードにフォーカスを移動し、選択可能であればそのノードを選択する
  • ↑キー
    • ノードを開いたり、閉じたりすることなく、フォーカス可能な前のノードにフォーカスを移動し、選択可能であればそのノードを選択する
  • Homeキー(任意)
    • ノードを開いたり閉じたりすることなく、ツリー内の最初のフォーカス可能なノードにフォーカスを移動し、そのノードが選択可能であれば選択する
  • Endキー(任意)
    • ノードを開いたり閉じたりすることなく、ツリー内の最後のフォーカス可能なノードにフォーカスを移動し、そのノードが選択可能であれば選択する。

ダイアログポップアップのキーボード操作

  • ダイアログポップアップにフォーカスがある時、以下の2つの方法で、ポップアップを閉じ、コンボボックスにフォーカスを戻す
    • コンボボックスの値を指定するボタンをアクティブにする(ダイアログないでアクションを実行する)
    • ダイアログをキャンセルする
  • ダイアログは、モーダルダイアログパターンで定義されたキーボード操作を実装する

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

  • 入力として機能し、コンボボックスの値を表示する要素に role="combobox" を設定する
  • コンボボックス要素で、ポップアップとして機能する要素のIDを aria-controls に設定する
    • aria-controls は、ポップアップが表示している時に設定される
  • ポップアップ要素は、role="listbox"role="tree"role="grid"role="dialog" を設定する
  • ポップアップ要素が role="listbox" 以外のroleの場合、コンボボックス要素で、ポップアップとして機能する要素のIDを aria-haspopup に設定する
  • ポップアップ要素が表示されていない場合、コンボボックス要素に aria-expanded="false" を設定する(デフォルト)
  • ポップアップ要素が表示されている場合、コンボボックス要素に aria-expanded="true" を設定する
  • コンボボックスにフォーカスをしたら、DOMフォーカスはコンボボックス要素に移動する
  • role="listbox"role="tree"role="grid" の子孫がフォーカスされると、DOMフォーカスはコンボボックスに残り、コンボボックス要素でポップアップとして機能する要素のIDを aria-activedescendant に設定する
  • コンボボックスに可視ラベルが必要で、Label要素を使ってラベルをつけられる場合、Label要素を用いる
    • そうでない場合は、ラベル要素のIDを aria-labelledby に設定する
    • 可視ラベルがない場合は、コンボボックス要素に aria-label でラベルを設定する
  • オートコンプリートが動作する場合、コンボボックス要素に aria-autocomplete を設定する
    • aria-autocomplete="none"
      • ポップアップが表示されるとき、コンボボックスに入力された文字に関係なく、含まれる値の候補は同じ
    • aria-autocomplete="list"
      • ポップアップが表示されるとき、提案された値が表示される
      • コンボボックスが編集可能な場合、ポップアップの値は、入力された文字に完全 or 論理的に対応する
    • aria-autocomplete="both"
      • ポップアップが表示されるとき、提案された値が表示される
      • 補完部分が、コンボボックスの入力カーソルの後にインラインで表示される
      • インライン補完文字列は視覚的に強調表示され、選択状態になる

アクセシビリティを意識したコンボボックスの完成形

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

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

1. HTMLを実装する

sample.html
<section class="combobox-container">
  <label for="cb1-input"></label>
  <div class="combobox combobox-list">
    <div class="group">
      <input id="cb1-input" class="cb_edit" type="text" role="combobox" aria-autocomplete="both" aria-expanded="false" aria-controls="cb1-listbox">
      <button type="button" id="cb1-button" aria-label="States" aria-expanded="false" aria-controls="cb1-listbox" tabindex="-1" class="material-symbols-outlined">expand_more</button>
    </div>
    <ul id="cb1-listbox" role="listbox" aria-label="States">
      <li id="lb1-al" role="option">Alabama</li>
      <li id="lb1-ak" role="option">Alaska</li>
      <li id="lb1-as" role="option">American Samoa</li>
      <li id="lb1-az" role="option">Arizona</li>
      <li id="lb1-ar" role="option">Arkansas</li>
      <li id="lb1-ca" role="option">California</li>
      <li id="lb1-co" role="option">Colorado</li>
      <li id="lb1-ct" role="option">Connecticut</li>
      <li id="lb1-de" role="option">Delaware</li>
      <li id="lb1-dc" role="option">District of Columbia</li>
      <li id="lb1-fl" role="option">Florida</li>
      <li id="lb1-ga" role="option">Georgia</li>
      <li id="lb1-gm" role="option">Guam</li>
      <li id="lb1-hi" role="option">Hawaii</li>
      <li id="lb1-id" role="option">Idaho</li>
      <li id="lb1-il" role="option">Illinois</li>
      <li id="lb1-in" role="option">Indiana</li>
      <li id="lb1-ia" role="option">Iowa</li>
      <li id="lb1-ks" role="option">Kansas</li>
      <li id="lb1-ky" role="option">Kentucky</li>
      <li id="lb1-la" role="option">Louisiana</li>
      <li id="lb1-me" role="option">Maine</li>
      <li id="lb1-md" role="option">Maryland</li>
      <li id="lb1-ma" role="option">Massachusetts</li>
      <li id="lb1-mi" role="option">Michigan</li>
      <li id="lb1-mn" role="option">Minnesota</li>
      <li id="lb1-ms" role="option">Mississippi</li>
      <li id="lb1-mo" role="option">Missouri</li>
      <li id="lb1-mt" role="option">Montana</li>
      <li id="lb1-ne" role="option">Nebraska</li>
      <li id="lb1-nv" role="option">Nevada</li>
      <li id="lb1-nh" role="option">New Hampshire</li>
      <li id="lb1-nj" role="option">New Jersey</li>
      <li id="lb1-nm" role="option">New Mexico</li>
      <li id="lb1-ny" role="option">New York</li>
      <li id="lb1-nc" role="option">North Carolina</li>
      <li id="lb1-nd" role="option">North Dakota</li>
      <li id="lb1-mp" role="option">Northern Marianas Islands</li>
      <li id="lb1-oh" role="option">Ohio</li>
      <li id="lb1-ok" role="option">Oklahoma</li>
      <li id="lb1-or" role="option">Oregon</li>
      <li id="lb1-pa" role="option">Pennsylvania</li>
      <li id="lb1-pr" role="option">Puerto Rico</li>
      <li id="lb1-ri" role="option">Rhode Island</li>
      <li id="lb1-sc" role="option">South Carolina</li>
      <li id="lb1-sd" role="option">South Dakota</li>
      <li id="lb1-tn" role="option">Tennessee</li>
      <li id="lb1-tx" role="option">Texas</li>
      <li id="lb1-ut" role="option">Utah</li>
      <li id="lb1-ve" role="option">Vermont</li>
      <li id="lb1-va" role="option">Virginia</li>
      <li id="lb1-vi" role="option">Virgin Islands</li>
      <li id="lb1-wa" role="option">Washington</li>
      <li id="lb1-wv" role="option">West Virginia</li>
      <li id="lb1-wi" role="option">Wisconsin</li>
      <li id="lb1-wy" role="option">Wyoming</li>
    </ul>
  </div>
</section>

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

.combobox-container {
  background-color: rgb(128 128 128 / .3);
  border-radius: 24px;
  display: grid;
  padding: 16px;
  position: relative;
  min-width: 150px;
  background-blend-mode: luminosity;
  backdrop-filter: blur(15px);
  &::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;
  }
}

label {
  font-size: 14px;
}

.combobox-list {
  position: relative;
  margin-top: 4px;
}

.combobox .group {
  display: inline-flex;
  cursor: pointer;
  border-radius: 8px;
  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;
  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;
}

.combobox input,
.combobox button {
  background-color: transparent;
  border: none;
  color: #FFFFFF;
  margin: 0;
  vertical-align: bottom;
  position: relative;
  cursor: pointer;
}

.combobox input {
  flex-grow: 1;
  font-size: 16px;
  padding: 8px 12px;
}

.combobox button {
  justify-content: center;
  align-items: center;
  display: flex;
  width: 32px;
  color: #FFFFFF;
  
  &[aria-expanded="true"] {
    transform: rotate(180deg);
  }
}


ul[role="listbox"] {
  margin: 0;
  padding: 8px 0;
  position: absolute;
  left: 0;
  top: 42px;
  list-style: none;
  background-color: rgb(128 128 128 / .3);
  background-blend-mode: luminosity;
  backdrop-filter: blur(15px);
  border-radius: 8px;
  display: none;
  max-height: 100px;
  overflow: auto;
  width: 100%;
  &::-webkit-scrollbar{
    display: none;
  }
}

ul[role="listbox"] li[role="option"] {
  cursor: pointer;
  margin: 0;
  display: block;
  padding: 4px 12px;
}

[role="listbox"].focus [role="option"][aria-selected="true"], [role="listbox"] [role="option"]:hover {
  background-color: rgb(128 128 128 / .5);
}

3. JavaScriptを実装する

sample.js
class ComboboxAutocomplete {
  constructor(comboboxNode, buttonNode, listboxNode) {
    this.comboboxNode = comboboxNode;
    this.buttonNode = buttonNode;
    this.listboxNode = listboxNode;

    this.comboboxHasVisualFocus = false;
    this.listboxHasVisualFocus = false;

    this.hasHover = false;

    this.isNone = false;
    this.isList = false;
    this.isBoth = false;

    this.allOptions = [];

    this.option = null;
    this.firstOption = null;
    this.lastOption = null;

    this.filteredOptions = [];
    this.filter = '';

    var autocomplete = this.comboboxNode.getAttribute('aria-autocomplete');

    if (typeof autocomplete === 'string') {
      autocomplete = autocomplete.toLowerCase();
      this.isNone = autocomplete === 'none';
      this.isList = autocomplete === 'list';
      this.isBoth = autocomplete === 'both';
    } else {
      // default value of autocomplete
      this.isNone = true;
    }

    this.comboboxNode.addEventListener(
      'keydown',
      this.onComboboxKeyDown.bind(this)
    );
    this.comboboxNode.addEventListener(
      'keyup',
      this.onComboboxKeyUp.bind(this)
    );
    this.comboboxNode.addEventListener(
      'click',
      this.onComboboxClick.bind(this)
    );
    this.comboboxNode.addEventListener(
      'focus',
      this.onComboboxFocus.bind(this)
    );
    this.comboboxNode.addEventListener('blur', this.onComboboxBlur.bind(this));

    document.body.addEventListener(
      'pointerup',
      this.onBackgroundPointerUp.bind(this),
      true
    );

    this.listboxNode.addEventListener(
      'pointerover',
      this.onListboxPointerover.bind(this)
    );
    this.listboxNode.addEventListener(
      'pointerout',
      this.onListboxPointerout.bind(this)
    );

    var nodes = this.listboxNode.getElementsByTagName('LI');

    for (var i = 0; i < nodes.length; i++) {
      var node = nodes[i];
      this.allOptions.push(node);

      node.addEventListener('click', this.onOptionClick.bind(this));
      node.addEventListener('pointerover', this.onOptionPointerover.bind(this));
      node.addEventListener('pointerout', this.onOptionPointerout.bind(this));
    }

    this.filterOptions();

    var button = this.comboboxNode.nextElementSibling;

    if (button && button.tagName === 'BUTTON') {
      button.addEventListener('click', this.onButtonClick.bind(this));
    }
  }

  getLowercaseContent(node) {
    return node.textContent.toLowerCase();
  }

  isOptionInView(option) {
    var bounding = option.getBoundingClientRect();
    return (
      bounding.top >= 0 &&
      bounding.left >= 0 &&
      bounding.bottom <=
        (window.innerHeight || document.documentElement.clientHeight) &&
      bounding.right <=
        (window.innerWidth || document.documentElement.clientWidth)
    );
  }

  setActiveDescendant(option) {
    if (option && this.listboxHasVisualFocus) {
      this.comboboxNode.setAttribute('aria-activedescendant', option.id);
      if (!this.isOptionInView(option)) {
        option.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      }
    } else {
      this.comboboxNode.setAttribute('aria-activedescendant', '');
    }
  }

  setValue(value) {
    this.filter = value;
    this.comboboxNode.value = this.filter;
    this.comboboxNode.setSelectionRange(this.filter.length, this.filter.length);
    this.filterOptions();
  }

  setOption(option, flag) {
    if (typeof flag !== 'boolean') {
      flag = false;
    }

    if (option) {
      this.option = option;
      this.setCurrentOptionStyle(this.option);
      this.setActiveDescendant(this.option);

      if (this.isBoth) {
        this.comboboxNode.value = this.option.textContent;
        if (flag) {
          this.comboboxNode.setSelectionRange(
            this.option.textContent.length,
            this.option.textContent.length
          );
        } else {
          this.comboboxNode.setSelectionRange(
            this.filter.length,
            this.option.textContent.length
          );
        }
      }
    }
  }

  setVisualFocusCombobox() {
    this.listboxNode.classList.remove('focus');
    this.comboboxNode.parentNode.classList.add('focus'); // set the focus class to the parent for easier styling
    this.comboboxHasVisualFocus = true;
    this.listboxHasVisualFocus = false;
    this.setActiveDescendant(false);
  }

  setVisualFocusListbox() {
    this.comboboxNode.parentNode.classList.remove('focus');
    this.comboboxHasVisualFocus = false;
    this.listboxHasVisualFocus = true;
    this.listboxNode.classList.add('focus');
    this.setActiveDescendant(this.option);
  }

  removeVisualFocusAll() {
    this.comboboxNode.parentNode.classList.remove('focus');
    this.comboboxHasVisualFocus = false;
    this.listboxHasVisualFocus = false;
    this.listboxNode.classList.remove('focus');
    this.option = null;
    this.setActiveDescendant(false);
  }

  // ComboboxAutocomplete Events

  filterOptions() {
    // do not filter any options if autocomplete is none
    if (this.isNone) {
      this.filter = '';
    }

    var option = null;
    var currentOption = this.option;
    var filter = this.filter.toLowerCase();

    this.filteredOptions = [];
    this.listboxNode.innerHTML = '';

    for (var i = 0; i < this.allOptions.length; i++) {
      option = this.allOptions[i];
      if (
        filter.length === 0 ||
        this.getLowercaseContent(option).indexOf(filter) === 0
      ) {
        this.filteredOptions.push(option);
        this.listboxNode.appendChild(option);
      }
    }

    // Use populated options array to initialize firstOption and lastOption.
    var numItems = this.filteredOptions.length;
    if (numItems > 0) {
      this.firstOption = this.filteredOptions[0];
      this.lastOption = this.filteredOptions[numItems - 1];

      if (currentOption && this.filteredOptions.indexOf(currentOption) >= 0) {
        option = currentOption;
      } else {
        option = this.firstOption;
      }
    } else {
      this.firstOption = null;
      option = null;
      this.lastOption = null;
    }

    return option;
  }

  setCurrentOptionStyle(option) {
    for (var i = 0; i < this.filteredOptions.length; i++) {
      var opt = this.filteredOptions[i];
      if (opt === option) {
        opt.setAttribute('aria-selected', 'true');
        if (
          this.listboxNode.scrollTop + this.listboxNode.offsetHeight <
          opt.offsetTop + opt.offsetHeight
        ) {
          this.listboxNode.scrollTop =
            opt.offsetTop + opt.offsetHeight - this.listboxNode.offsetHeight;
        } else if (this.listboxNode.scrollTop > opt.offsetTop + 2) {
          this.listboxNode.scrollTop = opt.offsetTop;
        }
      } else {
        opt.removeAttribute('aria-selected');
      }
    }
  }

  getPreviousOption(currentOption) {
    if (currentOption !== this.firstOption) {
      var index = this.filteredOptions.indexOf(currentOption);
      return this.filteredOptions[index - 1];
    }
    return this.lastOption;
  }

  getNextOption(currentOption) {
    if (currentOption !== this.lastOption) {
      var index = this.filteredOptions.indexOf(currentOption);
      return this.filteredOptions[index + 1];
    }
    return this.firstOption;
  }

  doesOptionHaveFocus() {
    return this.comboboxNode.getAttribute('aria-activedescendant') !== '';
  }

  isOpen() {
    return this.listboxNode.style.display === 'block';
  }

  isClosed() {
    return this.listboxNode.style.display !== 'block';
  }

  hasOptions() {
    return this.filteredOptions.length;
  }

  open() {
    this.listboxNode.style.display = 'block';
    this.comboboxNode.setAttribute('aria-expanded', 'true');
    this.buttonNode.setAttribute('aria-expanded', 'true');
  }

  close(force) {
    if (typeof force !== 'boolean') {
      force = false;
    }

    if (
      force ||
      (!this.comboboxHasVisualFocus &&
        !this.listboxHasVisualFocus &&
        !this.hasHover)
    ) {
      this.setCurrentOptionStyle(false);
      this.listboxNode.style.display = 'none';
      this.comboboxNode.setAttribute('aria-expanded', 'false');
      this.buttonNode.setAttribute('aria-expanded', 'false');
      this.setActiveDescendant(false);
      this.comboboxNode.parentNode.classList.add('focus');
    }
  }

  onComboboxKeyDown(event) {
    var flag = false,
      altKey = event.altKey;

    if (event.ctrlKey || event.shiftKey) {
      return;
    }

    switch (event.key) {
      case 'Enter':
        if (this.listboxHasVisualFocus) {
          this.setValue(this.option.textContent);
        }
        this.close(true);
        this.setVisualFocusCombobox();
        flag = true;
        break;

      case 'Down':
      case 'ArrowDown':
        if (this.filteredOptions.length > 0) {
          if (altKey) {
            this.open();
          } else {
            this.open();
            if (
              this.listboxHasVisualFocus ||
              (this.isBoth && this.filteredOptions.length > 1)
            ) {
              this.setOption(this.getNextOption(this.option), true);
              this.setVisualFocusListbox();
            } else {
              this.setOption(this.firstOption, true);
              this.setVisualFocusListbox();
            }
          }
        }
        flag = true;
        break;

      case 'Up':
      case 'ArrowUp':
        if (this.hasOptions()) {
          if (this.listboxHasVisualFocus) {
            this.setOption(this.getPreviousOption(this.option), true);
          } else {
            this.open();
            if (!altKey) {
              this.setOption(this.lastOption, true);
              this.setVisualFocusListbox();
            }
          }
        }
        flag = true;
        break;

      case 'Esc':
      case 'Escape':
        if (this.isOpen()) {
          this.close(true);
          this.filter = this.comboboxNode.value;
          this.filterOptions();
          this.setVisualFocusCombobox();
        } else {
          this.setValue('');
          this.comboboxNode.value = '';
        }
        this.option = null;
        flag = true;
        break;

      case 'Tab':
        this.close(true);
        if (this.listboxHasVisualFocus) {
          if (this.option) {
            this.setValue(this.option.textContent);
          }
        }
        break;

      case 'Home':
        this.comboboxNode.setSelectionRange(0, 0);
        flag = true;
        break;

      case 'End':
        var length = this.comboboxNode.value.length;
        this.comboboxNode.setSelectionRange(length, length);
        flag = true;
        break;

      default:
        break;
    }

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

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

  onComboboxKeyUp(event) {
    var flag = false,
      option = null,
      char = event.key;

    if (this.isPrintableCharacter(char)) {
      this.filter += char;
    }

    if (this.comboboxNode.value.length < this.filter.length) {
      this.filter = this.comboboxNode.value;
      this.option = null;
      this.filterOptions();
    }

    if (event.key === 'Escape' || event.key === 'Esc') {
      return;
    }

    switch (event.key) {
      case 'Backspace':
        this.setVisualFocusCombobox();
        this.setCurrentOptionStyle(false);
        this.filter = this.comboboxNode.value;
        this.option = null;
        this.filterOptions();
        flag = true;
        break;

      case 'Left':
      case 'ArrowLeft':
      case 'Right':
      case 'ArrowRight':
      case 'Home':
      case 'End':
        if (this.isBoth) {
          this.filter = this.comboboxNode.value;
        } else {
          this.option = null;
          this.setCurrentOptionStyle(false);
        }
        this.setVisualFocusCombobox();
        flag = true;
        break;

      default:
        if (this.isPrintableCharacter(char)) {
          this.setVisualFocusCombobox();
          this.setCurrentOptionStyle(false);
          flag = true;

          if (this.isList || this.isBoth) {
            option = this.filterOptions();
            if (option) {
              if (this.isClosed() && this.comboboxNode.value.length) {
                this.open();
              }

              if (
                this.getLowercaseContent(option).indexOf(
                  this.comboboxNode.value.toLowerCase()
                ) === 0
              ) {
                this.option = option;
                if (this.isBoth || this.listboxHasVisualFocus) {
                  this.setCurrentOptionStyle(option);
                  if (this.isBoth) {
                    this.setOption(option);
                  }
                }
              } else {
                this.option = null;
                this.setCurrentOptionStyle(false);
              }
            } else {
              this.close();
              this.option = null;
              this.setActiveDescendant(false);
            }
          } else if (this.comboboxNode.value.length) {
            this.open();
          }
        }

        break;
    }

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

  onComboboxClick() {
    if (this.isOpen()) {
      this.close(true);
    } else {
      this.open();
    }
  }

  onComboboxFocus() {
    this.filter = this.comboboxNode.value;
    this.filterOptions();
    this.setVisualFocusCombobox();
    this.option = null;
    this.setCurrentOptionStyle(null);
  }

  onComboboxBlur() {
    this.removeVisualFocusAll();
  }

  onBackgroundPointerUp(event) {
    if (
      !this.comboboxNode.contains(event.target) &&
      !this.listboxNode.contains(event.target) &&
      !this.buttonNode.contains(event.target)
    ) {
      this.comboboxHasVisualFocus = false;
      this.setCurrentOptionStyle(null);
      this.removeVisualFocusAll();
      setTimeout(this.close.bind(this, true), 300);
    }
  }

  onButtonClick() {
    if (this.isOpen()) {
      this.close(true);
    } else {
      this.open();
    }
    this.comboboxNode.focus();
    this.setVisualFocusCombobox();
  }

  onListboxPointerover() {
    this.hasHover = true;
  }

  onListboxPointerout() {
    this.hasHover = false;
    setTimeout(this.close.bind(this, false), 300);
  }

  onOptionClick(event) {
    this.comboboxNode.value = event.target.textContent;
    this.close(true);
  }

  onOptionPointerover() {
    this.hasHover = true;
    this.open();
  }

  onOptionPointerout() {
    this.hasHover = false;
    setTimeout(this.close.bind(this, false), 300);
  }
}

window.addEventListener('load', function () {
  var comboboxes = document.querySelectorAll('.combobox-list');

  for (var i = 0; i < comboboxes.length; i++) {
    var combobox = comboboxes[i];
    var comboboxNode = combobox.querySelector('input');
    var buttonNode = combobox.querySelector('button');
    var listboxNode = combobox.querySelector('[role="listbox"]');
    new ComboboxAutocomplete(comboboxNode, buttonNode, listboxNode);
  }
});

まとめ

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

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

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


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

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

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