@molecule/app-search-ui-react

Feature · search-ui · App (browser) · v1.0.2 · Apache-2.0

React search UI for @molecule/app-client-search — a search box with filter chips and tips, a ranked results list with matched terms marked, and a quick-search dialog; styled through the ClassMap

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

npm · Source on GitHub

How it works

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

import { useSearchSession } from '@molecule/app-client-search-react'
import { SearchBox, SearchResults } from '@molecule/app-search-ui-react'
import { useTranslation } from '@molecule/app-react'

function Catalog({ packages }: { packages: Pkg[] }) {
  const navigate = useNavigate()
  const { t } = useTranslation()
  const session = useSearchSession({
    docs: packages,
    options: PACKAGE_INDEX_OPTIONS,
    legacyHashField: 'category',
    rememberKey: 'catalog.search',
    onOpen: (p) => navigate(`/packages/${p.name}`),
  })
  return (
    <>
      <SearchBox
        value={session.query}
        onChange={session.setQuery}
        onKeyDown={session.onKeyDown}
        inputRef={session.inputRef}
        placeholder={t('catalog.search', undefined, { defaultValue: 'Search packages' })}
        count={session.hits.length}
        filters={session.parsed.filters}
        onRemoveFilter={session.removeFilter}
        onClear={session.clear}
        examples={['category:auth', 'type:bond']}
      />
      <SearchResults
        hits={session.hits}
        view={(p) => ({
          href: `/packages/${p.name}`,
          title: `@molecule/${p.name}`,
          description: p.description,
          meta: [p.type, p.category],
          mono: true,
        })}
        activeIndex={session.activeIndex}
        onActivate={session.setActiveIndex}
        onOpen={session.open}
        onPrefetch={session.prefetch}
        renderLink={(p) => (
          <Link
            to={p.href}
            state={p.state}
            className={p.className}
            onClick={p.onClick}
            data-mol-id={p['data-mol-id']}
          >
            {p.children}
          </Link>
        )}
        emptyText={t('catalog.empty', undefined, { defaultValue: 'Nothing matches' })}
      />
    </>
  )
}

Works with: @molecule/app-client-search, @molecule/app-client-search-react, @molecule/app-react, @molecule/app-ui, @molecule/app-ui-react

Reference

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

