IME 感知搜尋框(React)
注音/拼音組字中不觸發查詢、也不讓 Enter 誤選的 React 搜尋框:查詢等 compositionend 之後才發(一般打字走 debounce)、組字狀態指示 chip、結果關鍵字高亮、/ 快捷鍵聚焦,並附可直接抄進自己專案的 IME 處理 pattern。
中文使用者用一般搜尋框的兩個痛點:注音/拼音組字中每敲一鍵就打一次 API,Enter 選字時又把半成品送出去。這個 combobox 式搜尋框把 IME 狀態當一等公民——compositionstart 進入組字就只更新畫面、不排程查詢,compositionend 之後立刻查一次完整的字(一般打字則走 debounceMs 延遲),Enter 同時檢查 isComposing 與 keyCode 229 才選取。輸入框右側會浮出「組字中」chip,下方結果面板以 spring 開合、高亮列在項目間滑動,命中的關鍵字包進 <mark>(大小寫不敏感、空白分隔多關鍵字)。結果可以直接給 items 在本地過濾,也可以交給 onSearch() 回傳 Promise,讀取中顯示骨架、只採用最後一次請求的回應。焦點不在輸入元素時按 / 就聚焦搜尋框。
npx shadcn@latest add https://webberui.com/r/ime-aware-search.jsonPlayground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
<ImeAwareSearch />
安裝
npx shadcn@latest add https://webberui.com/r/ime-aware-search.json或在 components.json 設定 registries 後,改用 @webberui/ime-aware-search 安裝。
使用
import {
ImeAwareSearch,
highlightMatches,
type ImeAwareSearchItem,
} from "@/components/ui/ime-aware-search";
const ITEMS: ImeAwareSearchItem[] = [
{ id: "d1", label: "拿鐵", description: "雙份濃縮・熱/冰", group: "飲品" },
{ id: "s1", label: "肉桂捲", description: "附糖霜", group: "甜點" },
{ id: "b1", label: "中焙綜合豆", description: "200g・堅果、可可", group: "咖啡豆" },
];
// 靜態清單:元件在本地過濾(label/description/group 都參與比對)
<ImeAwareSearch items={ITEMS} onSelect={(item) => console.log(item.id)} />;
// 交給後端:onSearch 只會在組字結束、debounce 過後才被呼叫,空字串不呼叫
<ImeAwareSearch
onSearch={async (query) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return (await res.json()) as ImeAwareSearchItem[];
}}
onSelect={(item) => console.log("選取", item.id)} // 例如在這裡導向商品頁
debounceMs={250}
hotkey="/"
placeholder="搜尋商品⋯"
/>;
// 高亮函式可單獨用在你自己的清單上:「鐵」與「茶」會被包進 <mark>
highlightMatches("鐵觀音奶茶", "鐵 茶");Props
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
items | ImeAwareSearchItem[] | — | 靜態結果清單;未提供 onSearch 時,元件在本地依查詢過濾這份清單(空查詢列出全部,最多 maxResults 筆) |
onSearch | (query: string) => Promise<ImeAwareSearchItem[]> | ImeAwareSearchItem[] | — | 查詢函式,回傳陣列或 Promise;提供時優先於 items。空字串不會被呼叫,只採用最後一次請求的回應 |
onSelect | (item: ImeAwareSearchItem) => void | — | 以 Enter 或點擊選取某筆結果時回呼;輸入框同時被填入該筆的 label |
value | string | — | 受控的查詢字串;不提供時為非受控模式 |
defaultValue | string | "" | 非受控模式的初始查詢字串 |
onChange | (value: string) => void | — | 查詢字串變動時回呼;組字中的暫存文字也會回報,但不會觸發查詢 |
debounceMs | number | 200 | 一般輸入的查詢延遲毫秒數;compositionend 之後會立刻查詢、不等這段延遲 |
placeholder | string | "搜尋⋯" | 輸入框的 placeholder,同時作為 aria-label |
hotkey | string | false | "/" | 全域快捷鍵:焦點不在可輸入元素上時按下即聚焦搜尋框;傳 false 關閉 |
emptyText | string | "找不到符合的結果" | 查詢有內容但沒有任何結果時顯示的文字 |
composingText | string | "組字中" | IME 組字中顯示在輸入框右側的指示 chip 文字 |
maxResults | number | 8 | 最多顯示幾筆結果(先截斷再分組) |
popover | boolean | false | 以浮層方式把結果面板疊在下方內容之上;預設走文件流,不會被 overflow: hidden 的父層裁掉 |
className | string | — | 透傳到最外層容器 |
ImeAwareSearchItem
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
id | string | — | 唯一識別碼,作為 option 的 DOM id 與 React key |
label | string | — | 主要顯示文字,同時是比對與高亮的來源;被選取時會填進輸入框 |
description | string | — | 次要說明文字(同樣參與比對與高亮) |
group | string | — | 分組名稱;相同分組的結果會集中在同一個標題下,順序依首次出現 |
另外具名匯出三個純函式,方便在自己的清單或伺服器端重用同一套規則:matchesQuery(item, query)(所有關鍵字都要出現在 label/description/group 之一)、filterItems(items, query)(保留原順序過濾)、highlightMatches(text, query)(回傳可直接放進 JSX 的節點陣列,命中片段包在 <mark>)。
細節
IME 處理 pattern(可直接抄)——核心是一個 ref 記組字狀態,加上 Enter 的雙重檢查:
const composingRef = React.useRef(false);
<input
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={(e) => {
composingRef.current = false;
// 直接讀 DOM 值:Firefox/Safari 的 input 事件在 compositionend 之後才到,
// 此時 React state 可能還是組字中的暫存文字
search(e.currentTarget.value); // 立刻查一次完整的字,不等 debounce
}}
onChange={(e) => {
setValue(e.target.value); // 組字中只更新畫面
if (composingRef.current) return; // 不排程查詢
debouncedSearch(e.target.value);
}}
onKeyDown={(e) => {
// 中文等 IME 組字中按 Enter 不送出;Safari 在 compositionend 後才發 keydown(isComposing 已為 false),需再檢查 keyCode 229
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
if (e.key === "Enter") selectActive();
}}
/>;- 為什麼用 ref 不用 state:
compositionstart之後緊接著的input事件就要讀到true,state 會慢一拍 - 查詢流程:一般打字 → 等
debounceMs→ 查詢;組字中 → 只更新畫面;compositionend→ 立刻查詢並取消排程中的 debounce。Safari/Firefox 在compositionend之後補發的input事件內容與剛查過的字相同,會被略過,不會打第二次 - 請求順序:每次
onSearch都帶遞增序號,只採用最後一次請求的回應,慢的舊回應不會蓋掉新結果;卸載時排程中的查詢會被取消 - 比對與高亮:查詢以空白拆成多個關鍵字、統一小寫,每個關鍵字都要出現在 label/description/group 之一(AND);高亮用同一組關鍵字做大小寫不敏感的
split,正規表達式特殊字元會先跳脫 - 選取:Enter 或點擊把
label填進輸入框、收合面板並呼叫onSelect;本地模式的高亮會對齊新文字但不重新查詢。Esc 第一下收合面板、第二下清空文字;Tab 收合並移走焦點 - 快捷鍵:
hotkey只在焦點不在input/textarea/select/contentEditable 上、且沒有按住修飾鍵時生效;輸入框為空且未聚焦時右側會顯示kbd提示 - 面板定位:預設在文件流內、把下方內容往下推,不會被父層的
overflow: hidden裁掉;需要疊在內容上時開popover
可及性
- 輸入框是
role="combobox",帶aria-expanded、aria-controls、aria-haspopup="listbox"、aria-autocomplete="list";目前高亮的結果透過aria-activedescendant指向,焦點始終留在輸入框,不會被結果面板搶走 - 結果面板是
role="listbox",每筆結果為role="option"並以aria-selected標示高亮;有分組時以role="group"加aria-labelledby指向分組標題 - 鍵盤:↑/↓ 在結果間循環、Home/End 跳到首末、Enter 選取、Esc 收合再清空、Tab 收合;方向鍵在 IME 組字中一律放行給輸入法選字
- 結果數量、「搜尋中」與無結果文字放在
role="status"的aria-live="polite"區域,視覺上隱藏、只給螢幕閱讀器朗讀;「組字中」chip 與快捷鍵提示為純視覺,標示aria-hidden - 清除鈕為
type="button",帶aria-label="清除搜尋",支援鍵盤操作與focus-visible外框;按下後焦點仍留在輸入框 - 使用者系統開啟「減少動態效果」時,結果面板改為純淡入淡出、高亮列直接跳到新位置不滑動、「組字中」chip 只淡入、讀取指示與骨架不再旋轉/脈衝