@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-reactHow 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 fromuseClientSearchIndex, ornull.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 perinitial.
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—replaceorpush.
Injection Notes
Requirements
Peer dependencies:
@molecule/app-client-search^1.0.0@molecule/app-storage^1.0.1react^18.0.0 || ^19.0.0
Runtime Dependencies
-
@molecule/app-client-search -
@molecule/app-storage -
react -
Pass a stable list.
useClientSearchIndexrebuilds only when thedocsarray identity changes; a list recreated on every render (.filterin the render body) rebuilds the index on every keystroke. Keep it in state oruseMemoit. -
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
activewhen the empty state should look different. -
useListNavigationresets the highlight when the hit count changes and ignores keys with modifiers, soCmd+Enterand friends stay free for the app. WireEnterto real navigation (the URL changes), not just a highlight. -
useSearchSessionis 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 optionalaugmentthat appends hits from elsewhere (an API) as "related". -
These hooks render nothing and register no global key. Bind
/orCmd+Kto focus the input through@molecule/app-keyboard-shortcuts-react.