アクセシブルなコンボボックスをReactで実装する
これはYAMAPアドベントカレンダー19日目の記事です。
はじめに
株式会社ヤマップでフロントエンドエンジニアをしているt-yngです。
業務でコンボボックスUIをReactで自前実装しました。実装においてa11yの考慮をすると色々と実装が膨らんだので、今回はa11yを考慮したコンボボックスUIについて書きます。
開発の背景
コンボボックスを実装するにあたり、最初はheadless UIやReact Ariaなどのライブラリで実装をしようと考えていました。
しかし、ヤマップではデザインシステムを構築を進めております。自分たちのプロダクトに合わせてコンポーネントの挙動を実装する際に外部ライブラリでは挙動が合わない部分が多く今後の拡張も考えた際に外部ライブラリに依存すると、メンテがしづらくなりそうとの判断でコンボボックスを自前実装する判断をしました。
コンボボックスの要件
- 入力のテキストは編集可能
- テキストに応じてサジェストの一覧を表示
コンボボックスのa11yの要件
a11yの要件についてはW3Cのコンボボックスのパターン実装のドキュメントを参考にしています。
他にもButtonやInputなど基本的なコンポーネントのa11yを考慮した実装要件がパターンとして紹介されているので、自前でコンポーネント実装するときに非常に参考になります。
HTML要素
- input要素にはcomboboxロールを付与する
- input要素にaria-controlsを設定してポップアップの要素を紐付ける
- ポップアップにlisbox以外のロールを付与する場合はaria-haspopupを付与する- 
comboboxロールは暗黙的にaria-haspopup="listbox"が設定されるのでlistboxの場合は指定は不要
 
- 
- ポップアップが非表示の時はinput要素にaria-expanded="false"を設定する
- ポップアップが表示されている時はinput要素にaria-expanded="true"を設定する
- コンボボックスがフォーカスを受ける時はinput要素にフォーカスする
- ポップアップ内のオプション項目をフォーカスした場合にaria-activedescendantでフォーカスしている要素を紐付ける
- ポップアップのオプション項目が選択されていることを視覚的に示す場合は、aria-selected="true"をオプション項目に付与する
- label要素やaria-label等でコンボボックスをラベル付けする
- 自動補完の挙動に応じてinput要素にaria-autocompleteを設定する- none: 入力テキストに関係なく同じ一覧をポップアップで表示
- list: 入力テキストに対応した一覧をポップアップで表示
- both: 入力テキストに対応した一覧をポップアップで表示して、オプション項目を選択している時にインラインで未入力部分を補完する。
 
キーボード操作
コンボボックスにフォーカスがある場合
- 下矢印キーを押した時にポップアップの最初の選択肢にフォーカスする
- 既に選択肢にフォーカスがある場合は次の選択肢にフォーカスする
- 一番下の選択肢にフォーカスがある場合は最初の選択肢をフォーカスする
 
- 上矢印キーを押した時にポップアップの最後の選択肢にフォーカスする
- 既に選択肢にフォーカスがある場合は前の選択肢にフォーカスする
- 一番上の選択肢にフォーカスがある場合は最後の選択肢をフォーカスする
 
- Escapeキーを押した時にポップアップを非表示にする。コンボボックスが編集可能な場合は入力したテキストを元に戻す。
- Enterキーを押した時にフォーカスしている選択肢を値として選択する
- 編集可能な場合はテキストの削除や左右矢印キーなどでカーソルの移動ができる
ポップアップにフォーカスがある場合
- 下矢印キーを押した時に次の選択肢にフォーカスする
- 一番下の選択肢にフォーカスがある場合は最初の選択肢をフォーカスする
 
- 上矢印キーを押した時に前の選択肢にフォーカスする
- 一番上の選択肢にフォーカスがある場合は最後の選択肢をフォーカスする
 
