このサジェストでいちばん時間を溶かしたのは、深夜に向き合っていた「候補をクリックしても、選ぶ前にリストが消える」問題でした。最初は 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 が片付けていた。候補を選ぶ前に消えるのは、ある意味で当然の挙動でした。

直し方は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 で閉じる、までひととおり試せます。本番に入れるときは、辞書データ、デザイン、検索ページへの遷移をサイトに合わせて調整してください。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>日本語サジェスト サンプル</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; } .suggest-container { position: relative; width: 100%; max-width: 500px; } .suggest-input { width: 100%; padding: 14px 18px; font-size: 16px; border: 2px solid #e0e0e0; border-radius: 10px; outline: none; background: #fff; box-sizing: border-box; } .suggest-input:focus { border-color: #0066cc; box-shadow: 0 0 0 3px rgba(0,102,204,0.1); } .suggest-list { position: absolute; top: 100%; left: 0; right: 0; margin: 4px 0 0; padding: 0; list-style: none; background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-height: 320px; overflow-y: auto; z-index: 1000; display: none; } .suggest-list.active { display: block; } .suggest-item { padding: 12px 18px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .suggest-item:hover { background: #f5f5f5; } .suggest-item.selected { background: #e6f0ff; } .suggest-reading { color: #888; font-size: 13px; } </style> </head> <body> <div class="suggest-container"> <input type="text" id="searchInput" class="suggest-input" placeholder="ひらがなで入力してください..." autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="suggestionList"> <ul id="suggestionList" class="suggest-list" role="listbox" aria-label="検索候補"></ul> </div> <script> const dictionary = [ { text: '雨', reading: 'あめ' }, { text: '赤', reading: 'あか' }, { text: '青', reading: 'あお' }, { text: '秋', reading: 'あき' }, { text: '天気', reading: 'てんき' }, { text: '天気予報', reading: 'てんきよほう' }, { text: 'JavaScript', reading: 'じゃばすくりぷと' }, { text: 'WordPress', reading: 'わーどぷれす' } ]; const input = document.getElementById('searchInput'); const suggestionList = document.getElementById('suggestionList'); let isComposing = false; let currentResults = []; let selectedIndex = -1; function escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } function debounce(func, delay) { let t = null; return function (...a) { if (t !== null) clearTimeout(t); t = setTimeout(() => func.apply(this, a), delay); }; } function searchByReading(query) { const q = query.trim(); if (!q) return []; return dictionary.filter(item => item.reading.startsWith(q)) .sort((a, b) => { if (a.reading === q) return -1; if (b.reading === q) return 1; return a.reading.length - b.reading.length; }).slice(0, 10); } function renderSuggestions(results) { currentResults = results; selectedIndex = -1; if (results.length === 0) { hideSuggestions(); return; } suggestionList.innerHTML = results.map((item, i) => ` <li class="suggest-item" role="option" data-index="${i}"> <span>${escapeHtml(item.text)}</span> <span class="suggest-reading">${escapeHtml(item.reading)}</span> </li>`).join(''); suggestionList.classList.add('active'); input.setAttribute('aria-expanded', 'true'); // blur より先に走らせるため mousedown を使う suggestionList.querySelectorAll('.suggest-item').forEach(li => { li.addEventListener('mousedown', (e) => { e.preventDefault(); // フォーカスが外れる動作をキャンセル selectItem(parseInt(li.dataset.index, 10)); }); }); } function hideSuggestions() { suggestionList.classList.remove('active'); suggestionList.innerHTML = ''; input.setAttribute('aria-expanded', 'false'); selectedIndex = -1; } function updateSelection(index) { selectedIndex = index; const items = suggestionList.querySelectorAll('.suggest-item'); items.forEach((li, i) => li.classList.toggle('selected', i === index)); if (items[index]) items[index].scrollIntoView({ block: 'nearest' }); } function selectItem(index) { if (index < 0 || index >= currentResults.length) return; input.value = currentResults[index].text; hideSuggestions(); // 検索ページへ遷移するなら、ここで location.href = ... } const debouncedSearch = debounce((value) => { if (isComposing) return; renderSuggestions(searchByReading(value)); }, 150); input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', (e) => { isComposing = false; debouncedSearch(e.target.value); }); input.addEventListener('input', (e) => { if (e.isComposing || isComposing) return; debouncedSearch(e.target.value); }); input.addEventListener('keydown', (e) => { if (e.isComposing || isComposing) return; const visible = suggestionList.classList.contains('active'); switch (e.key) { case 'ArrowDown': if (visible && currentResults.length > 0) { e.preventDefault(); updateSelection(selectedIndex < currentResults.length - 1 ? selectedIndex + 1 : 0); } break; case 'ArrowUp': if (visible && currentResults.length > 0) { e.preventDefault(); updateSelection(selectedIndex > 0 ? selectedIndex - 1 : currentResults.length - 1); } break; case 'Enter': if (visible && selectedIndex >= 0) { e.preventDefault(); selectItem(selectedIndex); } break; case 'Escape': if (visible) { e.preventDefault(); hideSuggestions(); } break; } }); input.addEventListener('focus', () => { if (input.value.trim()) debouncedSearch(input.value); }); input.addEventListener('blur', () => { // mousedown 側で preventDefault 済み。フォールバックとして遅延して閉じる setTimeout(hideSuggestions, 200); }); </script> </body> </html> |

API にするなら、何が変わるか
辞書が大きくなったら、候補をサーバーから取る形になります。ここで最初に踏むのが、レスポンスの順番です。「あ」と入れた直後に「あめ」と入れると、リクエストが連続で飛び、ネットワーク次第で「あめ」のレスポンスが先に返り、あとから「あ」のレスポンスが返ることがある。素直に受け取ると、入力欄は「あめ」なのに候補欄に「あ」の結果が出る、という逆転が起きます。これを防ぐのが AbortController です。新しいリクエストを送る前に、前回を abort() で止めます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | let abortController = null; async function fetchSuggestions(query) { if (abortController) abortController.abort(); abortController = new AbortController(); try { const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`, { signal: abortController.signal }); if (!res.ok) throw new Error('Network error'); return await res.json(); } catch (err) { if (err.name === 'AbortError') return null; // 意図した中断は無視 console.error('Fetch error:', err); return []; } } |
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 で詰まったら、自分が見ているイベントのひとつ前、ひとつ外側で何が起きているかを、一度引いて確かめる。あなたの検索窓は、クリックされるひとつ手前で、何をしているでしょうか。
関連記事
- 「あ」で「雨」「赤」を出す日本語サジェストを、HTML と JavaScript だけで作る|IME に頼らない読み検索(基本編)。本記事の前編で、IME に頼らない検索ロジックを扱います。
- 姓名フォームのフリガナ自動入力を composition イベントで自前実装した話。同じ composition イベントを使った別実装です。
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話。日本語フォーム実装シリーズの仕上げです。
参考にした公式情報
- 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日


コメント