【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)
- ドロップダウンメニューを表示させる要素(コンボボックス)には以下を付与する
- combobox ロール
- aria-controls:listbox ロールの id
- aria-expanded:現在リストボックスが開いているなら true、そうでないなら false
- aria-haspopup="listbox"
- 現在アクティブな option ロールがある場合 aria-activedescendant 属性でアクティブな要素を
id
で指定する
- もしオプションが水平に表示される場合、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
イベントを購読してユーザーのキーボード操作を受け付けます。ポップアップが開いているときと閉じているときでそれぞれ別のイベントをハンドリングする必要があるため、openedKeydownHandler
と closedKeydownHandler
でそれぞれイベントのリスナーを取得しています。またポップアップが閉じている場合のイベントはコンボボックスにアクティブがある場合のみ受け付ける必要があるので、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;
}
コードの全体像は以下のようになります。実際に触ってみて正しく動作するか確かめてみてください。