日本語サジェストの実装版|キーボード操作・blur 競合・WAI-ARIA・API 連携まで直して、ようやく使える検索 UI にした話

Web Development
この記事は約18分で読めます。

このサジェストでいちばん時間を溶かしたのは、深夜に向き合っていた「候補をクリックしても、選ぶ前にリストが消える」問題でした。最初は click イベントの書き方を疑って、addEventListener の場所を行ったり来たり。でも、原因はそこではありません。入力欄の blur が、クリック処理より先に発火して、候補リストを片付けていたのです。見ているイベントの、ひとつ手前で別のことが起きていた。先にこの答えと直し方を置きます。直し方は、候補のクリックを click ではなく mousedown で受けて preventDefault() を呼び、フォーカスが外れる動作自体をキャンセルすることです。

前編で、日本語サジェストの検索ロジックは作りました。ひらがなで読みの前方一致の候補が出て、IME 変換中の暴発も止まり、debounce で検索回数も絞れる。ところが、自分のサイトの検索窓に入れて使い始めると、細かいところで困りました。候補は出るのに ↑↓ で選べない。Enter を押すと IME の確定とサジェストの選択がぶつかる。マウスで候補をクリックした瞬間にリストが消える。試しに macOS の VoiceOver を有効にして触ったら、候補が出ていること自体が音声では伝わっていませんでした。動いて見えるのに、検索 UI として使うには、もう一段あったのです。

検証環境:Google Chrome 147 / macOS Tahoe 26.4 / JavaScript ES6+。検証は2026年4月、記事更新は2026年5月6日。これは実装版で、検索ロジックの基本は前編にあります。コードは私の環境で動かした最小サンプルです。

「動くけど使えない」サジェストと「実際に使える」サジェストの対比図

クリックで消えるのは、click のせいではない

冒頭の問題を、順番で見るとはっきりします。入力欄にフォーカスがある状態で、候補をクリックしようとする。クリック対象は入力欄ではないので、フォーカスが外れる。このとき blur が先に発火して、候補リストを閉じる。その後 click が処理されるけれど、候補はもう消えている。つまり click の前に blur が片付けていた。候補を選ぶ前に消えるのは、ある意味で当然の挙動でした。

blurがclickより先に発火して候補リストが消える順序の図

直し方は2段構えにしました。候補のクリックを mousedown で受けます。mousedown は blur より先に発火するので、ここで preventDefault() を呼ぶと、入力欄からフォーカスが外れる動作そのものをキャンセルできます。これでクリックが blur に消される前に通ります。そのうえで、保険として blur 側は setTimeout で 200ms 遅らせてから閉じます。100ms だとトラックパッドでクリック途中に閉じることがあり、300ms だと閉じるのが遅く感じる。私の環境では 200ms がいちばん違和感が少なかった、という値です。端末やブラウザで体感は変わるので、絶対の数字ではありません。

キーボードと IME 確定の衝突を、どうほどくか

候補が出るだけでは、検索窓として物足りません。↓ で次の候補へ、↑ で前の候補へ(端で循環)、Enter で選択中の候補を確定、Esc で閉じる。この4つを keydown で入れます。↑↓ には e.preventDefault() も足します。これがないと、候補のハイライトを動かしたいだけなのに入力欄のカーソルまで動いて、操作感が悪くなりました。

いちばん気を遣ったのが Enter です。日本語入力では、Enter は IME の変換を確定するキーでもあり、サジェストの候補を確定するキーでもあります。変換中かを見ずに処理すると、まだ変換途中なのにサジェストの候補まで一緒に確定してしまう。「あめ」と入れようとした変換中に Enter を押した瞬間、候補欄の先頭の別の単語が入力欄に入って、入力中の文字が消える。これが地味に困ります。だから e.isComposing と自前の isComposing フラグの両方を見て、IME 変換中はサジェスト側のキー操作をスキップします。前編で作った合成中フラグが、ここでも効いてきます。

スクリーンリーダーに、候補の存在を伝える

見た目だけでは伝わらない部分もありました。VoiceOver を有効にして確かめると、ARIA 属性を入れていない状態では、候補が出ていても入力欄としてしか読み上げられません。候補が今いくつあるか、リストが開いているか、が音声では伝わっていなかった。見える人にはほぼ違いが分かりませんが、スクリーンリーダーから入る人には別物です。

