Reactで、入力候補を出すコンボボックスをキーボードで操作する方法

​​React​IME,Playwright,TypeScript,WAI-ARIA,アクセシビリティ

入力した文字で候補を絞り込むコンボボックスは、フォーカスを入力欄(input)に置いたまま、↑↓キーで「選んでいる候補」の番号だけを動かすと、キーボードで操作できるようになります。どの候補を選んでいるかは aria-activedescendant で支援技術に伝え、Enter で決め、Esc で閉じます。

日本語で使う部品では、変換中の ↓ や Enter をコンボボックスが横取りしないことも欠かせません。W3C の WAI-ARIA Authoring Practices Guide(APG)にある例と同じキー操作を React で作り、ブラウザで1つずつ確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。日本語の変換は実際の IME ではなく、Chrome DevTools Protocol の命令で変換中の状態を作って再現しています。スクリーンリーダーでの読み上げ、Firefox と Safari、スマートフォンのソフトウェアキーボードでは試していません。

どのキーで何をするか

APG のコンボボックスのパターンは、キー操作の決まりと、実装によって選べる動きを示しています。この記事では、そのうち一覧で候補を出す例(List Autocomplete)のキー操作に合わせました。ただし、候補を選んでいないときの Enter は、APG の例のように一覧を閉じるのではなく、何もせずにフォームの送信に任せています。

キー 動き
↓ 一覧を開き、次の候補を選ぶ。最後の候補の次は最初に戻る
↑ 一覧を開き、前の候補を選ぶ。最初の候補の前は最後に戻る
Alt + ↓ 候補を選ばずに、一覧を開くだけ
Enter 選んでいる候補を入力欄に入れて、一覧を閉じる
Esc 一覧が開いていれば閉じる。閉じていれば入力欄を空にする
←・→・Home・End 入力欄のカーソルを動かす(候補の選択は外す)

APG の例では、最後から最初へ戻る動きについて、Home と End を入力欄のカーソル移動に使う場合に役立つと説明しています。

コンポーネントの全体

候補の配列とラベルを受け取ります。入力した文字を含む候補だけを一覧に出します。

tsx
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';

type Props = {
  label: string;
  options: string[];
};

// 入力した文字を含む候補を出すコンボボックス。フォーカスは常に input に置いたまま、
// 選んでいる候補を aria-activedescendant で伝える
export function Combobox({ label, options }: Props) {
  const id = useId();
  const listId = `${id}-list`;
  const optionId = (index: number) => `${id}-option-${index}`;

  const [value, setValue] = useState('');
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const listRef = useRef<HTMLUListElement>(null);

  const filtered = options.filter((option) => option.includes(value));
  const expanded = open && filtered.length > 0;

  // 選んでいる候補が一覧の見えている範囲から外れたら、一覧をスクロールする
  useEffect(() => {
    if (activeIndex < 0) return;
    listRef.current?.children[activeIndex]?.scrollIntoView({ block: 'nearest' });
  }, [activeIndex]);

  function choose(index: number) {
    setValue(filtered[index]);
    setOpen(false);
    setActiveIndex(-1);
  }

  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    // 日本語の変換中のキー操作は、IME に任せる
    if (event.nativeEvent.isComposing) return;
    const last = filtered.length - 1;

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        if (last < 0) return;
        setOpen(true);
        if (event.altKey) return; // Alt + ↓ は一覧を開くだけ
        setActiveIndex((i) => (!expanded || i >= last ? 0 : i + 1));
        return;
      case 'ArrowUp':
        event.preventDefault();
        if (last < 0) return;
        setOpen(true);
        setActiveIndex((i) => (!expanded || i <= 0 ? last : i - 1));
        return;
      case 'Enter':
        if (expanded && activeIndex >= 0) {
          event.preventDefault(); // form の中でも、候補を決めるだけで送信しない
          choose(activeIndex);
        }
        return;
      case 'Escape':
        if (expanded) {
          setOpen(false);
          setActiveIndex(-1);
        } else {
          setValue('');
        }
        return;
      case 'ArrowLeft':
      case 'ArrowRight':
      case 'Home':
      case 'End':
        // カーソル移動はブラウザに任せ、候補の選択だけ外す
        setActiveIndex(-1);
        return;
    }
  }

  return (
    <div className="combobox">
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        type="text"
        role="combobox"
        aria-expanded={expanded}
        aria-controls={listId}
        aria-autocomplete="list"
        aria-activedescendant={expanded && activeIndex >= 0 ? optionId(activeIndex) : undefined}
        autoComplete="off"
        value={value}
        onChange={(event) => {
          setValue(event.target.value);
          setOpen(true);
          setActiveIndex(-1);
        }}
        onKeyDown={onKeyDown}
        onBlur={() => setOpen(false)}
      />
      <ul id={listId} role="listbox" aria-label={label} ref={listRef} hidden={!expanded}>
        {filtered.map((option, index) => (
          <li
            key={option}
            id={optionId(index)}
            role="option"
            aria-selected={index === activeIndex}
            // 押した時点で input からフォーカスが外れないようにする
            onMouseDown={(event) => event.preventDefault()}
            onClick={() => choose(index)}
          >
            {option}
          </li>
        ))}
      </ul>
    </div>
  );
}

