It owns the parts that are always rewritten and always come out slightly wrong: the debounce, the ordering, the cancellation of a stale pass, and the field wiring. Matching is fuzzy by default, through Fuse.js, and stays replaceable.
Type the typo "guardrials" - it still finds Convention guardrails. Watch the score column.
Customize
The typo is already in the box: with Fuse on it still finds Convention guardrails, and the score column shows how well. Turn Fuse off and watch it stop.
Installation
enigma add search installs fuse.js alongside the package, because a search primitive
without its engine is not much of one.
enigma add searchThen import from the /search entry, where Fuse is already wired:
import { createSearch } from "@enigmax/primitives/search";--no-deps adds the primitive and nothing else. The main entry has no engine, so you get
the built-in accent-insensitive substring matcher and no dependency at all.
enigma add search --no-depsimport { createSearch } from "@enigmax/primitives";It cannot forgive a typo - that is the trade. Everything else is identical.
enigma add installs with your project’s own package manager, not with npm: it reads
the packageManager field first, then looks for a lockfile, searching upwards so a
workspace package finds the one at the monorepo root. A pnpm project gets pnpm add, a bun
project bun add. Running npm inside one of those would write a second lockfile and leave
the tree in a state the project’s own tooling disagrees with.
Or do it by hand, in whichever you use:
Adds the package and prints the import to use. Append --copy to write the source into your project instead.
enigma add searchBrings fuse.js with it. --no-deps leaves them out.
Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react.
Fuse is what makes a typo still find the document. --no-deps leaves it out and the
built-in accent-insensitive substring matcher takes over.
Fuse is an optional peer dependency, not a real one, and it lives behind the /search
subpath. That is what keeps @enigmax/primitives dependency-free for someone who only
wanted the marquee: importing the main entry never pulls a search engine into the bundle.
Without the dependency
The same typo finds nothing here: a substring matcher cannot forgive one. But "cafe" still finds "Café config".
Or replace the engine entirely
createSearch({
items,
// Wins over `fuse`. Return them in the order you want them shown.
matcher: (query, items) => items
.filter(item => item.tags.includes(query))
.map(item => ({ item, score: 0 }))
});
That is the escape hatch for a server-side search, a trigram index, a language-aware collator, or anything else: the primitive keeps doing the debouncing and the wiring and stops having an opinion about matching.
React
import Fuse from "fuse.js";
import { useSearch } from "@enigmax/primitives/react";
export function DocSearch({ docs }) {
const { inputRef, results, query } = useSearch({
items: docs,
keys: ["title", "body"],
fuse: Fuse
});
return (
<>
<input ref={inputRef} type="search" aria-label="Search docs" />
<p>{query ? `${results.length} results` : "Type to search"}</p>
<ul>
{results.map(({ item, score }) => (
<li key={item.id}>{item.title} <small>{score.toFixed(2)}</small></li>
))}
</ul>
</>
);
}
Options
| Option | Default | Meaning |
|---|---|---|
items |
[] |
The collection to search. |
keys |
[] |
Fields to search. Dotted paths supported. |
fuse |
Fuse, via /search |
The constructor. From the main entry, omit it for the built-in matcher. |
fuseOptions |
- | Forwarded to Fuse verbatim. |
matcher |
- | Replaces the engine. Wins over fuse. |
debounce |
120 |
ms after the last keystroke. 0 searches on every one. |
minLength |
1 |
Shorter queries return nothing rather than everything. |
limit |
- | Cap the list. Applies to the empty-query state too. |
empty |
"none" |
What an empty query returns: "none" or "all". |
The instance exposes query, results, search(), searchNow(), setItems(), update(), attach(), subscribe() and destroy().
Details worth not rewriting
- A burst of keystrokes runs one search. A pending pass is dropped rather than queued, so a fast typist gets one pass over the data instead of one per character.
- New data re-runs the visible query. Results matching items that are gone are worse than no results.
- Fuse indexes on construction, so the engine is rebuilt when the items or keys change and never per keystroke.
- Escape clears the field first and stops there, instead of closing the dialog around it on the same press.
limitapplies to the “show everything” state too, which is the longest list of them all and the one most likely to hurt.