そこで、入力欄に role=”combobox”、aria-expanded、aria-autocomplete、aria-controls を、候補リストに role=”listbox” を付けました。候補の表示状態に合わせて aria-expanded を true / false で切り替えます。MDN の combobox ロールの説明と、WAI-ARIA Authoring Practices Guide の Combobox パターンを参照しています(いずれも 2026年5月6日に確認)。autocomplete=”off” も入れて、ブラウザ標準の入力履歴と自作の候補が二重に出ないようにしています。

コピーすれば動く完全版

ここまでを1つの HTML にまとめます。test.html のような名前で保存してブラウザで開けば、ひらがな入力から候補表示、↑↓ での選択、Enter での確定、マウスクリック、Esc で閉じる、までひととおり試せます。本番に入れるときは、辞書データ、デザイン、検索ページへの遷移をサイトに合わせて調整してください。

↓キーで2番目の候補が青く選択されている状態

API にするなら、何が変わるか

辞書が大きくなったら、候補をサーバーから取る形になります。ここで最初に踏むのが、レスポンスの順番です。「あ」と入れた直後に「あめ」と入れると、リクエストが連続で飛び、ネットワーク次第で「あめ」のレスポンスが先に返り、あとから「あ」のレスポンスが返ることがある。素直に受け取ると、入力欄は「あめ」なのに候補欄に「あ」の結果が出る、という逆転が起きます。これを防ぐのが AbortController です。新しいリクエストを送る前に、前回を abort() で止めます。

AbortError は、ネットワーク障害ではなく、こちらが意図して止めた結果なので、画面にエラーを出す必要はありません。リクエストに連番を振って古い番号を捨てる方法もありますが、fetch なら AbortController のほうが意図が読み取りやすく、コードもすっきりします。

残りは、規模に応じた調整です。debounce は、ローカル辞書なら 150ms で十分ですが、API なら 1 文字ごとにリクエストが飛ぶので 300ms 前後から試すほうが安全です。候補テーブルは reading 列にインデックスを張れば LIKE ‘あ%’ の前方一致が軽く返ります(前後ワイルドカード %あ% は重いので避ける)。同じクエリが短時間に繰り返されるなら Redis などのキャッシュを挟み、トラフィックが増えるなら最初のかな1文字ごとの静的 JSON を CDN で配る手もあります。公開する API には、レート制限(1 ユーザー 1 分 60 回くらいから)、クエリ長の上限(100 文字を超えたら空配列)、そして HTML エスケープを最初から入れておきます。辞書が自前のうちは問題が出にくくても、あとからユーザー投稿や外部データを混ぜたときに、エスケープを忘れていると XSS の入り口になります。

見ていたのは、ひとつ手前だったか

クリック問題のとき、私は click の書き方ばかり疑っていました。本当の原因は、もう一段手前の blur にあった。動かない、イコール、その処理のコードが悪い、と決めつけて、近くだけを見続けた結果、遠回りしました。

サジェスト UI で時間を使ったのは、検索ロジックそのものより、自然に使えるようにする部分です。IME 変換中の Enter、候補クリックの前に走る blur、キーボードだけで動くか、スクリーンリーダーで候補が伝わるか、API 化で古いレスポンスが逆転しないか。どれも、コードを眺めるだけだと見落とす項目でした。実際に入力して、変換して、クリックして、↑↓ で動かして、VoiceOver でも読ませて、ようやく気づけた。小さな検索窓ひとつでも、まずブラウザ内の辞書で動かし、候補が増えたら API や静的 JSON を検討する、という順番が、個人ブログでは扱いやすいです。UI で詰まったら、自分が見ているイベントのひとつ前、ひとつ外側で何が起きているかを、一度引いて確かめる。あなたの検索窓は、クリックされるひとつ手前で、何をしているでしょうか。

関連記事

参考にした公式情報

  • MDN Web Docs「ARIA: combobox ロール」確認日:2026年5月6日
  • WAI-ARIA Authoring Practices Guide「Combobox Pattern」確認日:2026年5月6日
  • MDN Web Docs「AbortController: abort() メソッド」確認日:2026年5月6日
Web Development
この記事を書いた人
rapls

WordPressのプラグインを作っているフリーランスエンジニアです。Web開発はもう6年以上。WordPress.orgで Rapls AI Chatbot、Thanks Mail for Stripe、Rapls PDF Image Creator、Prime Cache の4本を公開し、保守を続けながら、日本語ロケールの翻訳エディター(PTE)も務めています。このブログに書くのは、現場で自分が実際にハマって、調べて、直した話です。

raplsをフォローする
raplsをフォローする

コメント

タイトルとURLをコピーしました