一覧と各候補の id は、useId の値に -list や -option-0 を足して作りました。useId のリファレンスにある、関係する複数の要素に id を付ける使い方と同じ形です。同じ部品をページに2つ置いても、id はぶつかりません。ラベルと入力欄を結びつける useId の基本は、以前の記事「ReactのuseIdで、フォームのラベルと入力欄を結びつける方法」で書きました。

検証では、47都道府県の名前を候補として渡し、form の中に置きました。

フォーカスは入力欄に置いたまま、候補は番号で指す

APG のパターンでは、一覧の候補を選んでいるあいだも DOM のフォーカスは入力欄に残し、入力欄の aria-activedescendant に、選んでいる候補の id を入れるとしています。MDN の aria-activedescendant のページによると、ブラウザは、この属性が指す要素にフォーカスがあるかのように支援技術へ伝えます。フォーカスが入力欄から動かないので、↓で候補を選んだあとも、そのまま文字を打ち足せます。検証でも、「山」と入力して ↓ で山形県を選んだあとに「口」を入力すると、入力欄は「山口」になり、候補は山口県だけに絞り込まれ、選択は外れました。

「山」と入力してから ↓ を7回、↑ を2回押しました。

text
入力後: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input
候補: 山形県,富山県,山梨県,和歌山県,岡山県,山口県
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-0(山形県) / aria-selected=true: 山形県 / フォーカス: input
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-1(富山県) / aria-selected=true: 富山県 / フォーカス: input
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-2(山梨県) / aria-selected=true: 山梨県 / フォーカス: input
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-3(和歌山県) / aria-selected=true: 和歌山県 / フォーカス: input
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-4(岡山県) / aria-selected=true: 岡山県 / フォーカス: input
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-5(山口県) / aria-selected=true: 山口県 / フォーカス: input
↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-0(山形県) / aria-selected=true: 山形県 / フォーカス: input
↑: 値「山」 / expanded=true / activedescendant=_r_0_-option-5(山口県) / aria-selected=true: 山口県 / フォーカス: input
↑: 値「山」 / expanded=true / activedescendant=_r_0_-option-4(岡山県) / aria-selected=true: 岡山県 / フォーカス: input

文字を入力した時点では、一覧は開いても候補はまだ選ばれていません。↓のたびに aria-activedescendant と aria-selected が同じ候補へ動き、山口県の次は山形県に戻りました。フォーカスはずっと入力欄のままです。検証環境の React 19.3.1 では、useId の値は _r_0_ でした。

最後の状態で、Playwright の ariaSnapshot を使ってアクセシビリティツリーを取り出すと、入力欄は展開した combobox、一覧は listbox になり、岡山県が選ばれた状態になっていました。

text
- text: 都道府県
- combobox "都道府県" [expanded]: 山
- listbox "都道府県":
  - option "山形県"
  - option "富山県"
  - option "山梨県"
  - option "和歌山県"
  - option "岡山県" [selected]
  - option "山口県"

一覧を閉じているあいだは、aria-activedescendant を付けません。aria-expanded も false に戻します。APG のパターンでは、一覧が見えていないときの aria-expanded は false とされています。

Enter は、候補を選んでいるときだけ止める

前の状態(岡山県を選んでいる)で Enter を押し、続けてもう一度 Enter を押しました。

