Search

Search as you type, fuzzy by default with Fuse.js. Debouncing, ranking and cancellation are the primitive's; the matching stays replaceable.

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.

fuzzy by default

Type to search. Escape clears.

    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

    pick one

    enigma add search installs fuse.js alongside the package, because a search primitive without its engine is not much of one.

    enigma add search

    Then import from the /search entry, where Fuse is already wired:

    import { createSearch } from "@enigmax/primitives/search";

    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 search

    Brings 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.

    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

    no engine, zero dependencies

    Type to search. Escape clears.

      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.
      • limit applies to the “show everything” state too, which is the longest list of them all and the one most likely to hurt.