- 左矢印キーを押した時にコンボボックスが編集可能であればカーソル位置を左に移動する
- 右矢印キーを押した時にコンボボックスが編集可能であればカーソル位置を右に移動する
- Enterキーを押した時にフォーカスしている選択肢を値として選択して、ポップアップを閉じる
- バックスペースキーを押した時にコンボボックスが編集可能であればカーソルより前の文字を1文字削除する
- Escapeキーを押した時にポップアップを非表示にして、フォーカスをコンボボックスに戻す
HTMLの実装
まず最初はHTMLのa11y要件をベースにテキストが入力されて、ポップアップのオプション項目にフォーカスされている状態で静的にHTMLとCSSを組みます。
いきなり挙動を含めて実装を始めると、考えることが多くなるので、遠回りでも先にHTMLとCSSだけで実装をしてからロジックを追加していく手法はオススメです。
ここでは次のa11y要件を満たすHTMLを仮で実装します
- input要素にはcomboboxロールを付与する
- input要素にaria-controlsを設定してポップアップの要素を紐付ける
- ポップアップが表示されている時はinput要素にaria-expanded="true"を設定する
- ポップアップ内のオプション項目をフォーカスした場合にaria-activedescendantでフォーカスしている要素を紐付ける
- ポップアップのオプション項目が選択されていることを視覚的に示す場合は、aria-selected="true"をオプション項目に付与する
- コンボボックスをラベル付けする
- リストボックスをラベル付けする
- 自動補完の挙動としてinput要素にaria-autocomplete="list"を設定する
// Combobox.tsx
import { ComponentPropsWithoutRef, FC, useId, useState } from "react";
import "./style.css";
export const Combobox = () => {
  const [inputValue, setInputValue] = useState("a");
  const listboxId = useId();
  const labelId = useId();
  const inputId = useId();  
  return (
    <div className="combobox">    
      <label id={labelId} htmlFor={inputId}>
        ユーザー
      </label>    
      <Input
	id={inputId}
        value={inputValue}
	aria-labelledby={labelId}
        aria-controls={listboxId}
        aria-expanded={true}
        aria-autocomplete="list"
        aria-activedescendant="lb-op-alex"
      />
      <Listbox
        id={listboxId} 
	aria-label="候補"
	aria-labelledby={`${listboxId} ${labelId}`}
      />
    </div>
  );
};
type InputProps = ComponentPropsWithoutRef<"input">;
const Input: FC<InputProps> = (props) => {
  return <input type="text" className="input" role="combobox" {...props} />;
};
type ListboxProps = {
  id: string;
  "aria-label"?: string;
  "aria-labelledby"?: string;  
};
const Listbox: FC<ListboxProps> = ({ 
  id,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
}) => {
  return (
    <ul 
      id={id}
      className="listbox"
      role="listbox"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledBy}    
    >
      <li id="lb-op-alex" className="option" role="option" aria-selected="true">
        Alex
      </li>
      <li id="lb-op-alison" className="option" role="option">
        Alison
      </li>
    </ul>
  );
};
// style.css
.combobox {
  position: relative;
  width: 200px;
}
.input {
  width: 100%;
}
.listbox {
  position: absolute;
  margin-top: 4px;
  border: 1px solid black;
  width: 100%;
}
.listbox .option {
  padding: 4px 8px;
}
.listbox .option[aria-selected="true"] {
  background-color:  lightskyblue;
}

