WebberUI

IME-Aware Search (React)

A React search box that never fires queries or lets Enter mis-select while a Zhuyin/Pinyin IME is composing — queries wait until compositionend (normal typing is debounced), a composing-state chip, highlighted matches, a / hotkey to focus, and a copy-paste IME handling pattern for your own inputs.

Chinese-speaking users hit two problems with an ordinary search box: while a Zhuyin/Pinyin IME is composing, every keystroke fires another API call, and pressing Enter to pick a candidate submits the half-finished text. This combobox-style search box treats IME state as a first-class citizen — on compositionstart it only updates the display and schedules nothing, right after compositionend it queries the completed text once (regular typing goes through the debounceMs delay), and Enter only selects after checking both isComposing and keyCode 229. A "組字中" (composing) chip surfaces on the right of the input, the results panel below springs open and closed, the highlight bar slides between items, and matched keywords are wrapped in <mark> (case-insensitive, multiple whitespace-separated keywords). Results can come from a static items list filtered locally, or from onSearch() returning a Promise — a skeleton shows while loading and only the latest request's response is applied. Pressing / while focus is outside any input focuses the search box.

Loading preview…
npx shadcn@latest add https://webberui.com/r/ime-aware-search.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

200
8
<ImeAwareSearch />

Installation

npx shadcn@latest add https://webberui.com/r/ime-aware-search.json

Or, once registries are configured in components.json, install it as @webberui/ime-aware-search.

Usage

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: "咖啡豆" },
];

// Static list: filtered locally (label, description and group all take part in matching)
<ImeAwareSearch items={ITEMS} onSelect={(item) => console.log(item.id)} />;

// Hand it to a backend: onSearch is only called after composition ends and the debounce elapses; never for an empty string
<ImeAwareSearch
  onSearch={async (query) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    return (await res.json()) as ImeAwareSearchItem[];
  }}
  onSelect={(item) => console.log("selected", item.id)} // e.g. navigate to the product page here
  debounceMs={250}
  hotkey="/"
  placeholder="搜尋商品⋯"
/>;

// The highlighter works on your own lists too: 「鐵」 and 「茶」 get wrapped in <mark>
highlightMatches("鐵觀音奶茶", "鐵 茶");

Props

PropTypeDefaultDescription
itemsImeAwareSearchItem[]Static result list; when onSearch is not provided the component filters this list locally (an empty query lists everything, up to maxResults)
onSearch(query: string) => Promise<ImeAwareSearchItem[]> | ImeAwareSearchItem[]Query function returning an array or a Promise; takes precedence over items. Never called with an empty string, and only the latest request's response is applied
onSelect(item: ImeAwareSearchItem) => voidCalled when a result is picked with Enter or a click; the input is filled with that item's label at the same time
valuestringControlled query string; when omitted the component is uncontrolled
defaultValuestring""Initial query string in uncontrolled mode
onChange(value: string) => voidCalled whenever the query string changes; the in-progress text during composition is reported too, but it never triggers a query
debounceMsnumber200Query delay in milliseconds for regular typing; after compositionend the query fires immediately without waiting
placeholderstring"搜尋⋯" (Search…)Input placeholder, also used as the aria-label
hotkeystring | false"/"Global hotkey: pressing it while focus is outside any editable element focuses the search box; pass false to disable
emptyTextstring"找不到符合的結果" (No matching results)Text shown when the query has content but there are no results
composingTextstring"組字中" (Composing)Text of the indicator chip shown on the right of the input while the IME is composing
maxResultsnumber8Maximum number of results shown (truncated before grouping)
popoverbooleanfalseOverlay the results panel on top of the content below; by default it sits in the document flow, so it is never clipped by an overflow: hidden parent
classNamestringForwarded to the outermost container

ImeAwareSearchItem