text
Enter: 値「岡山県」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: input
カーソルの位置: 3(値の長さ 3) / 送信した回数:0
もう一度 Enter: 値「岡山県」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: input / 送信した回数:1

1回目の Enter では、入力欄に岡山県が入って一覧が閉じ、フォームは送信されませんでした。カーソルは値の最後にあります。APG のパターンでも、Enter で決めた値の最後にカーソルを置くとしています。

event.preventDefault() を呼ぶのは、候補を選んでいるときだけです。一覧を閉じたあとの Enter は止めないので、ふつうの入力欄と同じようにフォームが送信されました。いつも止めてしまうと、Enter でフォームを送れなくなります。

Esc は、閉じるのと空にするのを分ける

「山」と入力して ↓ を押したあと、Esc を2回押しました。

text
「山」と ↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-0(山形県) / aria-selected=true: 山形県 / フォーカス: input
Esc 1回目: 値「山」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: input
Esc 2回目: 値「」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: input

1回目は一覧を閉じるだけで、入力した「山」は残ります。閉じた状態で押した2回目で、入力欄が空になりました。APG のパターンでは、閉じた状態の Esc で入力欄を空にするのは「任意」の動きです。入力を消されたくない部品なら、else の側を消してください。

Alt + ↓ と、閉じた状態からの ↑

空の入力欄で Alt + ↓、続けて ↓ を押しました。そのあと Esc で閉じてから ↑ を押しました。

text
Alt+↓: 値「」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input
↓: 値「」 / expanded=true / activedescendant=_r_0_-option-0(北海道) / aria-selected=true: 北海道 / フォーカス: input
Esc で閉じてから ↑: 値「」 / expanded=true / activedescendant=_r_0_-option-46(沖縄県) / aria-selected=true: 沖縄県 / フォーカス: input

Alt + ↓ では、一覧が開くだけで候補は選ばれません。どんな候補があるかを先に見たいときの操作です。閉じた状態から ↑ を押すと、一覧が開いて最後の候補(沖縄県)が選ばれました。

←・→・Home・End は、入力欄のカーソル移動に残す

APG のパターンは、入力欄でふだん使う編集のキーを JavaScript で横取りしないよう注意しています。この記事のコンポーネントでは、←・→・Home・End で preventDefault() を呼ばず、候補の選択を外すだけにしました。

text
「山」と ↓ ↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-1(富山県) / aria-selected=true: 富山県 / フォーカス: input / カーソルの位置 1
Home: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input / カーソルの位置 0
↓ のあと End: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input / カーソルの位置 1

Home でカーソルが先頭に、End で最後に動き、一覧は開いたまま、候補の選択だけが外れました。APG の例でも、Home と End を押すと入力欄に戻り、カーソルが動きます。

選んでいる候補が見えるように、一覧をスクロールする

一覧には max-height: 180px と overflow-y: auto を付け、5件ほどが見える高さにしています。空の入力欄で ↓ を10回押し、10番目の群馬県を選んだところで、候補が一覧の見えている範囲にあるかを調べました。scrollIntoView を呼ぶエフェクトを消した版とも比べています。

text
scrollIntoView あり
一覧の高さ 190px、scrollTop 186px、選んでいる候補は一覧の見えている範囲にある

scrollIntoView なし
一覧の高さ 190px、scrollTop 0px、選んでいる候補は一覧の見えている範囲にない

フォーカスは入力欄にあるので、候補を選んでもブラウザは一覧をスクロールしません。エフェクトがないと、選んでいる候補は一覧の下に隠れたままでした。MDN の aria-activedescendant のページにも、選んでいる要素が見える位置にあるようにする(必要ならスクロールする)よう書かれています。

block: 'nearest' は、MDN の scrollIntoView() のページによると、見えるようになるまでの最小限だけスクロールする指定です。候補がもう見えているときはスクロールしません。block を指定しないときの既定値は start で、選んだ候補を一覧の上端に合わせる位置までスクロールします(この場合は試していません)。

日本語の変換中のキーは、IME に任せる

日本語の入力では、変換中に ↓ で変換候補を選び、Enter で変換を確定することがあります。このキーも keydown として入力欄に届くので、コンボボックスが反応すると困ります。

