日本語サジェストの後編です。前編で作った検索ロジックにUIを組み合わせ、キーボード操作・フォーカス制御・API連携まで含めた「コピペで動く完全版」を仕上げます。
前編では、日本語サジェストの仕組み——IMEのcompositionイベント対応、debounce、プレフィックス検索のロジック——を解説しました。
後編では、前編で作った「頭脳」に「体」を与えます。HTMLとCSSでUIを構築し、キーボード操作に対応させ、コピペで動くサンプルコードを仕上げます。さらに後半では外部API連携やキャッシュなど、本番運用に向けた応用もカバーします。
この後編を書くことになった経緯
前編を公開した後、自分のブログにサジェスト機能を実装しようとしたところ、検索ロジックだけでは「使い物になるUI」にはならないことを痛感しました。
前編のコードは「ひらがなを入力すると候補が出る」ところまでは動きます。しかし実際にブログの検索窓に組み込んでみると、問題が次々と出てきました。
↑↓キーで候補を選べない。Enterで確定できない。候補をマウスでクリックしようとすると、クリックの瞬間にリストが消える。スクリーンリーダーでは候補の存在すら認識されない。
「動くけど使えない」という状態です。検索ロジックはあくまで部品であって、ユーザーが触れるUIにするにはキーボード操作、フォーカス制御、アクセシビリティの3つが必要でした。この後編はその実装記録です。
HTMLの構造——WAI-ARIAで支援技術に伝える
サジェストUIは入力欄+候補リストの2要素で構成し、WAI-ARIAの属性でスクリーンリーダーに「ドロップダウンリストと連携している」と伝えます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<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> |
autocomplete="off"はブラウザ標準のオートコンプリートと自前のサジェストが競合するのを防ぐためです。
role="combobox"とaria-expandedは地味ですが重要です。これがないと、VoiceOverやNVDAのユーザーには候補リストの存在が見えません。実際に自分のMacでVoiceOverを有効にしてテストしたところ、ARIA属性なしでは「入力欄です」としか読み上げられず、候補が出ていることに気づけませんでした。
CSSのポイント——position: absoluteとz-index
候補リストはposition: absoluteで入力欄の直下に配置します。ホバーとキーボード選択で色を変え、どちらの操作かが視覚的に分かるようにします。
|
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 |
.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; transition: border-color 0.2s, box-shadow 0.2s; } .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; transition: background-color 0.1s; display: flex; justify-content: space-between; align-items: center; } .suggest-item:hover { background: #f5f5f5; } .suggest-item.selected { background: #e6f0ff; } |
:hover(マウス)と.selected(キーボード)で色を分けているのは、ユーザーが「今どちらの操作で候補を選んでいるか」を把握しやすくするためです。特にタッチデバイスとマウスが混在する環境では、この区別が効きます。
z-index: 1000は他のUI要素(ヘッダー、モーダルなど)より前面に出すためです。サイトによっては値を調整してください。
キーボード操作——↑↓Enter Escの4キー対応
サジェストを使い物にするにはキーボード操作が必須です。↑↓で候補を循環選択、Enterで確定、Escでキャンセル。IME変換中はすべてスキップします。
|
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 |
input.addEventListener('keydown', (e) => { if (e.isComposing || isComposing) return; // IME変換中はスキップ const isListVisible = suggestionList.classList.contains('active'); switch (e.key) { case 'ArrowDown': if (isListVisible && currentResults.length > 0) { e.preventDefault(); const next = selectedIndex < currentResults.length - 1 ? selectedIndex + 1 : 0; // 末尾→先頭に循環 updateSelection(next); } break; case 'ArrowUp': if (isListVisible && currentResults.length > 0) { e.preventDefault(); const prev = selectedIndex > 0 ? selectedIndex - 1 : currentResults.length - 1; updateSelection(prev); } break; case 'Enter': if (isListVisible && selectedIndex >= 0) { e.preventDefault(); selectItem(selectedIndex); } break; case 'Escape': if (isListVisible) { e.preventDefault(); hideSuggestions(); } break; } }); |
実装で最もハマったのはe.isComposingのチェックです。これがないと、IMEで「てんき」と入力中にEnterを押したとき、IMEの確定とサジェストの確定が同時に発火してしまいます。前編で解説したcompositionstart/endのフラグと併用して、変換中のキーイベントを確実にスキップします。
e.preventDefault()は↑↓キーのデフォルト動作(テキストカーソルの移動)を無効化するためです。これがないと候補選択とカーソル移動が同時に起きて混乱します。
末尾で先頭に戻る「循環選択」は、候補が10件あっても素早く目的の候補にたどり着けるようにするための工夫です。
blurとクリックの競合——200msの遅延で解決
入力欄からフォーカスが外れたら候補を閉じたい。しかし候補をクリックした瞬間にblurが先に発火してリストが消え、クリックが空振りする。200msの遅延を入れることで解決します。
これは実装中に最も「なぜ?」となった問題です。候補をマウスでクリックすると、クリックの瞬間に入力欄からフォーカスが外れてblurイベントが発火し、候補リストが消える。リストが消えた後にclickイベントが処理されるので、クリック対象が存在しない。結果、候補を選択できない。
|
1 2 3 4 5 6 7 8 9 |
input.addEventListener('blur', () => { setTimeout(() => { hideSuggestions(); }, 200); }); input.addEventListener('focus', () => { if (input.value.trim()) { debouncedUpdate(input.value); } }); |
200msの間にclickイベントが処理されるので、「候補を選択→リストが閉じる」という正しい順序になります。この200msという値は経験的なもので、100msだとクリックが間に合わないことがあり、300msだと閉じるのが遅く感じる。
コピペで動く完全版コード
ここまでのすべてを統合した完全版です。HTMLファイルとして保存してブラウザで開けば、そのまま動きます。

|
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 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>日本語サジェストデモ</title> <style> * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif; background: #f5f5f5; padding: 40px 20px; margin: 0; } .demo-container { max-width: 600px; margin: 0 auto; } h1 { font-size: 1.5rem; margin-bottom: 8px; } .description { color: #666; margin-bottom: 24px; } .suggest-container { position: relative; width: 100%; } .suggest-input { width: 100%; padding: 14px 18px; font-size: 16px; border: 2px solid #e0e0e0; border-radius: 10px; outline: none; background: #fff; transition: border-color 0.2s, box-shadow 0.2s; } .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 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; transition: background-color 0.1s; display: flex; justify-content: space-between; align-items: center; } .suggest-item:first-child { border-radius: 10px 10px 0 0; } .suggest-item:last-child { border-radius: 0 0 10px 10px; } .suggest-item:only-child { border-radius: 10px; } .suggest-item:hover { background: #f5f5f5; } .suggest-item.selected { background: #e6f0ff; } .suggest-text { font-weight: 500; } .suggest-reading { font-size: 13px; color: #888; } .suggest-empty { padding: 16px 18px; color: #888; text-align: center; } .hint { margin-top: 16px; padding: 12px 16px; background: #e8f5e9; border-radius: 8px; font-size: 14px; color: #2e7d32; } .hint code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; } </style> </head> <body> <div class="demo-container"> <h1>日本語サジェストデモ</h1> <p class="description">ひらがなを入力すると漢字候補が表示されます。↑↓で選択、Enterで確定、Escでキャンセル。</p> <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> <div class="hint"> 試してみてください:<code>あ</code> <code>てんき</code> <code>とうきょう</code> <code>JavaScript</code> </div> </div> <script> // === 辞書データ === const dictionary = [ {text:'雨',reading:'あめ'},{text:'雨天',reading:'うてん'}, {text:'雨雲',reading:'あまぐも'},{text:'雨季',reading:'うき'}, {text:'晴れ',reading:'はれ'},{text:'快晴',reading:'かいせい'}, {text:'曇り',reading:'くもり'},{text:'雪',reading:'ゆき'}, {text:'大雪',reading:'おおゆき'},{text:'台風',reading:'たいふう'}, {text:'天気',reading:'てんき'},{text:'天気予報',reading:'てんきよほう'}, {text:'気温',reading:'きおん'},{text:'湿度',reading:'しつど'}, {text:'赤',reading:'あか'},{text:'青',reading:'あお'}, {text:'黄色',reading:'きいろ'},{text:'緑',reading:'みどり'}, {text:'白',reading:'しろ'},{text:'黒',reading:'くろ'}, {text:'オレンジ',reading:'おれんじ'},{text:'ピンク',reading:'ぴんく'}, {text:'紫',reading:'むらさき'}, {text:'春',reading:'はる'},{text:'夏',reading:'なつ'}, {text:'秋',reading:'あき'},{text:'冬',reading:'ふゆ'}, {text:'朝',reading:'あさ'},{text:'昼',reading:'ひる'}, {text:'夕方',reading:'ゆうがた'},{text:'夜',reading:'よる'}, {text:'今日',reading:'きょう'},{text:'明日',reading:'あした'}, {text:'昨日',reading:'きのう'},{text:'今週',reading:'こんしゅう'}, {text:'JavaScript',reading:'じゃばすくりぷと'}, {text:'HTML',reading:'えいちてぃーえむえる'}, {text:'CSS',reading:'しーえすえす'},{text:'Python',reading:'ぱいそん'}, {text:'プログラミング',reading:'ぷろぐらみんぐ'}, {text:'サーバー',reading:'さーばー'},{text:'データベース',reading:'でーたべーす'}, {text:'アプリケーション',reading:'あぷりけーしょん'}, {text:'東京',reading:'とうきょう'},{text:'大阪',reading:'おおさか'}, {text:'名古屋',reading:'なごや'},{text:'福岡',reading:'ふくおか'}, {text:'北海道',reading:'ほっかいどう'},{text:'沖縄',reading:'おきなわ'}, {text:'京都',reading:'きょうと'},{text:'横浜',reading:'よこはま'}, {text:'アメリカ',reading:'あめりか'},{text:'日本',reading:'にほん'}, {text:'検索',reading:'けんさく'},{text:'設定',reading:'せってい'}, {text:'ヘルプ',reading:'へるぷ'},{text:'お問い合わせ',reading:'おといあわせ'}, {text:'ログイン',reading:'ろぐいん'},{text:'ログアウト',reading:'ろぐあうと'}, {text:'新規登録',reading:'しんきとうろく'},{text:'パスワード',reading:'ぱすわーど'}, {text:'電話番号',reading:'でんわばんごう'},{text:'住所',reading:'じゅうしょ'}, {text:'名前',reading:'なまえ'},{text:'会社',reading:'かいしゃ'}, {text:'仕事',reading:'しごと'},{text:'学校',reading:'がっこう'}, {text:'病院',reading:'びょういん'},{text:'銀行',reading:'ぎんこう'}, {text:'コンビニ',reading:'こんびに'},{text:'レストラン',reading:'れすとらん'}, {text:'ホテル',reading:'ほてる'}, ]; // === ユーティリティ === function debounce(fn, delay) { let tid = null; return function(...args) { if (tid !== null) clearTimeout(tid); tid = setTimeout(() => fn.apply(this, args), delay); }; } function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } // === 検索ロジック === function searchByReading(query) { const q = query.toLowerCase().trim(); if (!q) return []; let r = dictionary.filter(i => i.reading.startsWith(q) || i.text.toLowerCase().startsWith(q) ); r.sort((a, b) => { const ae = a.reading === q || a.text.toLowerCase() === q; const be = b.reading === q || b.text.toLowerCase() === q; if (ae && !be) return -1; if (!ae && be) return 1; return a.reading.length - b.reading.length; }); return r.slice(0, 10); } // === UI制御 === const input = document.getElementById('searchInput'); const list = document.getElementById('suggestionList'); let isComposing = false, selectedIndex = -1, currentResults = []; function showSuggestions(results) { currentResults = results; selectedIndex = -1; list.innerHTML = ''; if (results.length === 0) { const li = document.createElement('li'); li.className = 'suggest-empty'; li.textContent = '候補がありません'; list.appendChild(li); } else { results.forEach((item, i) => { const li = document.createElement('li'); li.className = 'suggest-item'; li.setAttribute('role', 'option'); li.innerHTML = `<span class="suggest-text">${escapeHtml(item.text)}</span> <span class="suggest-reading">${escapeHtml(item.reading)}</span>`; li.addEventListener('click', () => selectItem(i)); li.addEventListener('mouseenter', () => updateSelection(i)); list.appendChild(li); }); } list.classList.add('active'); input.setAttribute('aria-expanded', 'true'); } function hideSuggestions() { list.classList.remove('active'); input.setAttribute('aria-expanded', 'false'); selectedIndex = -1; currentResults = []; } function updateSelection(index) { const prev = list.querySelector('.selected'); if (prev) prev.classList.remove('selected'); selectedIndex = index; if (index >= 0 && index < currentResults.length) { const items = list.querySelectorAll('.suggest-item'); if (items[index]) { items[index].classList.add('selected'); items[index].scrollIntoView({ block: 'nearest' }); } } } function selectItem(index) { if (index >= 0 && index < currentResults.length) { input.value = currentResults[index].text; hideSuggestions(); input.focus(); } } function updateSuggestions(query) { const q = query.trim(); if (!q) { hideSuggestions(); return; } showSuggestions(searchByReading(q)); } const debouncedUpdate = debounce(updateSuggestions, 150); // === イベントリスナー === input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', () => { isComposing = false; updateSuggestions(input.value); }); input.addEventListener('input', (e) => { if (e.isComposing || isComposing) return; debouncedUpdate(e.target.value); }); input.addEventListener('keydown', (e) => { if (e.isComposing || isComposing) return; const vis = list.classList.contains('active'); switch (e.key) { case 'ArrowDown': if (vis && currentResults.length > 0) { e.preventDefault(); updateSelection(selectedIndex < currentResults.length - 1 ? selectedIndex + 1 : 0); } break; case 'ArrowUp': if (vis && currentResults.length > 0) { e.preventDefault(); updateSelection(selectedIndex > 0 ? selectedIndex - 1 : currentResults.length - 1); } break; case 'Enter': if (vis && selectedIndex >= 0) { e.preventDefault(); selectItem(selectedIndex); } break; case 'Escape': if (vis) { e.preventDefault(); hideSuggestions(); } break; } }); input.addEventListener('focus', () => { if (input.value.trim()) debouncedUpdate(input.value); }); input.addEventListener('blur', () => { setTimeout(() => hideSuggestions(), 200); }); </script> </body> </html> |


カスタマイズのポイント
辞書データとdebounce時間をサイトに合わせて調整すれば、このサンプルをそのまま本番に使えます。
辞書データ:ECサイトなら商品名とカテゴリ、ブログなら記事タイトルとタグ、企業サイトならサービス名とFAQ。辞書の中身がサジェストの品質を直接決めます。
表示件数:slice(0, 10)の数値で変更できます。5件ならコンパクトに、15件なら選択肢を増やせます。私のブログでは記事数が100本前後なので8件にしています。
debounce時間:150msはローカル検索用の値です。APIを叩くなら300msに上げてサーバー負荷を減らすのが妥当です。
ここから先は本番運用の話
ここまでの実装は「辞書データがブラウザ内にある」前提です。辞書が大きくなったり、リアルタイムに候補を更新したい場合は、サーバーサイドとの連携が必要になります。
小〜中規模のサイト(辞書が数百件程度)なら、ブラウザ内の辞書で十分です。私のブログはこの方式で運用しています。以下は辞書が大きくなった場合に必要になる技術です。
AbortControllerでリクエストの追い越しを防ぐ
サーバーから候補を取得する場合、ユーザーが「あ」→「あめ」と素早く入力すると、「あ」のレスポンスが「あめ」より後に返って古い結果が表示されることがあります。AbortControllerで前回のリクエストをキャンセルすれば解決します。
この「レスポンスの追い越し問題」は、サジェストに限らず非同期検索全般で発生します。
|
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 []; } } |
fetchの第2引数にsignalを渡しておくと、abort()呼び出し時にそのリクエストがキャンセルされます。AbortErrorは正常なキャンセルなので、エラーとして処理する必要はありません。私はこのパターンを知る前、setTimeoutで古いレスポンスを捨てるという回りくどい実装をしていました。AbortControllerの方がはるかにシンプルです。
サーバーサイドでの候補生成
候補データをMySQLに格納し、LIKE ‘あ%’の前方一致検索をかけます。インデックスが効くので数十万件でも高速です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
CREATE TABLE suggestions ( id INT AUTO_INCREMENT PRIMARY KEY, text VARCHAR(255) NOT NULL, reading VARCHAR(255) NOT NULL, popularity INT DEFAULT 0, INDEX idx_reading (reading), INDEX idx_text (text) ); SELECT text, reading FROM suggestions WHERE reading LIKE 'あ%' OR text LIKE 'あ%' ORDER BY popularity DESC, LENGTH(reading) ASC LIMIT 10; |
popularityカラムで検索回数を記録しておけば、よく使われる候補を上位に表示できます。
漢字→読みの変換を手動で作るのは大変なので、MeCabやpykakasiで自動生成するのが現実的です。ブログなら記事公開時にタイトルとタグの読みを自動生成して辞書テーブルに登録する運用ができます。
キャッシュ——RedisまたはJSON事前生成
サジェストAPIは短時間に大量のリクエストが飛ぶため、キャッシュが効果的です。Redisで動的キャッシュするか、よく使うプレフィックスを事前にJSONファイルとして生成する方法があります。
Redisでの動的キャッシュ
|
1 2 3 4 5 6 7 8 9 10 11 12 |
const Redis = require('ioredis'); const redis = new Redis(); async function getSuggestions(query) { const cacheKey = `suggest:${query}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const results = await searchFromDatabase(query); await redis.setex(cacheKey, 3600, JSON.stringify(results)); return results; } |
静的JSONファイルへの事前計算
ひらがな1文字分のプレフィックス(「あ」「い」「う」…)の結果を事前にJSONファイルとして生成し、CDNに乗せる方法です。APIサーバーすら不要になります。クライアント側では1文字目のJSONを読み込み、2文字目以降はブラウザ内で絞り込む。私のブログではこの方式を検討中です。
セキュリティ——レート制限とエスケープ
本番運用ではレート制限で連続リクエストを制限し、HTMLエスケープでXSSを防ぎます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const rateLimit = require('express-rate-limit'); app.use('/api/suggest', rateLimit({ windowMs: 60 * 1000, max: 60, message: { error: 'Too many requests' } })); app.get('/api/suggest', (req, res) => { const query = req.query.q; if (!query || typeof query !== 'string' || query.length > 100) { return res.json([]); } res.json(searchByReading(query)); }); |
長さ制限(100文字)は異常に長いクエリによるDoS対策です。レート制限は同一IPからの連続リクエストを制限し、APIの悪用を防ぎます。
HTMLエスケープについては前編でも触れましたが改めて強調します。辞書データが自前であっても、将来の拡張でユーザー入力やAPI経由のデータを含める可能性があります。最初からエスケープしておけば安全です。完全版コード内のescapeHtml()関数がこれに該当します。
まとめ
この記事の完全版コードをHTMLファイルとして保存すれば、ひらがなから漢字候補を表示するサジェスト機能がキーボード操作・アクセシビリティ対応付きですぐに動きます。
実装で最もハマったのは、blurとクリックの競合(200msの遅延で解決)と、IME変換中のキーイベント制御(isComposingフラグ)の2つでした。本番運用に進むならAbortControllerでリクエストの追い越しを防ぎ、Redisや静的JSONでキャッシュし、レート制限で悪用を防ぐ。まずはコピペで動かしてみて、辞書データをあなたのサイトに合わせて差し替えるところから始めてみてください。




コメント