@aiquants/select-box
v0.10.0
Published
High-performance select box component for React with virtual scrolling and fuzzy search
Readme
@aiquants/select-box
A high-performance React select box component with virtual scrolling, fuzzy search, multi-selection, and advanced multi-directional cross-script text normalization.
Features
- Virtual Scrolling: Built with
@aiquants/virtualscrollto handle 1,000+ options smoothly - Fuzzy Search: Built with
@aiquants/fuzzy-searchusing Web Workers for non-blocking search - Multi-Directional & Cross-Script Normalization: Supports full-width ↔ half-width, Hiragana ↔ Katakana, Romaji ↔ Kana, case-insensitive, and Japanese variant cross-matching out of the box
- Fail-Safe Match Classification: exact and substring matches are classified directly against every option, never through the fuzzy worker — a fuzzy-engine failure or debounce delay can only affect the fuzzy bucket, not deterministic matches
- Single & Multi Select: Unified API for both modes via the
multipleprop - Clearable: Optional clear button in both single- and multi-select mode; clearing emits
null(single) or[](multiple) - Resizable Dropdown: Drag the bottom-right handle to resize (bounds configurable); the resized height survives closing and reopening
- Search Highlighting & Score Gradient: Highlights exact matches (emerald green) and fuzzy matches (amber gradient scaled by similarity score)
- Search/Display Decoupling:
searchText/searchFieldssearch decorated labels correctly;renderOptiontakes over row rendering - Themeable & Dark Mode: Every color routes through
--sb-*CSS variables; dark palette keys off the.darkclass on<html> - Density & Row Height:
density="compact"preset or an explicitoptionHeightfor dense screens - Worker Isolation:
workerIdgives each instance its own fuzzy-search index (required when multiple SelectBoxes share a screen) - Full Keyboard Navigation: Arrow / Page / Home / End / Enter / Escape / Alt+Arrow, with scroll-follow that works at 1,500+ options — DOM focus never leaves the input
- IME-Safe: Every key handler stands down while a composition is in flight, so Japanese conversion (candidate window, Enter to confirm) is never intercepted
- WAI-ARIA Combobox:
role="combobox"+aria-expanded/aria-controls/aria-activedescendant, a namedrole="listbox"popup, per-row ids andaria-posinset/aria-setsizethat report the FULL set size rather than the rendered virtual window, plus a polite live region for the result count - Disabled Options:
option.disabledrendersaria-disabled, ignores clicks and is skipped by every navigation key - Localizable: built-in English (
"en", default) and Japanese ("ja") throughlocale; every built-in string (button names, empty message, live-region and selection texts, default placeholder, the resize-handle tooltip, the popup's scroll chrome) is overridable throughlabels— see Localization - TypeScript: Complete type definitions included
Installation
pnpm add @aiquants/select-boxPeer Dependencies
@aiquants/fuzzy-search and @aiquants/virtualscroll are mandatory — SelectBox renders its
list through virtualscroll and filters through fuzzy-search, so neither is optional. Install all
four:
pnpm add @aiquants/fuzzy-search @aiquants/virtualscroll react react-domInside this monorepo both @aiquants/* peers are declared as workspace:^ and resolve to the local
packages, so a root pnpm install is all you need.
select-box 0.10 requires @aiquants/virtualscroll 3.7.0 or later — every host, whether or not it
passes locale / labels. The package imports the engine's label catalog and resolvers when the
module loads, so on an older engine the import itself fails (ESM: missing named export; CJS: a
TypeError at load) and no SelectBox renders at all.
Quick Start
import { useState, useMemo } from "react"
import { SelectBox, type Option } from "@aiquants/select-box"
// Required: Vite environments need explicit Worker URLs
import indexWorkerUrl from "@aiquants/fuzzy-search/worker/indexWorker?url"
import levenshteinWorkerUrl from "@aiquants/fuzzy-search/worker/levenshteinWorker?url"
const options: Option[] = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Cherry", value: "cherry" },
]
function SingleSelect() {
const [value, setValue] = useState<Option | null>(null)
const workerUrls = useMemo(() => ({
indexWorker: indexWorkerUrl,
levenshteinWorker: levenshteinWorkerUrl,
}), [])
return (
<SelectBox
options={options}
value={value}
onChange={(val) => setValue(val as Option | null)}
placeholder="Select a fruit..."
workerUrls={workerUrls}
/>
)
}
function MultiSelect() {
const [values, setValues] = useState<Option[]>([])
const workerUrls = useMemo(() => ({
indexWorker: indexWorkerUrl,
levenshteinWorker: levenshteinWorkerUrl,
}), [])
return (
<SelectBox
options={options}
value={values}
onChange={(val) => setValues(Array.isArray(val) ? val : [])}
multiple
placeholder="Select multiple..."
workerUrls={workerUrls}
/>
)
}Multi-Directional & Cross-Script Normalization
@aiquants/select-box matches queries against option data even when character scripts or width styles differ:
// Querying "さくら" (Hiragana) matches "サクラ" (Katakana) & "サクラ" (Half-width Katakana)
// Querying "sakura" (Romaji) matches "サクラ" (Katakana) & "桜"
// Querying "10" (Full-width digits) matches "10" (Half-width digits)
<SelectBox
options={[
{ label: "10 | サクラ", value: "1" },
{ label: "20 | サクラ", value: "2" },
]}
normalizeOptions={{
convertKana: true, // Hiragana ↔ Katakana cross-matching
convertWidth: true, // Full-width ↔ Half-width normalization (NFKC)
convertRomaji: true, // Romaji ↔ Kana transliteration
}}
/>CSS Setup
The package ships two CSS artifacts; pick one per host. SelectBox builds on @aiquants/virtualscroll (and @aiquants/fuzzy-search), so the virtualscroll stylesheet is a peer CSS dependency — import it once alongside select-box.
Tailwind v4 host — import the components-only build in
layer(components), do the same for the virtualscroll peer build, and let your Tailwind build emit the JSX utilities from the package source:/* app tailwind.css */ @import "@aiquants/virtualscroll/styles/virtualscroll.css" layer(components); @import "@aiquants/select-box/styles/select-box.css" layer(components); @source "../node_modules/@aiquants/select-box/src/**/*.{ts,tsx}"; /* monorepo: @source "../../../../packages/select-box/src/**/*.{ts,tsx}"; */select-box.css(Artifact A) carries only the hand-written.sb-*classes (its@applyrules resolved tovar(--token, fallback)references) — no:roottheme variables, no Tailwind utilities, no preflight — and it does not embed the virtualscroll stylesheet. Do not also@importthe standalone build here: the duplicated utilities flip the base/variant cascade order.Non-Tailwind host — import the single self-contained standalone build; it bundles the
.sb-*classes, every JSX utility, and the virtualscroll peer CSS:@import "@aiquants/select-box/styles/select-box.standalone.css";Dark styles key off the
.darkclass on<html>.
Migrating to 0.7.0
0.7.0 turns the component into a real WAI-ARIA combobox. Three observable changes:
- The input's role is now
combobox, nottextbox. Update test locators:getByRole("textbox")→getByRole("combobox")(Testing Library, Playwright). - Option rows are no longer focusable. They lost
tabIndex={0}and theirEnter/Spacehandler, because the APG excludes a popup and its descendants from the tab sequence. Keyboard selection now happens on the input (ArrowDown…Enter), which is what the rows never reachably provided. Tests that tabbed into a row must drive the input instead. - Focusing the input no longer opens the popup. The APG is explicit that moving focus to a
combobox does not display its popup, and focus-driven opening re-entered badly (clearing the
selection hands focus back to the input, which reopened the popup it had just closed). The popup
now opens on pointer press, on typing, on
ArrowDown/ArrowUp/Alt+ArrowDown, and on the chevron — i.e. every path a user actually takes. OnlyautoFocuson mount behaves differently.
Everything else is additive. Give each instance an ariaLabel while you are there — see
Accessibility notes.
Localization
SelectBox renders eight strings of its own plus the scroll chrome of the dropdown's
VirtualScroll. All of them come from a frozen per-locale catalog, SELECT_BOX_LABEL_CATALOGS, in
English ("en", the default) and Japanese ("ja"):
| Key | Where it appears | en | ja |
| --- | --- | --- | --- |
| clear | clear button (aria-label) | Clear selection | 選択を解除 |
| toggle | chevron button (aria-label) | Toggle options | 候補を開閉 |
| removeTag | chip remove button (aria-label), (option) => string | Remove <label> | 「<label>」を削除 |
| noOptions | visible empty message of the open popup | No options found | 一致する候補がありません |
| status | polite live region, (info: SelectBoxStatusInfo) => string | "" while closed or searching; No options found; 1 option available; <n> options available | "" while closed or searching; 一致する候補がありません; <n> 件の候補があります |
| selection | selection description (aria-describedby), (selected: Option[]) => string | "" when none; Selected: <label>; <n> selected | "" when none; 選択中: <label>; <n> 件を選択中 |
| placeholder | input placeholder when the placeholder prop is omitted | Select... | 選択... |
| resizeHandle | SVG <title> of the popup's resize-handle glyph — the handle is aria-hidden, but browsers show the title as a native tooltip on hover | Resize handle | サイズ変更ハンドル |
| scrollUp / scrollDown | disabled arrow buttons of the popup's vertical scrollbar (aria-label) | Scroll up / Scroll down | 上へスクロール / 下へスクロール |
| scrollLeft / scrollRight / scrollToTop / scrollToBottom / noItems | never rendered by SelectBox (accepted for a uniform vocabulary) | from @aiquants/virtualscroll | from @aiquants/virtualscroll |
The seven engine keys (scrollUp … noItems) are VIRTUAL_SCROLL_LABEL_CATALOGS[locale] of
@aiquants/virtualscroll, reused by value — one source of truth for the scroll chrome.
Pick the language with locale and override single keys with labels:
<SelectBox
options={options}
locale="ja"
labels={{ noOptions: '該当なし', removeTag: (option) => `「${option.label}」を外す` }}
/>- Omitting
localerenders English; the DOM is byte-identical tolocale="en". localeandlabelsare resolved at the top of every render, so a bad value fails at mount, not on the first popup open.SelectBoxforwards the resolvedlocaleand the seven engine keys to the dropdown'sVirtualScroll(value-stable across renders, so an inlinelabels={{ … }}does not make the engine re-resolve).- Fail-fast (
RangeError): an unsupportedlocale("EN","ja-JP","fr","",null, ...);labelsthat is not a plain object (its prototype must beObject.prototypeornull— arrays, class instances andObject.create(proto)objects are rejected); an unknown key; a presentclear/toggle/noOptions/placeholder/resizeHandleor engine key that is not a string with non-whitespace content; a presentremoveTag/status/selectionthat is not a function. Anundefinedvalue keeps the catalog value. Only own properties are read. Messages describe the rejected value without stringifying it (got "ja-JP",got null,got number). statusandselectionmay legitimately return"": an emptystatuskeeps the live region silent, an emptyselectiondropsaria-describedby.- The
placeholderprop is per-instance content and wins over theplaceholderlabel. - No language negotiation: the package never reads
navigator.language. Map it yourself to a supported value (the list isVIRTUAL_SCROLL_LOCALESof@aiquants/virtualscroll), and set the document language (<html lang>) yourself — nolangattribute is stamped. localeis not a formatting locale. It selects the UI chrome language only. A BCP 47 tag such as"ja-JP"(whatIntlAPIs take, sometimes calledformatLocaleelsewhere) is a different axis and is rejected here.
Exported API: SELECT_BOX_LABEL_KEYS (the 15 keys: the 7 engine keys, then clear, toggle,
removeTag, noOptions, status, selection, placeholder, resizeHandle), SELECT_BOX_LABEL_CATALOGS (the
frozen catalogs), resolveSelectBoxLabels(locale, labels) (the effective frozen labels — the catalog
object itself when labels is undefined), and the types SelectBoxLocale, SelectBoxLabels and
SelectBoxLabelOverrides.
Migrating from 0.9 to 0.10
Breaking changes
textsis replaced bylocale+labels(clean cut-over, no alias). Thetextsprop, theSelectBoxTextstype and theDEFAULT_SELECT_BOX_TEXTSvalue are gone.labelskeeps the same member names and signatures (clear,toggle,removeTag(option),noOptions,status(info),selection(selected)) and addsplaceholder,resizeHandleand the seven scroll-chrome keys of@aiquants/virtualscroll.labelsis validated at mount (RangeError). In 0.9,textsfilled each member with??: anullmember or a misspelled key fell back to English silently, a""/ whitespace string was rendered as-is (an empty accessible name or a blank empty message), and a non-functionremoveTag/status/selectionthrew aTypeErroronly when it was called during render. All of these now throw aRangeErrorat mount that names the key: a non-plain object, an unknown key,null/""/ whitespace for a string key, or a non-function for a function key. Omit a member (or passundefined) to keep the catalog value.@aiquants/virtualscroll3.7.0 or later is required by the whole package, not only by hosts that uselocale/labels(see Peer Dependencies). Upgrade the engine together with select-box.- Hosts that pinned
^0.9.xmust bump to^0.10.0explicitly (a 0.x minor is outside the caret range).
New API: the locale and labels props, SELECT_BOX_LABEL_KEYS, SELECT_BOX_LABEL_CATALOGS,
resolveSelectBoxLabels, and the types SelectBoxLocale, SelectBoxLabels,
SelectBoxLabelOverrides (all described above).
Steps
// 0.9
<SelectBox texts={{ noOptions: '該当なし', status: myStatus }} />
// 0.10
<SelectBox locale="ja" labels={{ noOptions: '該当なし', status: myStatus }} />- Japanese UI: opt in with
locale="ja". The default chrome language is English (unchanged from 0.9), so a host that rewrote strings in Japanese throughtextsshould passlocale="ja"and keep only the overrides whose wording differs from thejacatalog.locale="ja"also translates the stringstextscould never reach: the default placeholder, the resize-handle tooltip and the popup's scroll-chrome button names. - Rename
textstolabelsand drop members that werenull/""/ whitespace (see item 2). - Code that spread
DEFAULT_SELECT_BOX_TEXTSreadsSELECT_BOX_LABEL_CATALOGS.en(frozen) instead. localeis notformatLocale. Pass"en"/"ja", never a BCP 47 tag such as"ja-JP"; select-box has no formatting locale (packages that do call itformatLocale, a separate axis).- Hosts that omit
localesee a DOM byte-identical to 0.9.2 (theencatalog reuses the previous English literals exactly).
API Reference
SelectBox Props
| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| options | Option[] | Yes | - | Array of selectable options |
| value | Option \| Option[] \| null | No | undefined | Current selection (controlled); null round-trips from onChange |
| onChange | (value: Option \| Option[] \| null) => void | No | undefined | Selection change callback (null on single-select clear) |
| placeholder | string | No | the placeholder label of locale ("Select..." / "選択...") | Per-instance placeholder shown when nothing is selected; takes precedence over the catalog default (an explicit "" is kept). Only this prop — never the catalog default — names the listbox when ariaLabel is absent |
| multiple | boolean | No | false | Enable multi-select mode |
| className | string | No | "" | Additional CSS class for the container |
| disabled | boolean | No | false | Disable the component |
| clearable | boolean | No | false | Show a clear button whenever at least one value is selected and the component is not disabled; clicking emits null (single) or [] (multiple), resets the query and closes the popup |
| autoFocus | boolean | No | false | Auto-focus the input on mount |
| enableHighlight | boolean | No | true | Enable/disable search result highlighting and score gradients |
| normalizeOptions | NormalizeOptions | No | DEFAULT_NORMALIZE_OPTIONS (exported) | Multi-directional text normalization settings (case, width, kana, romaji, variants) |
| workerUrls | { indexWorker?: string; levenshteinWorker?: string } | No | undefined | Explicit Web Worker URLs (see below) |
| workerId | string | No | undefined | Isolation key for the fuzzy-search worker/index. Give every SelectBox on the same screen a distinct value — instances sharing the default worker evict each other's index on every dataset switch |
| searchText | (option: Option) => string | No | undefined | Derives the search-target string per option, decoupling search from label decoration (padding, separators, units). Unset searches label |
| searchFields | string[] | No | undefined | Additional option fields to search besides the base target |
| threshold | number | No | 0.3 | Fuzzy-match similarity threshold (0.0-1.0). Only affects the fuzzy bucket — exact and substring matches are classified directly and never filtered by it |
| sortBy | "score" \| "relevance" | No | "score" | Scoring mode of the fuzzy engine for the fuzzy bucket; "relevance" adds matched-field-count weighting |
| renderOption | (option, ctx: { query; score; matchType }) => ReactNode | No | undefined | Custom row renderer; replaces the default label/highlight rendering entirely |
| showMatchScore | boolean | No | false | Show each row's match category and score as a subtle badge (search-tuning aid); only rendered while a query is active |
| optionHeight | number | No | 36 (28 compact) | Dropdown row height in px, applied to both the virtual-scroll math and the row elements |
| density | "comfortable" \| "compact" | No | "comfortable" | Display density preset; compact shrinks rows to 28px and text to text-xs |
| id | string | No | undefined (the input gets no id; generated ids fall back to an sb-<useId> prefix) | DOM id of the input, applied only when supplied. Every generated id — <base>-listbox, <base>-option-<index>, <base>-selection — uses <base> = this prop when given, else sb-<useId>. Supply it for deterministic ids in tests and to wire a <label htmlFor> |
| name | string | No | undefined | Form field name. The selection is submitted through hidden inputs — one per selected value under multiple, a single empty one when nothing is selected. The search field deliberately carries no name, so a submit never sends the raw query (or an empty string after a single-select commit) |
| ariaLabel | string | No | undefined | Accessible name applied to both the combobox and its listbox popup. ARIA requires each to be named independently — a <label> attached to the input does not name the popup |
| ariaLabelledBy | string | No | undefined | Same as ariaLabel, by id reference; takes precedence |
| autoHighlight | boolean | No | false | Activate the best match while typing so Enter accepts it. Default false follows the APG (typing returns visual focus to the textbox) and keeps Enter available for form submission |
| loopNavigation | boolean | No | true | Wrap Arrow navigation at the list ends |
| dropdownHeight | number | No | 300 | Initial popup height in px. A drag-resize now survives closing and reopening |
| minDropdownHeight | number | No | 100 | Lower bound of the resize drag |
| maxDropdownHeight | number | No | 600 | Upper bound of the resize drag |
| locale | SelectBoxLocale ("en" \| "ja") | No | "en" | UI chrome language of every built-in string, including the scroll chrome of the popup's VirtualScroll. An unsupported value throws a RangeError at mount. See Localization |
| labels | SelectBoxLabelOverrides | No | undefined | Per-key overrides laid over the catalog of locale. Validated at mount (RangeError on an unknown key, a blank string, or a non-function for removeTag / status / selection). See Localization |
| onActiveIndexChange | (index: number, option: Option \| null) => void | No | undefined | Fires whenever the keyboard-highlighted row changes (-1 when nothing is highlighted) |
| onOpenChange | (isOpen: boolean) => void | No | undefined | Fires whenever the popup opens or closes |
Keyboard
DOM focus stays on the text input at all times; the highlighted row is published through
aria-activedescendant (the WAI-ARIA APG "combobox with list autocomplete" pattern). Every handler
stands down while an IME composition is in flight.
| Key | Popup closed | Popup open |
| --- | --- | --- |
| ArrowDown | Opens, highlighting the current selection if it is in the list | Next option (wraps unless loopNavigation={false}) |
| ArrowUp | Opens, highlighting the current selection | Previous option (wraps) |
| Alt+ArrowDown | Opens without highlighting anything | – |
| Alt+ArrowUp | – | Closes |
| PageDown / PageUp | (browser default) | ± one viewport of rows, clamped at the ends |
| Home / End | (caret) | First / last option when the query is empty; otherwise the native caret move is preserved (APG's editable-combobox behavior) |
| Ctrl/Cmd+Home / End | (browser default) | First / last option, regardless of the query |
| Enter | (browser default — form submit still works) | Commits the highlighted row; with nothing highlighted the key is not intercepted |
| Escape | Not intercepted, so a parent dialog still receives it | Closes without committing |
| Tab | – | Closes without committing, focus moves on |
| Backspace | Removes the last chip when multiple and the query is empty | Same — the branch is not gated on the popup state |
Wrap-around follows the APG reference example; the pattern text permits either behavior.
PageDown / PageUp are a documented extension — the APG lists paging keys only for grid popups.
Escape carries no query-clearing branch because it cannot be reached: every path that closes the
popup also clears the query, so isOpen === false && query !== "" is not a host-reachable state.
Mouse wheel
When the list is taller than the popup, a vertical wheel over it stays inside the popup even at the
edges: once the list has reached its last (or first) row, further notches are consumed instead of
being chained to the page, so an open, scrollable popup stays under its input on a page that overflows
vertically. This is the virtualscroll overscrollBehavior: "contain" policy, fixed by the
package (virtualscroll >= 2.7.0 defaults to chaining). It applies only to the list's own scroll area:
a list that fits inside the popup (8 rows or fewer at the default 36px rows / 300px popup, 10 at
density="compact"), the empty "no options" popup and the resize-handle corner are not scroll
targets, so a wheel there reaches the page. Horizontal and Shift+wheel
gestures are never intercepted. A host-side .sb-dropdown { overscroll-behavior: contain } rule is
broader than this policy: Chromium cuts its wheel scroll chain at any scroll container whose
overscroll-behavior is not auto — an overflow: hidden box included — so there such a rule also
stops the fitting-list, empty-popup and resize-corner cases. This package does not test that rule and
other engines are not verified; keep such a rule if you rely on those cases.
Accessibility notes
- Give every instance an
ariaLabel(orariaLabelledBy). ARIA requires an accessible name on the combobox and on the listbox, and the popup is not covered by a label attached to the input. - Option rows are deliberately not focusable and carry no
tabIndex: the APG excludes a popup and its descendants from the page tab sequence. The chevron istabIndex={-1}for the same reason. aria-posinset/aria-setsizecarry the absolute position and the full option count, not the rendered virtual window, so a screen reader announces "item 900 of 1,552" correctly.- The
role="listbox"sits on the virtual scroller's content element (via@aiquants/virtualscroll'scontentProps), not on a wrapper around it. That element owns exactly the rendered rows; the custom scrollbar and the resize handle are its siblings, so the listbox owns nothing butoptions. The declared peer range of@aiquants/virtualscrollinpackage.jsonalready guarantees this.
Option Type
interface Option {
label: string // Display text (default search target; override with searchText/searchFields)
value: string | number // Unique identifier
disabled?: boolean // Non-selectable: aria-disabled, ignores clicks, skipped by navigation keys
[key: string]: any // Custom metadata
}While a query is active the engine rebuilds every option, attaching the internal fields _searchText, _normalizedSearchText, _score (match score) and _matchType ("exact" | "partial" | "fuzzy" ranking category — the non-"none" members of the exported MatchCategory). All four are engine-internal and never reach onChange — useSelectBox's toEmittableOption strips them before emitting, so the value your handler receives always carries exactly the keys you supplied, whether or not the user typed.
One consequence: when a query was active, the emitted object is a new object rather than the one you passed in (it is your own object only when nothing was typed). Compare selections by value, never by reference.
Rows still publish their score and category to renderers through renderOption's second argument (RenderOptionContext), which is the supported seam. Read matchType there rather than decoding score: the 0.9 partial sentinel can numerically collide with genuine fuzzy scores.
Decorated Labels (code + name)
Searching a decorated label like "10 | 桜" breaks: the | token matches every option, while a name-only query can miss entirely. Decouple display from search instead:
<SelectBox
options={options} // { label, value, code, name }
searchText={(o) => `${o.code} ${o.name}`}
renderOption={(o) => (
<span style={{ display: "grid", gridTemplateColumns: "6ch 1fr", gap: "0.5rem" }}>
<span>{o.code}</span>
<span style={{ borderLeft: "1px solid var(--sb-border)", paddingLeft: "0.5rem" }}>{o.name}</span>
</span>
)}
/>Theming
Every color and the component font size resolve through --sb-* CSS custom properties declared on .sb-container, defaulting to the light palette (each default chains to the host's Tailwind token, e.g. var(--color-gray-300, oklch(…))). Override any subset on a wrapper or on .sb-container itself:
.my-app .sb-container {
--sb-bg: #101828;
--sb-fg: #f3f4f6;
--sb-border: #364153;
--sb-font-size: 0.75rem;
}Key variables: --sb-bg, --sb-fg, --sb-border, --sb-border-hover, --sb-focus, --sb-placeholder, --sb-icon, --sb-icon-hover, --sb-disabled-bg, --sb-tag-bg, --sb-tag-border, --sb-option-hover-bg, --sb-option-focused-bg, --sb-option-selected-bg, --sb-option-selected-fg, --sb-highlight-exact-bg, --sb-highlight-exact-fg, --sb-highlight-fuzzy-bg, --sb-highlight-fuzzy-fg, --sb-font-size, --sb-line-height (full inventory in src/styles/select-box.css).
Dark mode ships as a .dark .sb-container variable block in both CSS artifacts, keyed off the .dark class on <html>. There is deliberately no @media (prefers-color-scheme) rule — it would auto-darken light-only apps for OS-dark users; hosts wanting OS-driven switching map the variables themselves.
Web Worker URLs (Important)
The fuzzy search uses Web Workers internally. In Vite development mode, the default import.meta.url-based Worker resolution may fail, causing errors like:
[fuzzy-search] Index Worker Error
[fuzzy-search] Index Worker: Ping timeout
[fuzzy-search] Failed to rebuild index: Error: Index build timeoutTo fix this, pass workerUrls with Vite's ?url import:
import indexWorkerUrl from "@aiquants/fuzzy-search/worker/indexWorker?url"
import levenshteinWorkerUrl from "@aiquants/fuzzy-search/worker/levenshteinWorker?url"
<SelectBox
options={options}
workerUrls={{
indexWorker: indexWorkerUrl,
levenshteinWorker: levenshteinWorkerUrl,
}}
/>Dependencies
| Package | Purpose |
| --- | --- |
| @aiquants/fuzzy-search | Web Worker-based fuzzy search engine |
| @aiquants/virtualscroll | Virtual scrolling for large lists |
Documentation
See the docs/specs/ directory for detailed specifications.
License
MIT
