WebberUI

IME 感知搜尋框(React)

注音/拼音組字中不觸發查詢、也不讓 Enter 誤選的 React 搜尋框:查詢等 compositionend 之後才發(一般打字走 debounce)、組字狀態指示 chip、結果關鍵字高亮、/ 快捷鍵聚焦,並附可直接抄進自己專案的 IME 處理 pattern。

中文使用者用一般搜尋框的兩個痛點:注音/拼音組字中每敲一鍵就打一次 API,Enter 選字時又把半成品送出去。這個 combobox 式搜尋框把 IME 狀態當一等公民——compositionstart 進入組字就只更新畫面、不排程查詢,compositionend 之後立刻查一次完整的字(一般打字則走 debounceMs 延遲),Enter 同時檢查 isComposingkeyCode 229 才選取。輸入框右側會浮出「組字中」chip,下方結果面板以 spring 開合、高亮列在項目間滑動,命中的關鍵字包進 <mark>(大小寫不敏感、空白分隔多關鍵字)。結果可以直接給 items 在本地過濾,也可以交給 onSearch() 回傳 Promise,讀取中顯示骨架、只採用最後一次請求的回應。焦點不在輸入元素時按 / 就聚焦搜尋框。

載入預覽⋯
npx shadcn@latest add https://webberui.com/r/ime-aware-search.json

Playground

即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。

200
8
<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型別預設值說明
itemsImeAwareSearchItem[]靜態結果清單;未提供 onSearch 時,元件在本地依查詢過濾這份清單(空查詢列出全部,最多 maxResults 筆)
onSearch(query: string) => Promise<ImeAwareSearchItem[]> | ImeAwareSearchItem[]查詢函式,回傳陣列或 Promise;提供時優先於 items。空字串不會被呼叫,只採用最後一次請求的回應
onSelect(item: ImeAwareSearchItem) => void以 Enter 或點擊選取某筆結果時回呼;輸入框同時被填入該筆的 label
valuestring受控的查詢字串;不提供時為非受控模式
defaultValuestring""非受控模式的初始查詢字串
onChange(value: string) => void查詢字串變動時回呼;組字中的暫存文字也會回報,但不會觸發查詢
debounceMsnumber200一般輸入的查詢延遲毫秒數;compositionend 之後會立刻查詢、不等這段延遲
placeholderstring"搜尋⋯"輸入框的 placeholder,同時作為 aria-label
hotkeystring | false"/"全域快捷鍵:焦點不在可輸入元素上時按下即聚焦搜尋框;傳 false 關閉
emptyTextstring"找不到符合的結果"查詢有內容但沒有任何結果時顯示的文字
composingTextstring"組字中"IME 組字中顯示在輸入框右側的指示 chip 文字
maxResultsnumber8最多顯示幾筆結果(先截斷再分組)
popoverbooleanfalse以浮層方式把結果面板疊在下方內容之上;預設走文件流,不會被 overflow: hidden 的父層裁掉
classNamestring透傳到最外層容器

ImeAwareSearchItem

欄位型別預設值說明
idstring唯一識別碼,作為 option 的 DOM id 與 React key
labelstring主要顯示文字,同時是比對與高亮的來源;被選取時會填進輸入框
descriptionstring次要說明文字(同樣參與比對與高亮)
groupstring分組名稱;相同分組的結果會集中在同一個標題下,順序依首次出現

另外具名匯出三個純函式,方便在自己的清單或伺服器端重用同一套規則: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 不用 statecompositionstart 之後緊接著的 input 事件就要讀到 true,state 會慢一拍
  • 查詢流程:一般打字 → 等 debounceMs → 查詢;組字中 → 只更新畫面;compositionend → 立刻查詢並取消排程中的 debounce。Safari/Firefox 在 compositionend 之後補發的 input 事件內容與剛查過的字相同,會被略過,不會打第二次
  • 請求順序:每次 onSearch 都帶遞增序號,只採用最後一次請求的回應,慢的舊回應不會蓋掉新結果;卸載時排程中的查詢會被取消
  • 比對與高亮:查詢以空白拆成多個關鍵字、統一小寫,每個關鍵字都要出現在 label/description/group 之一(AND);高亮用同一組關鍵字做大小寫不敏感的 split,正規表達式特殊字元會先跳脫
  • 選取:Enter 或點擊把 label 填進輸入框、收合面板並呼叫 onSelect;本地模式的高亮會對齊新文字但不重新查詢。Esc 第一下收合面板、第二下清空文字;Tab 收合並移走焦點
  • 快捷鍵hotkey 只在焦點不在 inputtextareaselect/contentEditable 上、且沒有按住修飾鍵時生效;輸入框為空且未聚焦時右側會顯示 kbd 提示
  • 面板定位:預設在文件流內、把下方內容往下推,不會被父層的 overflow: hidden 裁掉;需要疊在內容上時開 popover

可及性

  • 輸入框是 role="combobox",帶 aria-expandedaria-controlsaria-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 只淡入、讀取指示與骨架不再旋轉/脈衝

本頁目錄