@molecule/app-client-search-react

Feature · client-search · App (browser) · v1.0.3 · Apache-2.0

React hooks for @molecule/app-client-search — build an index from a list, search it as the person types, and move through the hits with the keyboard

npm install @molecule/app-client-search-react

npm · Source on GitHub

How it works

@molecule/app-client-search-react is a ready-made client-search feature for the app (browser) side. It composes the core interfaces it needs, so it works with whichever providers your app has bonded.

import {
  useClientSearch,
  useClientSearchIndex,
  useListNavigation,
} from '@molecule/app-client-search-react'

function PackageSearch({ packages }: { packages: Pkg[] }) {
  const index = useClientSearchIndex(packages, {
    idField: 'name',
    fields: ['name', 'description', 'exports'],
    filterFields: ['category', 'type'],
    boost: { name: 3 },
  })
  const { query, setQuery, hits, active } = useClientSearch(index, { limit: 50 })
  const nav = useListNavigation({
    count: hits.length,
    onSelect: (i) => navigate(`/packages/${hits[i].id}`),
    onEscape: () => setQuery(''),
  })
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={nav.onKeyDown} />
      <ul>
        {hits.map((h, i) => (
          <li key={h.id} aria-selected={i === nav.activeIndex}>
            {h.doc.name}
          </li>
        ))}
      </ul>
    </>
  )
}

Works with: @molecule/app-client-search

Reference

React hooks for @molecule/app-client-search.

Three headless hooks: useClientSearchIndex builds the index from a list, useClientSearch keeps an input's text and its hits, and useListNavigation moves through the hits with the keyboard. The app renders the input and the list itself, styled with getClassMap().

Quick Start

import {
  useClientSearch,
  useClientSearchIndex,
  useListNavigation,
} from '@molecule/app-client-search-react'

function PackageSearch({ packages }: { packages: Pkg[] }) {
  const index = useClientSearchIndex(packages, {
    idField: 'name',
    fields: ['name', 'description', 'exports'],
    filterFields: ['category', 'type'],
    boost: { name: 3 },
  })
  const { query, setQuery, hits, active } = useClientSearch(index, { limit: 50 })
  const nav = useListNavigation({
    count: hits.length,
    onSelect: (i) => navigate(`/packages/${hits[i].id}`),
    onEscape: () => setQuery(''),
  })
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={nav.onKeyDown} />
      <ul>
        {hits.map((h, i) => (
          <li key={h.id} aria-selected={i === nav.activeIndex}>
            {h.doc.name}
          </li>
        ))}
      </ul>
    </>
  )
}

Type

feature

Installation

npm install @molecule/app-client-search-react @molecule/app-client-search @molecule/app-storage react
npm install -D @types/react

API

Interfaces

ClientSearchState

What useClientSearch returns.

interface ClientSearchState<T extends ClientSearchDocument> {
  /** The text as typed. Bind it to the input's `value`. */
  query: string

  /** Sets the text. Bind it to the input's `onChange`. */
  setQuery: (query: string) => void

  /** The text in its parsed form (terms, phrases, filters, exclusions). */
  parsed: ParsedQuery

  /** The hits for the current (debounced) text; every document when the text is empty. */
  hits: ClientSearchHit<T>[]

  /** Whether the text is non-blank. */
  active: boolean

  /** Clears the text. */
  clear: () => void
}

HashQuery

What the address bar's hash says about the search.

interface HashQuery {
  /** The query, or `''`. */
  query: string

  /**
   * The hash was a plain anchor (`#auth`), read as `<legacyField>:auth`; the
   * caller may rewrite it to the `#q=` form.
   */
  legacy: boolean
}

ListNavigation

What useListNavigation returns.

interface ListNavigation {
  /** The active item, or `-1`. */
  activeIndex: number

  /** Sets the active item (e.g. on pointer hover). */
  setActiveIndex: (index: number) => void

  /** Attach to the input (or the list) as `onKeyDown`. */
  onKeyDown: (event: ReactKeyboardEvent) => void

  /** Back to no active item. */
  reset: () => void
}

ReadHashQueryOptions

Options for readHashQuery.

