React

【React】アクセシビリティに考慮したリストボックスを実装する

リストボックスにアクセシビリティ上求められる要件を確認した後に、React で実際に要件に従った実装をおこないます。

リストボックス は 1 つまたは複数の静的な項目をユーザーが選択できるリストです。役割は <select> 要素と同等ですが、<select> は CSS による装飾が困難であるため、独自にリストボックスを作成することが多いでしょう。

アクセシブルなリストボックスの要件

リストボックスをアクセシブルにするためには以下の実装を行う必要があります。

ロール・ステート・プロパティ

  • オプションを持つ要素に listbox ロールを付与する
  • リストボックスの子要素には 1 つ以上のoption ロールが必要
  • listbox ロールを付与した要素には aria-labelledby または aria-label 属性を付与してアクセシブルな名前をつける(画面上で可視化されるため、aria-labelledby がより良い)
  • もしリストボックスが複数選択可能であるならば、listbox ロールを持った要素に対して aria-bultiselectable="true" を設定する(デフォルトでは false
  • option ロールを付与した要素の中で、現在アクティブな要素には aria-selected="true" を付与する。アクティブでない要素には aria-selected="false" を付与する(もしくは aria-checked
  • ドロップダウンメニューを表示させる要素(コンボボックス)には以下を付与する
  • もしオプションが水平に表示される場合、listbox ロールを付与した要素にaria-orientation 属性に horizontal を設定する(デフォルトは vertical 垂直)

キーボード操作

単一選択リストボックスの場合
  • フォーカスを受け取ったとき
    • どのオプションも選択されていない場合、最初のオプションがフォーカスを受け取る。任意で最初のオプションを自動選択することもできる
    • フォーカスを受け取る前にオプションが選択されていた場合、フォーカスは選択されているオプションに設定される
  • :次のオプションにフォーカスを移動する
  • :前のオプションにフォーカスを移動する
  • Space:フォーカスされたオプションの選択状態を変更する
  • Esc:リストボックスが開いている場合閉じ、コンボボックスにフォーカスを戻す
  • Tab:リスナーをが開いている場合とき、通常の Tab キーが押されたときの動作(次の要素のフォーカスを移す)を実行します。

複数選択リストボックスの場合

  • フォーカスを受け取ったとき
    • どのオプションも選択されていない場合、最初のオプションがフォーカスを受け取る
    • フォーカスを受け取る前に 1 つ以上のオプションが選択されていた場合、リストの中で最初に選択されているオプションがフォーカスを受け取る
  • Shift + ↓:現在の選択状態を変更しつつ、次のオプションにフォーカスを移動する
  • Shift + ↑:現在の選択状態を変更しつつ、前のオプションにフォーカスを移動する
  • Control + ↓:現在の選択状態を変更せずに次のオプションにフォーカスを移動する
  • Control + ↑:現在の選択状態を変更せずに前のオプションにフォーカスを移動する
  • Control + Space:フォーカスされたオプションの選択状態を変更する

ポップアップが閉じているとき

  • ,,Space,Enter:ポップアップを表示する。フォーカスはコンボボックスに残したまま。

実装

それでは、前述した要件を満たしたリストボックスの実装を考えてみます。

ベースとなるリストボックス

以下のアクセシビリティに関する機能が実装されていないリストボックスコンポーネントをベースとして考えてみます。

import React, { RefObject, useEffect, useRef, useState } from "react";

const useClickOutside = (
  ref: RefObject<HTMLElement>,
  handler: (event: MouseEvent) => void
) => {
  const listener = (event: MouseEvent) => {
    if (!ref.current || ref.current.contains(event.target as Node)) {
      return;
    }
    handler(event);
  };

  useEffect(() => {
    document.addEventListener("mousedown", listener);

    return () => {
      document.removeEventListener("mousedown", listener);
    };
  }, [ref, handler]);
};

type ListBoxProps = {
  label: string;
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
};

export const ListBox: React.FC<ListBoxProps> = ({
  label,
  options,
  value,
  onChange,
}) => {
  const [open, setOpen] = useState(false);
  const selectedOption = options.find((option) => option.value === value);

  const handleSelect = (value: string) => {
    onChange(value);
    setOpen(false);
  };
  const toggleOpen = () => setOpen((open) => !open);
  const close = () => setOpen(false);

  const wrapperRef = useRef<HTMLDivElement>(null);
  useClickOutside(wrapperRef, close);

  return (
    <div ref={wrapperRef}>
      <h3>{label}</h3>
      <button onClick={toggleOpen}>
        {selectedOption ? selectedOption.label : "選択してください"}
      </button>
      {open && (
        <ul>
          {options.map((option) => (
            <li key={option.value}>
              <button onClick={() => handleSelect(option.value)}>
                {option.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

ロール・ステート・プロパティの付与

まずはそれぞれの要素に適切なロールなどを付与します。ステートやプロパティは通常 JavaScript を用いて適宜更新する必要があるのですが、React の宣言的なスタイルのおかげでさほど難しいものではありません。

まずはリストボックスを表示させる要素に付与します。

const labelId = useId();
const listboxId = useId();

return (
    <h3 id={labelId}>{label}</h3>
    <button
      onClick={toggleOpen}
      role="combobox"
      aria-haspopup="listbox"
      aria-controls={listboxId}
      aria-labelledby={labelId}
      aria-expanded={open}
      aria-activedescendant={/** TODO 現在アクティブなオプションの ID */}
    >
      {selectedOption ? selectedOption.label : "選択してください"}
    </button>

combobox はユーザーが複数の項目から値を選択できるように関連するポップアップを備えている入力ウィジェットです。aria-haspopup="listbox" を指定することでこのコンボボックスはリストボックスをポップアップすることを伝えます。aria-controls でこのコンボボックスと関連するリストボックスを id で指定します。アクセシビリティ対応のため要素同士を関連付けるには id 属性が多く用いられますが、Reacrt18 で追加された useId フックを使うのが適しています。このフックで生成された ID を使用すれば、ページ内で一意であることが担保されます。

同様に aria-labbeledby を使用してコンボボックスとラベルを紐付けています。aria-expanded はコンボボックスがコントロールする要素(aria-controls で指定した要素)が現在表示されているかどうかを指定します。aria-activeddescendant はリストボックスのオプションの中で現在アクティブな要素を id で指定します。

続いてリストボックス要素です。

<ul
  role="listbox"
  id={listboxId}
  aria-labelledby={labelId}
  tabIndex={-1}
>

リストボックスであることをユーザーに示すために listbox ロールを付与しています。コンボボックスとの関連付けのために id を指定する必要があるのは前述のとおりです。コンボボックスと同様に aria-labelledby でラベルを紐付けします。さらに、tabIndex={-1} を指定して JavaScript からフォーカスが可能であるように指定します。

さらにもう 1 つ、リストボックスの表示制御についてやるべきことがあります。ベースとなるリストボックスでは下記のように jsx の制御構文を用いて表示・非表示を切り替えていました。

{open && (
  <ul
    role="listbox"
    id={listboxId}
    aria-labelledby={labelId}
    tabIndex={-1}
  >

jsx の条件分岐を用いて表示を切り替えた場合、その要素は DOM ツリーから削除されるのですが、これは要素の関連付けを行っている場合には好ましくありません。なぜなら、コンボボックスにおいて aria-controls で指定した要素が存在しないことになってしまうためです。

これを修正するためには、jsx の制御構文ではなく CSS のスタイルで表示・非表示を切り替えるようにします。

button:not([aria-expanded=true]) + ul {
  width: 0;
  height: 0;
  overflow: hidden;
}

最後にオプション要素です。

<li
  key={option.value}
  role="option"
  aria-selected={option.value === value}
  id={`${listboxId}-${option.value}`}
>

option ロールを付与してオプション要素であることを伝えます。現在選択されている要素かどうか、aria-selected で表現をしています。またコンボボックスの aria_activedescendant 属性から指定するために id を付与する必要があります。

キーボード操作

次に要件に従ったキーボード操作を実装しましょう。リストボックスの操作を行う useListBox フックを作成します。

type UseListBoxProps = {
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
  comboBoxRef: RefObject<HTMLElement>;
};

const useListBox = ({
  onChange,
  value,
  options,
  comboBoxRef,
}: UseListBoxProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const selectedOption = options.find((option) => option.value === value);

  const open = () => {
    setIsOpen(true);
    setActiveIndex(selectedOption ? options.indexOf(selectedOption) : 0);
  };
  const close = () => {
    setIsOpen(false);
    setActiveIndex(-1);
  };

  const handleSelect = (value: string) => {
    onChange(value);
    close();
  };

  useEffect(() => {
    let listener: (event: KeyboardEvent) => void = () => {};
    if (isOpen) {
      listener = openedKeydownHandler({
        close,
        activeIndex,
        setActiveIndex,
        options,
        handleSelect,
      });
    } else if (document.activeElement === comboBoxRef.current) {
      listener = closedKeydownHandler({
        open,
      });
    }
    document.addEventListener("keydown", listener);

    return () => {
      document.removeEventListener("keydown", listener);
    };
  }, [isOpen, activeIndex, options, handleSelect, open, close, comboBoxRef]);

  return {
    isOpen,
    open,
    selectedOption,
    close,
    handleSelect,
    activeIndex,
  };
};

isOpen はポップアップが表示されているかどうかの状態です。activeIndex では現在アクティブなオプション(カーソル操作で選択された)のインデックスを保持します。

open 関数でポップアップが表示されたとき、どのオプションがアクティブとなるかを決定します。キーボード操作の要件より、現在なにかしらのオプションが選択されている場合にはそのオプションのインデックスをアクティブとします。オプションが選択されていない場合、最初のオプション(=インデックスは 0)をアクティブとします。close 関数でポップアップを戻す際にはアクティブなオプションのインデックスを -1 に設定しておきます。

useEffect 内では keydown イベントを購読してユーザーのキーボード操作を受け付けます。ポップアップが開いているときと閉じているときでそれぞれ別のイベントをハンドリングする必要があるため、openedKeydownHandlerclosedKeydownHandler でそれぞれイベントのリスナーを取得しています。またポップアップが閉じている場合のイベントはコンボボックスにアクティブがある場合のみ受け付ける必要があるので、isFocused でコンボボックスにフォーカスがあるかどうかを判定します。(フォーカスの状態の操作は後ほどコンポーネントで実装します)

まずは簡単な closedKeydownHandler から見てきましょう。,,Space,Enter いずれかのキーが押された場合、ポップアップを表示します。

const closedKeydownHandler = ({ open }: any) => {
  const listener = (event: KeyboardEvent) => {
    switch (event.key) {
      case "Up":
      case "ArrowUp":
      case "Down":
      case "ArrowDown":
      case " ":
      case "Enter":
        event.preventDefault();
        open();
    }
  };
  return listener;
};

続いて openedKeydownHandler です。Escape または TabP キーが押された場合はポップアップを閉じます。 はアクティブなオプションを1つ前に戻します。先頭のオプションがアクティブな場合には、最後のオプションをアクティブにします。 はアクティブなオプションを1つ後にします。最後のオプションがアクティブな場合、先頭のオプションをアクティブにします。EnterまたはScape` キーが押された場合は現在アクティブなオプションを選択します。

const openedKeydownHandler = ({
  close,
  activeIndex,
  setActiveIndex,
  options,
  handleSelect,
}: {
  close: () => void;
  activeIndex: number;
  setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
  options: { value: string; label: string }[];
  handleSelect: (value: string) => void;
}) => {
  const listener = (event: KeyboardEvent) => {
    switch (event.key) {
      case "Escape":
      case "Tab":
        close();
        break;
      case "ArrowDown":
        setActiveIndex((i) => (i + 1 >= options.length ? 0 : i + 1));
        break;
      case "ArrowUp":
        setActiveIndex((i) => (i - 1 < 0 ? options.length - 1 : i - 1));
        break;
      case "Enter":
      case " ":
        event.preventDefault();
        handleSelect(options[activeIndex].value);
        break;
    }
  };
  return listener;
};

それでは作成した useListBox フックを使用してコンポーネントを修正しましょう。

const { open, close, isOpen, handleSelect, activeIndex, selectedOption } =
  useListBox({
    options,
    value,
    onChange,
  });

コンボボックスの aria-activedescendant は現在アクティブなインデックスが -1 の場合は undefined を、それ以外の場合は現在アクティブなオプションの value を利用してオプションの id を指定します。またコンボボックスがフォーカスしたときのフォーカスが離れたときの処理も追加します。

<button
  aria-activedescendant={
    activeIndex !== -1
      ? `${listboxId}-${options[activeIndex].value}`
      : undefined
  }
  onFocus={() => setIsFocused(true)}
  onBlur={() => setIsFocused(false)}
>

現在アクティブなオプションが視覚的に表示されるように、アクティブなインデックスが一致する場合には active クラスを付与するようにします。

{options.map((option, i) => (
  <li
    key={option.value}
    role="option"
    aria-selected={option.value === value}
    id={`${listboxId}-${option.value}`}
    onClick={() => handleSelect(option.value)}
    className={i === activeIndex ? "active" : ""}
  >
.active {
  border: 2px solid navy;
} 

コードの全体像は以下のようになります。実際に触ってみて正しく動作するか確かめてみてください。

参考


Contributors

> GitHub で修正を提案する
この記事をシェアする
Twitterで共有
Hatena

関連記事