「やま」と入力して「山」に変換した状態で ↓ を押し、Enter で変換を確定しました。onKeyDown の最初で isComposing を見る版と、その行を消した版を比べています。

text
isComposing を見る版
「やま」を「山」に変換した状態: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input
変換中に ↓: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input
Enter で確定: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input / 送信した回数:0
届いた keydown: ArrowDown(isComposing=true) → Enter(isComposing=true)

isComposing を見ない版
「やま」を「山」に変換した状態: 値「山」 / expanded=true / activedescendant=なし / aria-selected=true: なし / フォーカス: input
変換中に ↓: 値「山」 / expanded=true / activedescendant=_r_0_-option-0(山形県) / aria-selected=true: 山形県 / フォーカス: input
Enter で確定: 値「山形県山」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: input / 送信した回数:0
届いた keydown: ArrowDown(isComposing=true) → Enter(isComposing=true)

isComposing を見ない版では、変換中の ↓ で山形県が選ばれ、変換を確定する Enter で山形県に決まりました。そのあとに確定した「山」が入り、入力欄は「山形県山」になっています。isComposing を見る版では、どちらのキーにも反応せず、確定した「山」で候補が絞り込まれたままでした。

MDN の isComposing のページのとおり、isComposing は変換の始まり(compositionstart)から終わり(compositionend)までのあいだに起きたキーで true になります。React の onKeyDown に渡るのは React のイベントで、React のキーボードイベントのプロパティの一覧には isComposing がありません。そこで、ブラウザのもとのイベントである event.nativeEvent から読みました。

「やま」と入力して「山」に変換し、Enter で確定しても候補は選ばれない。続けて ↓ を3回、↑ を1回押すと、選んでいる候補が山形県、富山県、山梨県、富山県と動き、Enter で入力欄の値が富山県になる
キー操作8回の様子。変換を確定する Enter では候補が選ばれず、変換のあとの ↓↑ と Enter で候補を選んで決める(操作と状態の表示を足した検証用の画面。変換の操作は Chrome DevTools Protocol の命令で再現)
注意

検証では、実際の IME ではなく Chrome DevTools Protocol の命令で変換中の状態を作りました。実際の IME では、OS やブラウザによってイベントの順番が変わることがあります。MDN の keydown イベントのページにある IME の説明では、変換の最初や最後のキーで isComposing が false のまま届く場合があるとして、keyCode が 229 かどうかもあわせて見るよう勧めています。この記事のコードは isComposing だけを見ていて、229 の場合は確かめていません。使う環境の IME で、変換を確定する Enter で候補が決まってしまわないかを確かめてください。

keyCode もあわせて見る書き方は、以前の記事「Reactのカスタムフックでキーボードショートカットを作る方法」で扱いました。

マウスで選ぶときは、押した時点でフォーカスを外さない

キーボードの話からは少し外れますが、同じ部品をマウスでも使えるようにするには、候補の onMouseDown で preventDefault() を呼びます。一覧は、入力欄からフォーカスが外れたとき(onBlur)に閉じるようにしているためです。「山」と入力してから岡山県の位置をクリックし、この1行を消した版と比べました。

text
preventDefault する版
「岡山県」の位置を押したあと: 値「岡山県」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: input

しない版
「岡山県」の位置を押したあと: 値「山」 / expanded=false / activedescendant=なし / aria-selected=true: なし / フォーカス: BODY

preventDefault() を呼ばない版では、マウスのボタンを押した時点で入力欄からフォーカスが外れ、一覧が閉じました。ボタンを離したときには候補がもう隠れているので、岡山県は選ばれず、フォーカスも body に移っています。

作るときのポイント

フォーカスは入力欄に置いたまま、選んでいる候補の番号を state に持ち、その候補の id を aria-activedescendant に、aria-selected を候補に付けます。↓↑ は最後と最初でつながるようにし、←・→・Home・End は入力欄の編集に残します。Enter の preventDefault() は候補を選んでいるときだけにして、閉じたあとの Enter でフォームを送れるようにします。

フォーカスが動かないぶん、一覧のスクロールは自分で scrollIntoView({ block: 'nearest' }) を呼びます。日本語で使う部品では、onKeyDown の最初で event.nativeEvent.isComposing を見て、変換中の ↓ や Enter を IME に任せます。

​Posted by 86