interface ReadHashQueryOptions {
  /**
   * A field to turn a plain anchor into a filter for: with `'category'`,
   * `#auth` reads as `category:auth`. Without it a plain anchor is not a query.
   */
  legacyField?: string
}

SearchSession

Everything a search box and a results list share.

interface SearchSession<T extends ClientSearchDocument> {
  /** Attach to the input. */
  inputRef: RefObject<HTMLInputElement | null>
  query: string
  setQuery: (query: string) => void
  clear: () => void
  parsed: ParsedQuery
  hits: SessionHit<T>[]
  /** Anything typed at all (text, a phrase, or a filter). */
  active: boolean
  /** Free text or a phrase is present, so hits are ranked rather than browsed. */
  ranked: boolean
  activeIndex: number
  setActiveIndex: (index: number) => void
  /** Attach to the input as `onKeyDown`. */
  onKeyDown: (event: ReactKeyboardEvent) => void
  /** Removes the i-th filter from the text. */
  removeFilter: (index: number) => void
  /** Opens a hit and commits the search to history. */
  open: (doc: T) => void
  prefetch: (doc: T) => void
  /** Focuses the input and selects its text. */
  focus: () => void
}

SearchSessionConfig

What useSearchSession needs to know.

interface SearchSessionConfig<T extends ClientSearchDocument> {
  /** The documents, or `null` until they exist. Keep the array identity stable. */
  docs: T[] | null

  /** How to index them. Keep the object identity stable (a module constant). */
  options: ClientSearchIndexOptions<T>

  /** Maximum hits. Defaults to `60`. */
  limit?: number

  /**
   * Keep the text in the URL hash (`#q=…`) and read it back on load and on
   * Back/Forward. Defaults to `true`.
   */
  syncUrl?: boolean

  /** The field a plain `#anchor` in the URL stands for (`'category'` makes `#auth` mean `category:auth`). */
  legacyHashField?: string

  /**
   * Share the text between pages through the bonded storage under this key,
   * for `rememberMs`. Omit to keep nothing.
   */
  rememberKey?: string

  /** How long a remembered text is offered back. Defaults to 30 minutes. */
  rememberMs?: number

  /** Opens a hit (Enter, or a click on its row). */
  onOpen: (doc: T) => void

  /** Warms a hit's page ahead of time (hover, focus, and the top hits once results settle). */
  prefetch?: (doc: T) => void

  /** Escape with nothing typed. Defaults to blurring the input; a dialog closes instead. */
  onEscape?: () => void

  /**
   * More hits for the current query from elsewhere (an API, a semantic
   * index): called after typing pauses, its results are appended after the
   * index's own hits, marked `related`; ids already present are skipped.
   * Return nothing for no extra hits.
   */
  augment?: (parsed: ParsedQuery, signal: AbortSignal) => Promise<T[] | undefined | void>

  /** Milliseconds to wait after typing before calling `augment`. Defaults to `250`. */
  augmentDelayMs?: number

  /** Only call `augment` when this returns `true` for the query. Defaults to "two or more words, or a phrase". */
  augmentWhen?: (parsed: ParsedQuery) => boolean
}

UseClientSearchOptions

Options for useClientSearch.

interface UseClientSearchOptions {
  /** The text to start with (e.g. from the URL). */
  initialQuery?: string

  /** Maximum hits. Defaults to `50`. */
  limit?: number

  /**
   * Milliseconds to wait after the last keystroke before searching. `0` (the
   * default) searches synchronously on every change, which an in-memory index
   * handles comfortably; raise it only for very large lists.
   */
  debounceMs?: number
}

UseListNavigationOptions

Options for useListNavigation.

interface UseListNavigationOptions {
  /** How many items there are. The active index is clamped to it. */
  count: number

  /** Called with the active index on Enter. */
  onSelect?: (index: number) => void

  /** Called on Escape (e.g. clear the input or close the panel). */
  onEscape?: () => void

  /** Wrap from the last item to the first and back. Defaults to `true`. */
  loop?: boolean

  /** The index active before any key is pressed. `-1` (the default) means none. */
  initialIndex?: number
}

Types

SessionHit

A hit, possibly one an augmenting search added.