Three pieces that fit a useSearchSession from @molecule/app-client-search-react: SearchBox (the field, its clear button and shortcut cap, a help icon whose tooltip shows the search syntax, an afterField slot for the consumer's own tabs, the active filter chips and the hit count), SearchResults (ranked rows with the matched terms marked, keyboard-active row, hover prefetch, an optional action per row), and QuickSearchDialog (both in a mod+k dialog). Every class comes from the ClassMap's search tokens (searchField, resultList, …), so a UI bond restyles all of it at once.

Quick Start

import { useSearchSession } from '@molecule/app-client-search-react'
import { SearchBox, SearchResults } from '@molecule/app-search-ui-react'
import { useTranslation } from '@molecule/app-react'

function Catalog({ packages }: { packages: Pkg[] }) {
  const navigate = useNavigate()
  const { t } = useTranslation()
  const session = useSearchSession({
    docs: packages,
    options: PACKAGE_INDEX_OPTIONS,
    legacyHashField: 'category',
    rememberKey: 'catalog.search',
    onOpen: (p) => navigate(`/packages/${p.name}`),
  })
  return (
    <>
      <SearchBox
        value={session.query}
        onChange={session.setQuery}
        onKeyDown={session.onKeyDown}
        inputRef={session.inputRef}
        placeholder={t('catalog.search', undefined, { defaultValue: 'Search packages' })}
        count={session.hits.length}
        filters={session.parsed.filters}
        onRemoveFilter={session.removeFilter}
        onClear={session.clear}
        examples={['category:auth', 'type:bond']}
      />
      <SearchResults
        hits={session.hits}
        view={(p) => ({
          href: `/packages/${p.name}`,
          title: `@molecule/${p.name}`,
          description: p.description,
          meta: [p.type, p.category],
          mono: true,
        })}
        activeIndex={session.activeIndex}
        onActivate={session.setActiveIndex}
        onOpen={session.open}
        onPrefetch={session.prefetch}
        renderLink={(p) => (
          <Link
            to={p.href}
            state={p.state}
            className={p.className}
            onClick={p.onClick}
            data-mol-id={p['data-mol-id']}
          >
            {p.children}
          </Link>
        )}
        emptyText={t('catalog.empty', undefined, { defaultValue: 'Nothing matches' })}
      />
    </>
  )
}

(searchPlaceholder and nothingMatches are the app's own translated strings.)

Type

feature

Installation

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

API

Interfaces

HighlightProps

Props for Highlight.

interface HighlightProps {
  /** The text to show. */
  text: string
  /** The matched terms to mark (a hit's `terms`). */
  terms: string[]
}

KbdProps

Props for Kbd.

interface KbdProps {
  /** The key or keys shown, e.g. `/`, `↵`, `esc`. */
  children: ReactNode
  /** Extra classes composed onto the cap. */
  className?: string
}

QuickSearchDialogProps

Props for QuickSearchDialog.

interface QuickSearchDialogProps<T extends ClientSearchDocument> {
  /** Whether the dialog is open. Rendered only while open. */
  open: boolean
  /** Close it (Escape with nothing typed, the overlay, or after opening a hit). */
  onClose: () => void
  /** The search session (from `useSearchSession`), created with `syncUrl: false` and `onEscape: onClose`. */
  session: SearchSession<T>
  /** How a hit renders. */
  view: (doc: T) => SearchResultView
  /** Placeholder for the box, already translated. */
  placeholder: string
  /** Example tokens for the tips line. */
  examples?: string[]
  /** Shown while the text matches nothing, already translated. */
  emptyText: string
  /** Shown before anything is typed, already translated. Defaults to the empty text. */
  idleText?: string
  /** Renders each row's link (a router link keeps navigation client-side). */
  renderLink?: (props: SearchResultLinkProps) => ReactNode
  /** `data-mol-id` prefix. Defaults to `quick-search`. */
  molId?: string
}

SearchBoxProps

Props for SearchBox.

interface SearchBoxProps {
  /** The text. */
  value: string
  /** Called with the new text on every keystroke. */
  onChange: (value: string) => void
  /** Key handling for the input (arrows, Enter, Escape), usually a session's `onKeyDown`. */
  onKeyDown?: (event: ReactKeyboardEvent<HTMLInputElement>) => void
  /** A ref to the input, so a shortcut can focus it. */
  inputRef?: RefObject<HTMLInputElement | null>
  /** Placeholder, already translated. Also the accessible name unless `label` is given. */
  placeholder: string
  /** Accessible name for the input, already translated. Defaults to `placeholder`. */
  label?: string
  /** How many hits the current text has. Omit to show no count. */
  count?: number
  /** Active `field:value` filters, shown as removable chips. */
  filters?: ClientSearchFilter[]
  /** Removes the i-th filter. */
  onRemoveFilter?: (index: number) => void
  /** Clears the text. Shown as an × while there is text. */
  onClear?: () => void
  /**
   * Example tokens for the search tips, e.g. `['category:auth', 'type:bond']`.
   * Given, a help icon sits inside the field and shows the syntax on hover or
   * focus. Omit for no help icon.
   */
  examples?: string[]
  /** The key that focuses the box, shown as a cap while it is empty. `null` hides it. Defaults to `/`. */
  shortcut?: string | null
  /** Focus the input on mount. */
  autoFocus?: boolean
  /**
   * Rendered directly under the field, before the status row (chips and
   * count), so a consumer can put its own controls — tabs, a scope switch —
   * right below the input.
   */
  afterField?: ReactNode
  /** `data-mol-id` prefix: `<molId>-input`, `-clear`, `-help`, `-filter`, `-count`. */
  molId?: string
  /** Extra classes composed onto the field. */
  className?: string
}

SearchResultHit

What the list needs from a hit. A session's hits already have this shape.

interface SearchResultHit<T> {
  id: string
  doc: T
  /** The matched terms, marked in the title and description. */
  terms: string[]
  /** Found by meaning rather than by the words typed. */
  related?: boolean
}

SearchResultLinkProps

Props the row's link gets; renderLink turns them into an element.

interface SearchResultLinkProps {
  href: string
  className: string
  children: ReactNode
  onClick: (event: ReactMouseEvent<HTMLElement>) => void
  state?: unknown
  'data-mol-id': string
}

SearchResultsProps

Props for SearchResults.

interface SearchResultsProps<T> {
  hits: SearchResultHit<T>[]
  /** How a hit renders. */
  view: (doc: T) => SearchResultView
  /** The keyboard- or pointer-active row, or `-1`. */
  activeIndex: number
  /** The pointer moved onto a row. */
  onActivate?: (index: number) => void
  /** A row was chosen (a plain click, or Enter elsewhere). Navigation is the caller's. */
  onOpen: (doc: T) => void
  /** The person is about to open a row (hover, focus, touch): warm its page. */
  onPrefetch?: (doc: T) => void
  /**
   * Renders the row's link. Defaults to a plain `<a>`; pass a router link to
   * keep navigation client-side (spread `props` onto it; `state` is optional).
   */
  renderLink?: (props: SearchResultLinkProps) => ReactNode
  /** Something at the row's end (an action button). */
  action?: (doc: T) => ReactNode
  /** Shown when there are no hits. */
  emptyText: string
  /** `data-mol-id` prefix: `<molId>-results`, `-hit`, `-link`, `-related`, `-empty`. */
  molId?: string
  /** Extra classes composed onto the list. */
  className?: string
}

SearchResultView

How one hit renders.

interface SearchResultView {
  /** Where the row goes. */
  href: string
  /** The row's title. */
  title: string
  /** Under the title, clamped to two lines. */
  description?: string
  /** Small facts after the title: type, category, source. */
  meta?: string[]
  /** Render the title in monospace (a package name). */
  mono?: boolean
  /** Opaque data handed to `renderLink` (router state). */
  state?: unknown
}

Functions

Highlight(props)

Text with the matched terms marked: each term is marked where it starts a word, case-insensitively, through the ClassMap's resultMark.

function Highlight({ text, terms }: HighlightProps): JSX.Element
  • props — The text and the terms.

Returns: The text with <mark> around each match.

Kbd(props)

A key cap, for shortcut hints (/ to focus, ↵ to open).

function Kbd({ children, className }: KbdProps): JSX.Element
  • props — The key label.

Returns: A <kbd> styled by the ClassMap.

QuickSearchDialog(props)

A search dialog for mod+k: the box on top with focus, the hits below in a scrolling pane, and the key hints in the footer. Before anything is typed the pane shows idleText rather than a browse list. Enter opens the active hit through the session (which closes the dialog by the caller's onOpen).

function QuickSearchDialog({
  open,
  onClose,
  session,
  view,
  placeholder,
  examples,
  emptyText,
  idleText,
  renderLink,
  molId = 'quick-search',
}: QuickSearchDialogProps<T>): JSX.Element | null
  • props — See QuickSearchDialogProps.

Returns: The dialog, or nothing while closed.

SearchBox(props)

A search field: a leading icon, the input, a help icon that shows the syntax on hover, an × to clear, and the shortcut cap; under it, whatever afterField holds, then the active filters as removable chips and the hit count. The field stays mounted, and its text with it, across searches.

function SearchBox({
  value,
  onChange,
  onKeyDown,
  inputRef,
  placeholder,
  label,
  count,
  filters = [],
  onRemoveFilter,
  onClear,
  examples,
  shortcut = '/',
  autoFocus,
  afterField,
  molId = 'search',
  className,
}: SearchBoxProps): JSX.Element
  • props — See SearchBoxProps.

Returns: The field and its status row.

SearchResults(props)

The ranked hits: title and description with the matched terms marked, the meta line, the active row marked for keyboard users, hover making a row active and warming its page. The whole row is the link.

function SearchResults({
  hits,
  view,
  activeIndex,
  onActivate,
  onOpen,
  onPrefetch,
  renderLink = defaultLink,
  action,
  emptyText,
  molId = 'search',
  className,
}: SearchResultsProps<T>): JSX.Element
  • props — See SearchResultsProps.

Returns: The list, or the empty state.

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-client-search ^1.0.0
  • @molecule/app-client-search-react ^1.0.0
  • @molecule/app-react ^1.0.1
  • @molecule/app-ui ^1.3.0
  • @molecule/app-ui-react ^1.2.0
  • react ^18.0.0 || ^19.0.0

Runtime Dependencies

  • @molecule/app-client-search

  • @molecule/app-client-search-react

  • @molecule/app-react

  • @molecule/app-ui

  • @molecule/app-ui-react

  • react

  • Pass a router link through renderLink, or every row is a full page load: the list renders a plain <a> by default because it does not know your router. A plain click calls onOpen (which the session turns into navigation); a modified click is left to the browser so "open in new tab" works.

  • The box does not own the text: bind value/onChange to a session (or any state) and it stays mounted, with its text, across searches. Do not remount it per search.

  • placeholder, emptyText and the like are already-translated strings from the app; the package translates only its own chrome ("Clear search", "{{count}} results", "Search tips") through @molecule/app-locales-search-ui.

  • The quick dialog needs a session created with syncUrl: false and onEscape: onClose; the page's own session keeps the URL.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • The field shows its placeholder and the shortcut cap; the cap goes away while typing and the × clears the text and keeps focus.
  • A field:value in the text appears as a chip; its × removes only that part.
  • Results update on every keystroke with the matched words marked; the count agrees with the rows.
  • Arrow keys move the highlight, Enter opens the highlighted row and the URL changes; a plain click on a row does the same; Cmd/Ctrl-click opens a new tab.
  • mod+k opens the dialog with focus in its field; Escape with an empty field closes it; opening a hit closes it and navigates.

Translations

Translation strings are provided by @molecule/app-locales-search-ui.