テキストに応じてリスボックスの内容を更新
テキスト入力に対応してリストボックスの一覧を更新する挙動を実装します。
この時点ではまだリストボックスは表示されたままになっています。
useComboboxStateのカスタムhookを作成して、今後のキーボード操作など状態管理のロジックはここに集約していくようにします。
// useComboboxState.ts
import { useEffect, useState } from "react";
type Props = {
  allOptions: string[];
};
export const useComboboxState = ({ allOptions }: Props) => {
  const [inputValue, setInputValue] = useState<string>("");
  const [options, setOptions] = useState<string[]>(allOptions);
  useEffect(() => {
    if (value === "") {
      setOptions(allOptions);
    } else {
      setOptions(
        allOptions.filter((option) => option.toLowerCase().startsWith(value))
      );
    }
  }, [value, allOptions]);
  return {
    inputValue,
    setInputValue,
    options,
  };
};
// Combobox.tsx
import { useComboboxState } from "./useComboboxState";
const users = ["Alex", "Alison", "Bobby", "Brenda", "Cindy", "Cathy"];
export const Combobox = () => {
  const labelId = useId();
  const inputId = useId();
  const listboxId = useId();
  const { inputValue, setInputValue, options } = useComboboxState({ allOptions: users });
  return (
    <div className="combobox">
      <label id={labelId} htmlFor={inputId}>
        ユーザー
      </label>
      <Input
        ...
        onChange={(e) => {
          setInputValue(e.target.value);
        }}
      />
      <Listbox 
        ...
	options={options} 
      />
    </div>
  );
};
// Listbox.tsx
import { FC } from "react";
type ListboxProps = {
  ...,
  options: string[];
};
export const Listbox: FC<ListboxProps> = ({
  id,
  options,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
}) => {
  return (
    <ul 
      id={id}
      className="listbox"
      role="listbox"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledBy}
    >
      {options.map((option) => (
        <li id={`lb-op-${option}`} className="option" role="option">
          {option}
        </li>
      ))}
    </ul>
  );
};
リストボックスの表示/非表示を制御
次の条件でリストボックスの表示/非表示を制御する実装をしていきます。
- input要素をフォーカスした時にリストボックスを表示
- input要素からフォーカスが外れた時にリストボックスを非表示
- 
Escapeキーを押した時にリストボックスを非表示
- テキストが変更された時にリストボックスを表示
リストボックスの表示状態をvisibleListboxで状態を管理して、リストボックスの表示状態に応じてaria-expandedの値を更新します。
export const useComboboxState = ({ allOptions }: Props) => {
  const [inputValue, setInputValue] = useState<string>("");
  const [options, setOptions] = useState<string[]>(allOptions);
  const [visibleListbox, setVisibleListbox] = useState<boolean>(false);
  const onFocusInput = useCallback<FocusEventHandler<HTMLInputElement>>(() => {
    setVisibleListbox(true);
  }, []);
  const onBlurInput = useCallback<FocusEventHandler<HTMLInputElement>>(() => {
    setVisibleListbox(false);
  }, []);
  
  /**
   * input要素のキーボードイベントに応じて状態を管理
   */
  const onKeyDownInput = useCallback<KeyboardEventHandler<HTMLInputElement>>(
    (event) => {
      if(event.key === "Escape") {
	setVisibleListbox(false);
      }
    } 
  }, []);
  useEffect(() => {
    if (value === "") {
      setOptions(allOptions);
    } else {
      setOptions(
        allOptions.filter((option) => option.toLowerCase().startsWith(value))
      );
    }
    setVisibleListbox(true);
  }, [value, allOptions]);
  return {
    inputValue,
    setInputValue,
    options,
    visibleListbox,
    onFocusInput,
    onBlurInput,
    onKeyDownInput,
  };
};
export const Combobox = () => {
  const labelId = useId();
  const inputId = useId();
  const listboxId = useId();
  const {
    inputValue,
    setInputValue,
    options,
    visibleListbox,
    onFocusInput,
    onBlurInput,
  } = useComboboxState({ allOptions: users });
  return (
    <div className="combobox">
      <Input
        ...
        onFocus={onFocusInput}
        onBlur={onBlurInput}
        aria-expanded={visibleListbox}
      />
      <Listbox ... visible={visibleListbox} />
    </div>
  );
};
type ListboxProps = {
  ...
  visible: boolean;
};
export const Listbox: FC<ListboxProps> = ({ id, options, visible }) => {
  if (!visible || options.length === 0) {
    return null;
  }
  
  // (省略)
};
リストボックスのフォーカス制御
次の要件に従ってリストボックスのオプション項目のフォーカス制御と選択を実装していきます。
- マウスをホバーしてフォーカスを移動
- 上下の矢印でフォーカスを移動
- ポップアップ内のオプション項目をフォーカスした場合にaria-activedescendantでフォーカスしている要素を紐付ける
- ポップアップのオプション項目が選択されていることを視覚的に示す場合は、aria-selected="true"をオプション項目に付与する
- オプション項目をクリックしたら選択を確定
- Enterを押したら選択を確定
キーボードによるフォーカス移動の実装
フォーカスしているオプションのインデックスをfocusedOptionIndexで管理をして、上下の矢印キーの入力に応じて、インデックスを更新します。
また、テキストの変更やフォーカスが外れたタイミングでフォーカスされているインデックスを初期化していきます。
オプション項目にインデックス番号を管理するdata-indexを追加して、マウスをホバーした時にホバーしたオプション項目のインデックスを取得して、focusedOptionIndexを更新します。
aria-activedescendantでフォーカスしているオプション項目のidを紐付けるために、リスボックスのオプションのidなどをまとめたListboxOptionをデータ構造として新しく定義をしてオプションの一覧をuseComboboxStateで管理するように修正をしています。
// useComboboxState.ts
export type ListboxOption = {
  id: string;
  label: string;
};
export const useComboboxState = ({ allOptions }: Props) => {
  const optionId = useId();
  const allListboxOptions = useMemo(
    () =>
      allOptions.map((option) => ({
        id: `${optionId}-${option}`,
        label: option,
      })),
    [allOptions, optionId]
  );
  const [inputValue, setInputValue] = useState<string>("");
  const [options, setOptions] = useState<ListboxOption[]>(allListboxOptions);
  const [visibleListbox, setVisibleListbox] = useState<boolean>(false);
  const [focusedOptionIndex, setFocusedOptionIndex] = useState<number | null>(
    null
  );
  const [focusedOption, setFocusedOption] = useState<ListboxOption | null>(
    null
  );
  const onFocusInput = useCallback<FocusEventHandler<HTMLInputElement>>(() => {
    setVisibleListbox(true);
  }, []);
  const onBlurInput = useCallback<FocusEventHandler<HTMLInputElement>>(() => {
    setVisibleListbox(false);
    setFocusedOptionIndex(null);
  }, []);
  /**
   * input要素のキーボードイベントに応じて状態を管理
   */
  const onKeyDownInput = useCallback<KeyboardEventHandler<HTMLInputElement>>(
    (event) => {
      if(event.key === "Escape") {
	setVisibleListbox(false);
	setFocuesOptionIndex(null);
      } else if (event.key === "ArrowUp") {
        // ↑キーが押されたらフォーカスを前に移動
        setFocusedOptionIndex((prev) => {
          if (prev == null || prev === 0) {
            return options.length - 1;
          }
          return prev - 1;
        });
      } else if (event.key === "ArrowDown") {
        // ↓キーが押されたらフォーカスを次に移動
        setFocusedOptionIndex((prev) => {
          if (prev == null || prev === options.length - 1) {
            return 0;
          }
          return prev + 1;
        });
      }
    },
    [options]
  );
  /**
   * オプション項目のホバーイベントに応じてオプション項目のフォーカスを更新
   */
  const onMouseOverOption = useCallback<MouseEventHandler<HTMLLIElement>>(
    (event) => {
      setFocusedOptionIndex(Number(event.currentTarget.dataset.index));
    },
    []
  );
  /**
   * フォーカス中のオプション項目のインデックスが更新に併せてフォーカス中のオプション項目を更新
   */
  useEffect(() => {
    if (focusedOptionIndex == null) {
      setFocusedOption(null);
    } else {
      setFocusedOption(options[focusedOptionIndex]);
    }
  }, [focusedOptionIndex, options]);
  useEffect(() => {
    if (value === "") {
      setOptions(allListboxOptions);
    } else {
      setOptions(
        allListboxOptions.filter((option) =>
          option.label.toLowerCase().startsWith(value)
        )
      );
    }
    setVisibleListbox(true);
    setFocusedOptionIndex(null);
  }, [value, allListboxOptions]);
  return {
    inputValue,
    setInputValue,
    options,
    focusedOptionIndex,
    focusedOption,
    visibleListbox,
    onFocusInput,
    onBlurInput,
    onKeyDownInput,
    onMouseOverOption,
  };
};
export const Combobox = () => {
  const labelId = useId();
  const inputId = useId();
  const listboxId = useId();
  const {
    ...,
    focusedOptionIndex,
    focusedOption,
    onMouseOverOption,
  } = useComboboxState({ allOptions: users });
  return (
    <div className="combobox">
      <Input
	...
        onKeyDown={onKeyDownInput}
        aria-activedescendant={focusedOption ? focusedOption.id : ""}
      />
      <Listbox
        ...
        focusedOptionIndex={focusedOptionIndex}
        onMouseOverOption={onMouseOverOption}
      />
    </div>
  );
};
type ListboxProps = {
  ...,
  focusedOptionIndex: number | null;
  onMouseOverOption: MouseEventHandler<HTMLLIElement>;
};
export const Listbox: FC<ListboxProps> = ({
  ...,
  focusedOptionIndex,
  onMouseOverOption,
}) => {
  return (
    <ul
      id={id}
      className="listbox"
      role="listbox"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledBy}
    >
      {options.map((option, index) => (
        <li
          id={option.id}
          className="option"
          role="option"
          aria-selected={index === focusedOptionIndex}
          onMouseOver={onMouseOverOption}
          data-index={index}
        >
          {option.label}
        </li>
      ))}
    </ul>
  );
};
オプション項目の選択
Enterが押された時 or オプション項目がクリックされた時にフォーカス中のオプション項目の値でコンボボックスのvalueを更新します。
ここで、inputValueとvalueを区別しているのは、Escapeキーを押された時に入力内容をリセットできるようにするためです。
またオプション項目のクリックイベントはclickではなくmousedownイベントに対してトリガーを設定します。clickイベントをトリガーにしてしまうと先にinput要素のフォーカスが外れてリストボックスが非表示になりクリックイベントが処理できないためです。同じ理由で、オプション項目をクリック時にinput要素からフォーカスを外さないために、event.preventDefault()でイベントの伝播を止める対応をしています。
export const useComboboxState = ({ allOptions }: Props) => {
  const [value, setValue] = useState<string | null>(null);
  /**
   * オプション項目を選択する
   */
  const selectFocusedOption = useCallback((option: ListboxOption) => {
    setValue(option.label);
    setInputValue(option.label);
    setFocusedOptionIndex(null);
    setVisibleListbox(false);
  }, []);
  /**
   * input要素のキーボードイベントに応じて状態を管理
   */
  const onKeyDownInput = useCallback<KeyboardEventHandler<HTMLInputElement>>(
    (event) => {
      // (省略)
      } else if (event.key === "Enter") {
        if (focusedOption != null) {
          selectOption(focusedOption);
        }
      }
    },
    [options.length, selectFocusedOption]
  );
  /**
   * オプション項目をクリックしたらオプション項目を選択
   * NOTE: clickイベントだと先にinput要素のフォーカスが外れてリストボックスが非表示になり、イベントが処理できない
   */
  const onMouseDownOption = useCallback<MouseEventHandler<HTMLLIElement>>(
    (event) => {
      // モバイルでの操作も考慮してイベントから選択されたオプション項目を取得
      event.preventDefault();
      const focusedOption = options[Number(event.currentTarget.dataset.index)];
      selectOption(focusedOption);
    },
    [selectFocusedOption]
  );
  return {
    ...,
    onMouseDownOption,
  };
};
export const Combobox = () => {
  const listboxId = useId();
  const {
    ...,
    onMouseDownOption,
  } = useComboboxState({ allOptions: users });
  return (
    <div className="combobox">
      // (省略)
      <Listbox
        ...
        onMouseDownOption={onMouseDownOption}
      />
    </div>
  );
};
type ListboxProps = {
  ...;
  onMouseDownOption: MouseEventHandler<HTMLLIElement>;
};
export const Listbox: FC<ListboxProps> = ({
  ...,
  onMouseDownOption,
}) => {
  return (
    <ul 
      id={id}
      className="listbox"
      role="listbox"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledBy}    
    >
      {options.map((option, index) => (
        <li
          // (省略)
          onMouseDown={onMouseDownOption}
        >
          {option.label}
        </li>
      ))}
    </ul>
  );
};
さいごに
キーボード操作などa11yの考慮を含めると、気にする事が増えて思ったよりも実装が大変な印象でした。自分でコンボボックスを実装する機会があまり無かったですが、他の外部モジュールの実装やHTML構造を参考する機会になり勉強になりました。
技術選定を考える際に開発のスピード感や安定性を考えるのであれば、使えるなら外部ライブラリを積極的に採用した方が良いなと思いました。




Discussion