type SessionHit<T extends ClientSearchDocument> = ClientSearchHit<T> & {
  /** Found by an augmenting search (for example by meaning) rather than by the words typed. */
  related?: boolean
}

Functions

hashForQuery(query)

The hash for a query.

function hashForQuery(query: string): string
  • query — The text.

Returns: #q=…, or '' for blank text.

readHashQuery(hash, options)

Reads the search from a hash: #q=… is the query as typed (URL-encoded).

function readHashQuery(hash?: string, options?: ReadHashQueryOptions): HashQuery
  • hash — The hash, with or without #. Defaults to the address bar's.
  • options — How to treat a plain anchor.

Returns: The query and whether it came from a plain anchor.

useClientSearch(index, options)

Keeps an input's text and the hits for it. With no index yet the hits are empty; with an index and blank text they are every document (browse mode), so one list renders both states.

function useClientSearch(
  index: ClientSearchIndex<T> | null,
  options?: UseClientSearchOptions,
): ClientSearchState<T>
  • index — The index from useClientSearchIndex, or null.
  • options — Initial text, limit, debounce.

Returns: The search state.

useClientSearchIndex(docs, options)

Indexes docs with the bonded provider. The index is rebuilt only when the docs array identity changes (memoize the list, or pass it from state), and is null until the list exists or while no provider is bonded.

function useClientSearchIndex(
  docs: T[] | null | undefined,
  options: ClientSearchIndexOptions<T>,
): ClientSearchIndex<T> | null
  • docs — The documents to index, or nothing yet.
  • options — How to index them. Read once per rebuild; keep it stable.

Returns: The index, or null.

useLazyDocs(initial, load)

Starts with initial (records the page already bundles, so the first keystroke always has something to search) and switches to the result of load once it resolves, during idle time. A failed load keeps initial and logs at debug level; the page reports nothing.

function useLazyDocs(initial: T[], load: () => Promise<T[]>): T[]
  • initial — The records available at once. Keep the array identity stable.
  • load — Loads the fuller set. Called once per initial.

Returns: The current documents.

useListNavigation(options)

Arrow keys move the active item, Home/End jump, Enter selects it, Escape calls back. The index resets when the list length changes so a stale highlight never outlives its results.

function useListNavigation(options: UseListNavigationOptions): ListNavigation
  • options — The list size and callbacks.

Returns: The active index and the key handler.

useSearchSession(config)

The search state for one list, wired to the address bar and the keyboard. The text starts from #q=, then from what was last typed under rememberKey; every change is mirrored back to both after typing pauses. Typing after a committed search first pushes a history entry, then rewrites it, so Back returns to the previous search rather than to each keystroke.

function useSearchSession(config: SearchSessionConfig<T>): SearchSession<T>
  • config — The documents, index options, and callbacks.

Returns: The session.

writeHashQuery(query, mode)

Puts the query in the hash without scrolling. replace (the default) rewrites the current history entry, for typing; push adds one, for a committed search, so Back returns to the previous search.

function writeHashQuery(query: string, mode?: 'replace' | 'push'): void
  • query — The text.
  • mode — replace or push.

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-client-search ^1.0.0
  • @molecule/app-storage ^1.0.1
  • react ^18.0.0 || ^19.0.0

Runtime Dependencies

  • @molecule/app-client-search

  • @molecule/app-storage

  • react

  • Pass a stable list. useClientSearchIndex rebuilds only when the docs array identity changes; a list recreated on every render (.filter in the render body) rebuilds the index on every keystroke. Keep it in state or useMemo it.

  • Blank text returns every document, so the same list renders the browse state and the search state; there is no separate "no query" path to write. Check active when the empty state should look different.

  • useListNavigation resets the highlight when the hit count changes and ignores keys with modifiers, so Cmd+Enter and friends stay free for the app. Wire Enter to real navigation (the URL changes), not just a highlight.

  • useSearchSession is the whole thing wired up: text ↔ #q= in the address bar, a remembered query across pages (through the bonded storage), keyboard navigation, Enter opening a hit, top hits prefetched, and an optional augment that appends hits from elsewhere (an API) as "related".

  • These hooks render nothing and register no global key. Bind / or Cmd+K to focus the input through @molecule/app-keyboard-shortcuts-react.