FieldTypeDefaultDescription
idstringUnique identifier, used as the option's DOM id and React key
labelstringPrimary display text; it is also the source for matching and highlighting, and fills the input on selection
descriptionstringSecondary text (also matched and highlighted)
groupstringGroup name; results sharing a group are gathered under one heading, ordered by first appearance

Three pure functions are also named exports, so the same rules can be reused on your own lists or on the server: matchesQuery(item, query) (every keyword must appear in one of label / description / group), filterItems(items, query) (filters while preserving order), and highlightMatches(text, query) (returns an array of nodes ready to drop into JSX, with matched fragments wrapped in <mark>).

How it works

The IME handling pattern (copy it as is) — the core is one ref tracking composition state, plus a double check on Enter:

const composingRef = React.useRef(false);

<input
  onCompositionStart={() => {
    composingRef.current = true;
  }}
  onCompositionEnd={(e) => {
    composingRef.current = false;
    // Read the DOM value directly: in Firefox/Safari the input event arrives after
    // compositionend, so React state may still hold the in-progress text
    search(e.currentTarget.value); // query the completed text right away, no debounce
  }}
  onChange={(e) => {
    setValue(e.target.value); // while composing, only update the display
    if (composingRef.current) return; // do not schedule a query
    debouncedSearch(e.target.value);
  }}
  onKeyDown={(e) => {
    // Never submit on Enter while a CJK IME is composing; Safari fires keydown after
    // compositionend (isComposing is already false), so keyCode 229 must be checked too
    if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
    if (e.key === "Enter") selectActive();
  }}
/>;
  • Why a ref instead of state: the input event that immediately follows compositionstart must already read true; state would lag one render behind
  • Query flow: regular typing → wait debounceMs → query; while composing → display only; compositionend → query immediately and cancel any pending debounce. The extra input event Safari/Firefox emit after compositionend carries the same text that was just queried, so it is skipped rather than firing a second request
  • Request ordering: every onSearch call carries an incrementing sequence number and only the latest request's response is applied, so a slow stale response never overwrites fresh results; pending queries are cancelled on unmount
  • Matching and highlighting: the query is split on whitespace into keywords and lower-cased; every keyword must appear in one of label / description / group (AND). Highlighting uses the same keywords in a case-insensitive split, with regex special characters escaped first
  • Selection: Enter or a click fills the input with the label, closes the panel and calls onSelect; in local mode the highlight follows the new text without re-querying. Esc closes the panel first and clears the text on the second press; Tab closes and moves focus on
  • Hotkey: hotkey only fires when focus is not inside an input / textarea / select / contentEditable element and no modifier key is held; while the input is empty and unfocused a kbd hint shows on the right
  • Panel placement: by default the panel sits in the document flow and pushes the content below down, so a parent's overflow: hidden never clips it; turn on popover when it should float above the content instead

Accessibility

  • The input is role="combobox" with aria-expanded, aria-controls, aria-haspopup="listbox" and aria-autocomplete="list"; the currently highlighted result is referenced via aria-activedescendant, so focus always stays in the input and is never stolen by the results panel
  • The results panel is role="listbox"; each result is role="option" with aria-selected marking the highlight; when grouped, a role="group" with aria-labelledby points at the group heading
  • Keyboard: ↑ / ↓ cycle through results, Home / End jump to the first / last, Enter selects, Esc closes then clears, Tab closes; arrow keys are always passed through to the IME while it is composing
  • The result count, "searching" and empty-state text live in a role="status" aria-live="polite" region that is visually hidden and read only by screen readers; the composing chip and the hotkey hint are purely visual and marked aria-hidden
  • The clear button is type="button" with aria-label="清除搜尋" (clear search), supports keyboard use with a focus-visible ring, and keeps focus in the input after it is pressed
  • When the user has "reduce motion" enabled at the system level, the results panel only fades, the highlight bar jumps to its new position instead of sliding, the composing chip only fades in, and the loading indicator and skeleton stop spinning / pulsing

On this page