Reactで、入力候補を出すコンボボックスをキーボードで操作する方法
入力した文字で候補を絞り込むコンボボックスは、フォーカスを入力欄(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 を入力欄のカーソル移動に使う場合に役立つと説明しています。
コンポーネントの全体
候補の配列とラベルを受け取ります。入力した文字を含む候補だけを一覧に出します。
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回押しました。
入力後: 値「山」 / 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: 都道府県
- combobox "都道府県" [expanded]: 山
- listbox "都道府県":
- option "山形県"
- option "富山県"
- option "山梨県"
- option "和歌山県"
- option "岡山県" [selected]
- option "山口県"
一覧を閉じているあいだは、aria-activedescendant を付けません。aria-expanded も false に戻します。APG のパターンでは、一覧が見えていないときの aria-expanded は false とされています。
Enter は、候補を選んでいるときだけ止める
前の状態(岡山県を選んでいる)で Enter を押し、続けてもう一度 Enter を押しました。
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回押しました。
「山」と ↓: 値「山」 / 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 で閉じてから ↑ を押しました。
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() を呼ばず、候補の選択を外すだけにしました。
「山」と ↓ ↓: 値「山」 / 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 を呼ぶエフェクトを消した版とも比べています。
scrollIntoView あり
一覧の高さ 190px、scrollTop 186px、選んでいる候補は一覧の見えている範囲にある
scrollIntoView なし
一覧の高さ 190px、scrollTop 0px、選んでいる候補は一覧の見えている範囲にない
フォーカスは入力欄にあるので、候補を選んでもブラウザは一覧をスクロールしません。エフェクトがないと、選んでいる候補は一覧の下に隠れたままでした。MDN の aria-activedescendant のページにも、選んでいる要素が見える位置にあるようにする(必要ならスクロールする)よう書かれています。
block: 'nearest' は、MDN の scrollIntoView() のページによると、見えるようになるまでの最小限だけスクロールする指定です。候補がもう見えているときはスクロールしません。block を指定しないときの既定値は start で、選んだ候補を一覧の上端に合わせる位置までスクロールします(この場合は試していません)。
日本語の変換中のキーは、IME に任せる
日本語の入力では、変換中に ↓ で変換候補を選び、Enter で変換を確定することがあります。このキーも keydown として入力欄に届くので、コンボボックスが反応すると困ります。
「やま」と入力して「山」に変換した状態で ↓ を押し、Enter で変換を確定しました。onKeyDown の最初で isComposing を見る版と、その行を消した版を比べています。
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 から読みました。

検証では、実際の IME ではなく Chrome DevTools Protocol の命令で変換中の状態を作りました。実際の IME では、OS やブラウザによってイベントの順番が変わることがあります。MDN の keydown イベントのページにある IME の説明では、変換の最初や最後のキーで isComposing が false のまま届く場合があるとして、keyCode が 229 かどうかもあわせて見るよう勧めています。この記事のコードは isComposing だけを見ていて、229 の場合は確かめていません。使う環境の IME で、変換を確定する Enter で候補が決まってしまわないかを確かめてください。
keyCode もあわせて見る書き方は、以前の記事「Reactのカスタムフックでキーボードショートカットを作る方法」で扱いました。
マウスで選ぶときは、押した時点でフォーカスを外さない
キーボードの話からは少し外れますが、同じ部品をマウスでも使えるようにするには、候補の onMouseDown で preventDefault() を呼びます。一覧は、入力欄からフォーカスが外れたとき(onBlur)に閉じるようにしているためです。「山」と入力してから岡山県の位置をクリックし、この1行を消した版と比べました。
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 に任せます。





