@findlane/browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @findlane/browser
2
2
 
3
- The Findlane browser SDK: fast, typo-tolerant search from the browser with an index's public ID. No dependencies, about 1 KB.
3
+ The Findlane browser SDK: fast, typo-tolerant search from the browser with an index's public ID. No dependencies, about 1 KB, plus optional React hooks and components.
4
4
 
5
5
  Docs: https://findlane.dev/docs/browser-sdk
6
6
 
@@ -31,6 +31,19 @@ Without a build step, load it with a script tag, which defines a global `Findlan
31
31
 
32
32
  Each index has a copyable public ID in the dashboard. An owner must save allowed origins for the index before browser search is enabled.
33
33
 
34
+ ## React
35
+
36
+ `@findlane/browser/react` has a search box that suggests results as visitors type, a results page with filters, and the hooks behind them, for React 18.2 and 19. Pages that don't import it don't load it.
37
+
38
+ ```tsx
39
+ import { SearchBox } from "@findlane/browser/react"
40
+ import "@findlane/browser/react/styles.css" // optional
41
+
42
+ <SearchBox publicId="pub_your_public_id" getHref={(hit) => hit.record.url} action="/search" />
43
+ ```
44
+
45
+ `SearchDialog` is the same search box in a dialog that ⌘K opens, as on docs sites. A results page shares its state through `SearchProvider`, with `RefinementList`, `SortSelect`, `Hits`, `LoadMore`, and hooks such as `useRefinementList` and `useRange` for your own markup. With `routing`, the search is kept in the URL. `@findlane/browser/react/ssr` renders the first results on the server. Docs: https://findlane.dev/docs/react
46
+
34
47
  ## Search requests
35
48
 
36
49
  | Option | |
@@ -38,6 +51,7 @@ Each index has a copyable public ID in the dashboard. An owner must save allowed
38
51
  | `q` | The query. Words match with typos and as prefixes, depending on the index's settings. |
39
52
  | `filters` | Field values to keep: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. |
40
53
  | `facets` | Fields to count values for, such as `["brand.name"]`. |
54
+ | `disjunctiveFacets` | Facets counted without the filter on their own field, so a list of checkboxes keeps its other values. |
41
55
  | `sort` | A sort ID from the index settings, or `"relevance"`. |
42
56
  | `limit`, `offset` | Paging: up to 50 hits a request, and offsets up to 1,000. |
43
57
  | `countMode`, `facetMode` | `"page"` (the default, fastest) or `"exact"` for totals and facet counts across every match. |
package/dist/index.d.ts CHANGED
@@ -5,6 +5,9 @@ export type SearchRequest = {
5
5
  /** Field values to keep: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. */
6
6
  filters?: Record<string, unknown>;
7
7
  facets?: string[];
8
+ /** Facets (also in `facets`) counted without the filter on their own field, so a list of choices
9
+ * keeps its other values, with counts, after one is chosen. */
10
+ disjunctiveFacets?: string[];
8
11
  /** Hits per request: 1–50 (default 20). */
9
12
  limit?: number;
10
13
  /** Up to 1,000. */
package/dist/index.js CHANGED
@@ -1,38 +1,32 @@
1
- // src/index.ts
2
- class FindlaneError extends Error {
3
- status;
4
- constructor(status, message) {
5
- super(message);
6
- this.status = status;
7
- this.name = "FindlaneError";
8
- }
1
+ export class FindlaneError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ this.name = "FindlaneError";
7
+ }
9
8
  }
10
- var defaultBaseUrl = "https://api.findlane.dev";
11
-
12
- class SearchBrowserClient {
13
- url;
14
- fetcher;
15
- constructor(options) {
16
- if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
17
- throw new Error("A valid public index ID (pub_…) is required");
18
- this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}/search`;
19
- this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
20
- }
21
- async search(request, options = {}) {
22
- const response = await this.fetcher(this.url, {
23
- method: "POST",
24
- headers: { "content-type": "application/json" },
25
- body: JSON.stringify(request),
26
- signal: options.signal
27
- });
28
- const data = await response.json();
29
- if (!response.ok)
30
- throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
31
- return data;
32
- }
9
+ /** The hosted Findlane API, used unless `baseUrl` says otherwise. */
10
+ export const defaultBaseUrl = "https://api.findlane.dev";
11
+ export class SearchBrowserClient {
12
+ url;
13
+ fetcher;
14
+ constructor(options) {
15
+ if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
16
+ throw new Error("A valid public index ID (pub_…) is required");
17
+ this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}/search`;
18
+ this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
19
+ }
20
+ async search(request, options = {}) {
21
+ const response = await this.fetcher(this.url, {
22
+ method: "POST",
23
+ headers: { "content-type": "application/json" },
24
+ body: JSON.stringify(request),
25
+ signal: options.signal,
26
+ });
27
+ const data = await response.json();
28
+ if (!response.ok)
29
+ throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
30
+ return data;
31
+ }
33
32
  }
34
- export {
35
- FindlaneError,
36
- SearchBrowserClient,
37
- defaultBaseUrl
38
- };
@@ -0,0 +1,7 @@
1
+ import type { SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
2
+ /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
3
+ * the query's first eight lowercase words, defaults left out, and list order ignored. */
4
+ export declare function searchKey(request: SearchRequest): string;
5
+ /** A cached response, if there's a recent one. */
6
+ export declare function peek<T>(client: SearchBrowserClient, request: SearchRequest): SearchResponse<T> | null;
7
+ export declare function cachedSearch<T>(client: SearchBrowserClient, request: SearchRequest, signal?: AbortSignal): Promise<SearchResponse<T>>;
@@ -0,0 +1,42 @@
1
+ import { canonical } from "./state.js";
2
+ const size = 50;
3
+ const maxAge = 5 * 60_000;
4
+ const caches = new WeakMap();
5
+ /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
6
+ * the query's first eight lowercase words, defaults left out, and list order ignored. */
7
+ export function searchKey(request) {
8
+ const normal = { ...request };
9
+ if (typeof normal.q === "string" && normal.q.length <= 200)
10
+ normal.q = normal.q.toLowerCase().match(/[\p{L}\p{N}]+/gu)?.slice(0, 8).join(" ") ?? "";
11
+ const defaults = { q: "", limit: 20, offset: 0, countMode: "page", facetMode: "page" };
12
+ for (const [key, value] of Object.entries(defaults))
13
+ if (normal[key] === value)
14
+ delete normal[key];
15
+ for (const key of ["facets", "disjunctiveFacets"]) {
16
+ const list = normal[key];
17
+ if (Array.isArray(list))
18
+ normal[key] = list.length ? [...list].sort() : undefined;
19
+ }
20
+ return canonical(normal);
21
+ }
22
+ /** A cached response, if there's a recent one. */
23
+ export function peek(client, request) {
24
+ const entry = caches.get(client)?.get(searchKey(request));
25
+ return entry && Date.now() - entry.at < maxAge ? entry.response : null;
26
+ }
27
+ export async function cachedSearch(client, request, signal) {
28
+ const found = peek(client, request);
29
+ if (found)
30
+ return found;
31
+ const response = await client.search(request, { signal });
32
+ let cache = caches.get(client);
33
+ if (!cache)
34
+ caches.set(client, (cache = new Map()));
35
+ const key = searchKey(request);
36
+ cache.delete(key);
37
+ cache.set(key, { at: Date.now(), response });
38
+ // A Map keeps insertion order, so the first key is the oldest.
39
+ if (cache.size > size)
40
+ cache.delete(cache.keys().next().value);
41
+ return response;
42
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from "react";
2
+ import { SearchBrowserClient } from "../index.js";
3
+ /** Where to search: a public ID (with an optional `baseUrl`), or a client you made. Leave both out
4
+ * inside a FindlaneProvider. */
5
+ export type ClientOptions = {
6
+ publicId?: string;
7
+ baseUrl?: string;
8
+ client?: SearchBrowserClient;
9
+ };
10
+ /** Sets the index once for the hooks and components inside it. */
11
+ export declare function FindlaneProvider({ children, ...options }: ClientOptions & {
12
+ children: ReactNode;
13
+ }): import("react").JSX.Element;
14
+ export declare function useClient(options?: ClientOptions): SearchBrowserClient;
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ import { SearchBrowserClient } from "../index.js";
5
+ const ClientContext = createContext(null);
6
+ function useOwnClient({ publicId, baseUrl, client }) {
7
+ return useMemo(() => client ?? (publicId ? new SearchBrowserClient({ publicId, baseUrl }) : null), [client, publicId, baseUrl]);
8
+ }
9
+ /** Sets the index once for the hooks and components inside it. */
10
+ export function FindlaneProvider({ children, ...options }) {
11
+ const client = useOwnClient(options);
12
+ if (!client)
13
+ throw new Error("FindlaneProvider needs a publicId or a client");
14
+ return _jsx(ClientContext.Provider, { value: client, children: children });
15
+ }
16
+ export function useClient(options = {}) {
17
+ const own = useOwnClient(options);
18
+ const provided = useContext(ClientContext);
19
+ const client = own ?? provided;
20
+ if (!client)
21
+ throw new Error("Pass a publicId, or put this inside a FindlaneProvider");
22
+ return client;
23
+ }
@@ -0,0 +1,87 @@
1
+ import { type ReactNode } from "react";
2
+ import { type Refinement } from "./search-provider.js";
3
+ import type { Hit } from "./state.js";
4
+ import { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
5
+ /** A hit's image and title, for records with `image` and `title` (or `name`) fields. */
6
+ export declare function DefaultHit<T>({ hit }: {
7
+ hit: Hit<T>;
8
+ }): import("react").JSX.Element;
9
+ export type SearchBoxProps<T> = AutocompleteOptions<T> & {
10
+ placeholder?: string;
11
+ /** The input's accessible name (default "Search"). */
12
+ label?: string;
13
+ /** A results page, such as "/search". Enter goes to it with `?q=`, and the list links to it. */
14
+ action?: string;
15
+ /** A suggestion's content; DefaultHit shows an image and a title. */
16
+ renderHit?: (hit: Hit<T>) => ReactNode;
17
+ /** Shown when nothing matches (default "No results for “…”"). */
18
+ empty?: (query: string) => ReactNode;
19
+ /** The link to the results page (default "See all N results"). */
20
+ seeAll?: (total: number) => ReactNode;
21
+ className?: string;
22
+ autoFocus?: boolean;
23
+ };
24
+ /** A results page's address for a query. */
25
+ export declare const resultsHref: (action: string, query: string) => string;
26
+ /** The suggestions under a search box: the hits, "no results", or an error, then a link to the
27
+ * results page. Shared by SearchBox and SearchDialog. */
28
+ export declare function Suggestions<T>({ box, label, action, renderHit, empty, seeAll }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "empty" | "seeAll"> & {
29
+ box: ReturnType<typeof useAutocomplete<T>>;
30
+ label: string;
31
+ }): import("react").JSX.Element;
32
+ /** A search box that suggests results as visitors type. Works on its own with a `publicId`. */
33
+ export declare function SearchBox<T = Record<string, unknown>>({ placeholder, label, action, renderHit, empty, seeAll, className, autoFocus, ...options }: SearchBoxProps<T>): import("react").JSX.Element;
34
+ /** The query input of the surrounding SearchProvider. */
35
+ export declare function SearchInput({ placeholder, label, className, autoFocus }: {
36
+ placeholder?: string;
37
+ label?: string;
38
+ className?: string;
39
+ autoFocus?: boolean;
40
+ }): import("react").JSX.Element;
41
+ export type HitsProps<T> = {
42
+ /** A hit's content; DefaultHit shows an image and a title. */
43
+ render?: (hit: Hit<T>, index: number) => ReactNode;
44
+ /** Shown when nothing matches (default "No results for “…”"). */
45
+ empty?: (query: string) => ReactNode;
46
+ /** Shown before the first results arrive. */
47
+ loading?: ReactNode;
48
+ className?: string;
49
+ };
50
+ export declare function Hits<T = Record<string, unknown>>({ render, empty, loading, className }: HitsProps<T>): import("react").JSX.Element;
51
+ export type RefinementListProps = {
52
+ field: string;
53
+ /** The heading (default the field). */
54
+ label?: string;
55
+ /** Values shown before "Show all" (default 8). */
56
+ limit?: number;
57
+ /** A value as people should read it, when the data holds a code. */
58
+ format?: (value: string) => string;
59
+ className?: string;
60
+ };
61
+ /** Checkboxes for a facet's values, with counts. */
62
+ export declare function RefinementList({ field, label, limit, format, className }: RefinementListProps): import("react").JSX.Element;
63
+ /** A select for the sorts in the index settings; the first option is the default. */
64
+ export declare function SortSelect({ options, label, className }: {
65
+ options: Array<{
66
+ id: string;
67
+ label: string;
68
+ }>;
69
+ label?: string;
70
+ className?: string;
71
+ }): import("react").JSX.Element;
72
+ /** The chosen filters as chips that remove them, and "Clear all". Shows nothing when none are chosen. */
73
+ export declare function CurrentRefinements({ format, clearLabel, className }: {
74
+ /** A chip's text (default the value, or a range like "2019–2021"). */
75
+ format?: (refinement: Refinement) => ReactNode;
76
+ clearLabel?: string;
77
+ className?: string;
78
+ }): import("react").JSX.Element | null;
79
+ export declare function LoadMore({ label, className }: {
80
+ label?: string;
81
+ className?: string;
82
+ }): import("react").JSX.Element | null;
83
+ /** "1,234 results". */
84
+ export declare function Stats({ format, className }: {
85
+ format?: (total: number) => ReactNode;
86
+ className?: string;
87
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,83 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Highlight } from "./highlight.js";
5
+ import { messageFor } from "./messages.js";
6
+ import { useCurrentRefinements, useHits, useLoadMore, useRefinementList, useSearchBox, useSortBy, } from "./search-provider.js";
7
+ import { useAutocomplete } from "./use-autocomplete.js";
8
+ // The components render plain elements marked with data-findlane-* attributes and no styles of
9
+ // their own; styles.css gives them a default look.
10
+ const number = (value) => value.toLocaleString("en-US");
11
+ /** A hit's image and title, for records with `image` and `title` (or `name`) fields. */
12
+ export function DefaultHit({ hit }) {
13
+ const record = (hit.record ?? {});
14
+ const image = typeof record.image === "string" ? record.image : null;
15
+ const field = ["title", "name"].find((name) => name in record || hit.highlights?.[name] !== undefined);
16
+ return (_jsxs(_Fragment, { children: [image && _jsx("img", { src: image, alt: "", loading: "lazy", "data-findlane-image": "" }), _jsx("span", { "data-findlane-title": "", children: field ? _jsx(Highlight, { hit: hit, field: field }) : hit.id })] }));
17
+ }
18
+ /** A results page's address for a query. */
19
+ export const resultsHref = (action, query) => `${action}${action.includes("?") ? "&" : "?"}q=${encodeURIComponent(query)}`;
20
+ /** The suggestions under a search box: the hits, "no results", or an error, then a link to the
21
+ * results page. Shared by SearchBox and SearchDialog. */
22
+ export function Suggestions({ box, label, action, renderHit, empty, seeAll }) {
23
+ const { hits, query, total, error } = box;
24
+ return (_jsxs("div", { "data-findlane-panel": "", children: [error ? (_jsx("p", { "data-findlane-note": "", children: messageFor(error) })) : hits.length ? (_jsx("div", { ...box.getListboxProps({ "aria-label": label }), children: hits.map((hit, index) => {
25
+ const href = box.hrefFor(hit);
26
+ const content = renderHit ? renderHit(hit) : _jsx(DefaultHit, { hit: hit });
27
+ return href ? (_jsx("a", { href: href, tabIndex: -1, ...box.getOptionProps(index), children: content }, hit.id)) : (_jsx("div", { ...box.getOptionProps(index), children: content }, hit.id));
28
+ }) })) : (_jsx("p", { "data-findlane-note": "", children: empty ? empty(query) : _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) })), action && total > hits.length && (_jsx("a", { href: resultsHref(action, query), "data-findlane-all": "", onMouseDown: (event) => event.preventDefault(), children: seeAll ? seeAll(total) : `See all ${number(total)} results` }))] }));
29
+ }
30
+ /** A search box that suggests results as visitors type. Works on its own with a `publicId`. */
31
+ export function SearchBox({ placeholder, label = "Search", action, renderHit, empty, seeAll, className, autoFocus, ...options }) {
32
+ const box = useAutocomplete(options);
33
+ return (_jsxs("div", { className: className, "data-findlane-searchbox": "", "data-open": box.isOpen || undefined, children: [_jsx("form", { role: "search", action: action, method: "get", onSubmit: (event) => { if (!action)
34
+ event.preventDefault(); }, children: _jsx("input", { type: "search", name: "q", placeholder: placeholder, "aria-label": label, autoFocus: autoFocus, ...box.getInputProps() }) }), box.isOpen && (_jsx(Suggestions, { box: box, label: label, action: action, renderHit: renderHit, empty: empty, seeAll: seeAll }))] }));
35
+ }
36
+ /** The query input of the surrounding SearchProvider. */
37
+ export function SearchInput({ placeholder, label = "Search", className, autoFocus }) {
38
+ const { query, setQuery } = useSearchBox();
39
+ return (_jsx("form", { role: "search", className: className, "data-findlane-search-input": "", onSubmit: (event) => event.preventDefault(), children: _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: placeholder, "aria-label": label, autoFocus: autoFocus, autoComplete: "off", spellCheck: false, maxLength: 200 }) }));
40
+ }
41
+ export function Hits({ render, empty, loading = null, className }) {
42
+ const { hits, isLoaded, isPending, error } = useHits();
43
+ const { query } = useSearchBox();
44
+ if (error && !isLoaded)
45
+ return _jsx("p", { "data-findlane-note": "", children: messageFor(error) });
46
+ if (!isLoaded)
47
+ return _jsx(_Fragment, { children: loading });
48
+ if (!hits.length)
49
+ return _jsx("p", { "data-findlane-empty": "", children: empty ? empty(query) : query ? _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) : "No results" });
50
+ return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) => (_jsx("li", { children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id))) }));
51
+ }
52
+ /** Checkboxes for a facet's values, with counts. */
53
+ export function RefinementList({ field, label, limit = 8, format, className }) {
54
+ const { items, toggle } = useRefinementList(field);
55
+ const [expanded, setExpanded] = useState(false);
56
+ const shown = expanded ? items : items.slice(0, limit);
57
+ return (_jsxs("fieldset", { className: className, "data-findlane-refinement-list": "", children: [_jsx("legend", { children: label ?? field }), items.length === 0 && _jsx("p", { "data-findlane-note": "", children: "No values" }), shown.map((item) => (_jsxs("label", { "data-empty": item.count === 0 || undefined, children: [_jsx("input", { type: "checkbox", checked: item.selected, onChange: () => toggle(item.value) }), _jsx("span", { "data-findlane-value": "", title: format ? item.value : undefined, children: format ? format(item.value) : item.value }), _jsx("span", { "data-findlane-count": "", children: number(item.count) })] }, item.value))), items.length > limit && (_jsx("button", { type: "button", onClick: () => setExpanded((open) => !open), children: expanded ? "Show fewer" : `Show all ${items.length}` }))] }));
58
+ }
59
+ /** A select for the sorts in the index settings; the first option is the default. */
60
+ export function SortSelect({ options, label = "Sort by", className }) {
61
+ const { sort, setSort } = useSortBy();
62
+ return (_jsx("select", { className: className, "data-findlane-sort": "", "aria-label": label, value: sort ?? options[0]?.id, onChange: (event) => setSort(event.target.value === options[0]?.id ? undefined : event.target.value), children: options.map((option) => _jsx("option", { value: option.id, children: option.label }, option.id)) }));
63
+ }
64
+ /** The chosen filters as chips that remove them, and "Clear all". Shows nothing when none are chosen. */
65
+ export function CurrentRefinements({ format, clearLabel = "Clear all", className }) {
66
+ const { items, clear } = useCurrentRefinements();
67
+ if (!items.length)
68
+ return null;
69
+ return (_jsxs("div", { className: className, "data-findlane-current-refinements": "", children: [items.map((item) => (_jsxs("button", { type: "button", onClick: item.remove, children: [format ? format(item) : item.label, " ", _jsx("span", { "aria-hidden": "true", children: "\u00D7" })] }, `${item.field}:${String(item.label)}`))), _jsx("button", { type: "button", "data-findlane-clear": "", onClick: clear, children: clearLabel })] }));
70
+ }
71
+ export function LoadMore({ label = "Show more", className }) {
72
+ const { hasMore, loadMore, isLoading, shown, total } = useLoadMore();
73
+ if (!hasMore)
74
+ return null;
75
+ return (_jsxs("div", { className: className, "data-findlane-load-more": "", children: [_jsx("button", { type: "button", onClick: loadMore, disabled: isLoading, children: isLoading ? "Loading…" : label }), _jsxs("span", { children: [number(shown), " of ", number(total)] })] }));
76
+ }
77
+ /** "1,234 results". */
78
+ export function Stats({ format, className }) {
79
+ const { total, totalRelation, isLoaded } = useHits();
80
+ if (!isLoaded)
81
+ return null;
82
+ return (_jsx("p", { className: className, "data-findlane-stats": "", children: format ? format(total) : `${number(total)}${totalRelation === "gte" ? "+" : ""} ${total === 1 ? "result" : "results"}` }));
83
+ }
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from "react";
2
+ import { type SearchBoxProps } from "./components.js";
3
+ export type SearchDialogProps<T> = Omit<SearchBoxProps<T>, "autoFocus"> & {
4
+ /** The button that opens the dialog (default "Search", with the shortcut). */
5
+ trigger?: ReactNode;
6
+ /** Open with ⌘K (Ctrl+K elsewhere) and "/" (default true). */
7
+ shortcuts?: boolean;
8
+ };
9
+ /** A search button that opens a dialog with a search box, as on docs sites; ⌘K and "/" open it too.
10
+ * The dialog is the browser's own, so focus stays in it and the page behind can't be used. */
11
+ export declare function SearchDialog<T = Record<string, unknown>>({ trigger, shortcuts, placeholder, label, action, renderHit, empty, seeAll, className, ...options }: SearchDialogProps<T>): import("react").JSX.Element;
@@ -0,0 +1,63 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Suggestions, resultsHref } from "./components.js";
5
+ import { useAutocomplete } from "./use-autocomplete.js";
6
+ const onMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
7
+ /** A search button that opens a dialog with a search box, as on docs sites; ⌘K and "/" open it too.
8
+ * The dialog is the browser's own, so focus stays in it and the page behind can't be used. */
9
+ export function SearchDialog({ trigger, shortcuts = true, placeholder, label = "Search", action, renderHit, empty, seeAll, className, ...options }) {
10
+ const dialog = useRef(null);
11
+ const [open, setOpen] = useState(false);
12
+ const close = () => dialog.current?.close();
13
+ const box = useAutocomplete({
14
+ ...options,
15
+ // A chosen suggestion closes the dialog; its link, if any, is followed as usual.
16
+ onSelect: options.onSelect && ((hit) => { close(); options.onSelect(hit); }),
17
+ onSubmit: options.onSubmit
18
+ ? (query) => { close(); options.onSubmit(query); }
19
+ : action ? (query) => window.location.assign(resultsHref(action, query)) : undefined,
20
+ });
21
+ const show = () => {
22
+ if (dialog.current?.open)
23
+ return;
24
+ dialog.current?.showModal();
25
+ setOpen(true);
26
+ };
27
+ // The shortcut hint is shown after hydration, so the server's HTML doesn't guess the platform.
28
+ const [mac, setMac] = useState(false);
29
+ useEffect(() => setMac(onMac()), []);
30
+ useEffect(() => {
31
+ if (!shortcuts)
32
+ return;
33
+ const onKey = (event) => {
34
+ const target = event.target;
35
+ const typing = !!target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
36
+ if ((event.key === "k" || event.key === "K") && (event.metaKey || event.ctrlKey)) {
37
+ event.preventDefault();
38
+ if (dialog.current?.open)
39
+ close();
40
+ else
41
+ show();
42
+ }
43
+ else if (event.key === "/" && !typing) {
44
+ event.preventDefault();
45
+ show();
46
+ }
47
+ };
48
+ window.addEventListener("keydown", onKey);
49
+ return () => window.removeEventListener("keydown", onKey);
50
+ }, [shortcuts]);
51
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: className, "data-findlane-dialog-trigger": "", "aria-haspopup": "dialog", onClick: show, children: trigger ?? (_jsxs(_Fragment, { children: [_jsx("span", { children: label }), shortcuts && _jsx("kbd", { "aria-hidden": "true", children: mac ? "⌘K" : "Ctrl K" })] })) }), _jsx("dialog", { ref: dialog, "data-findlane-dialog": "", "aria-label": label, onClose: () => setOpen(false),
52
+ // A click on the backdrop lands on the dialog itself.
53
+ onClick: (event) => { if (event.target === event.currentTarget)
54
+ close(); }, children: open && (_jsxs("div", { "data-findlane-searchbox": "", "data-open": box.isOpen || undefined, children: [_jsx("form", { role: "search", onSubmit: (event) => event.preventDefault(), children: _jsx("input", { type: "search", placeholder: placeholder, "aria-label": label, autoFocus: true, ...box.getInputProps({
55
+ // Escape closes the dialog rather than only the suggestions.
56
+ onKeyDown: (event) => {
57
+ if (event.key === "Escape") {
58
+ event.preventDefault();
59
+ close();
60
+ }
61
+ },
62
+ }) }) }), box.isOpen && (_jsx(Suggestions, { box: box, label: label, action: action, renderHit: renderHit, empty: empty, seeAll: seeAll }))] })) })] }));
63
+ }
@@ -0,0 +1,29 @@
1
+ import { type ElementType } from "react";
2
+ import type { Hit } from "./state.js";
3
+ /** Highlighted text from the API as plain and matched parts. */
4
+ export declare function highlightParts(html: string): {
5
+ text: string;
6
+ marked: boolean;
7
+ }[];
8
+ /** A field's values in a record, by path; list fields and lists of objects give each value. */
9
+ export declare function valuesAt(record: unknown, path: string): string[];
10
+ export type HighlightProps<T> = {
11
+ hit: Hit<T>;
12
+ /** A field path, such as "title" or "brand.name". */
13
+ field: string;
14
+ /** Between a list field's values (default ", "). */
15
+ separator?: string;
16
+ /** The element for matched words (default "mark"). */
17
+ mark?: ElementType;
18
+ className?: string;
19
+ };
20
+ /** A field with the words the query matched marked. Needs `highlight: true` in the request, which
21
+ * the hooks set. */
22
+ export declare function Highlight<T>({ hit, field, separator, mark, className }: HighlightProps<T>): import("react").JSX.Element;
23
+ /** A few words of highlighted text where the most query words matched, so a phrase wins over a lone
24
+ * typo match. The API marks single words, so cutting at spaces never splits a tag or an escape. */
25
+ export declare function excerpt(html: string, words?: number): string;
26
+ /** A long field, such as a description, cut to the words around its best match. */
27
+ export declare function Snippet<T>({ hit, field, words, separator, mark, className }: HighlightProps<T> & {
28
+ words?: number;
29
+ }): import("react").JSX.Element;
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
3
+ // The API escapes highlighted values (dashboard/search-terms.ts) and wraps matched words in
4
+ // <mark>. Splitting on the tags and decoding the five escapes gives text React can render, so
5
+ // nothing is inserted as HTML.
6
+ const escapes = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
7
+ const decode = (text) => text.replace(/&(amp|lt|gt|quot|#39);/g, (entity) => escapes[entity]);
8
+ /** Highlighted text from the API as plain and matched parts. */
9
+ export function highlightParts(html) {
10
+ return html.split(/(<mark>|<\/mark>)/).reduce((acc, piece) => {
11
+ if (piece === "<mark>")
12
+ acc.marked = true;
13
+ else if (piece === "</mark>")
14
+ acc.marked = false;
15
+ else if (piece)
16
+ acc.parts.push({ text: decode(piece), marked: acc.marked });
17
+ return acc;
18
+ }, { parts: [], marked: false }).parts;
19
+ }
20
+ /** A field's values in a record, by path; list fields and lists of objects give each value. */
21
+ export function valuesAt(record, path) {
22
+ const flat = (value) => (Array.isArray(value) ? value.flatMap(flat) : [value]);
23
+ let values = flat(record);
24
+ for (const key of path.split("."))
25
+ values = values.flatMap((value) => (value && typeof value === "object" ? flat(value[key]) : []));
26
+ return values.filter((value) => value !== null && value !== undefined && typeof value !== "object").map(String);
27
+ }
28
+ const escape = (text) => text.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
29
+ /** A field as highlighted text, whether or not it matched. */
30
+ function fieldHtml(hit, field, separator) {
31
+ const highlighted = hit.highlights?.[field];
32
+ if (highlighted !== undefined)
33
+ return Array.isArray(highlighted) ? highlighted.join(separator) : highlighted;
34
+ return escape(valuesAt(hit.record, field).join(separator));
35
+ }
36
+ function Marked({ html, mark: Mark }) {
37
+ return _jsx(_Fragment, { children: highlightParts(html).map((part, at) => part.marked ? _jsx(Mark, { children: part.text }, at) : _jsx(Fragment, { children: part.text }, at)) });
38
+ }
39
+ /** A field with the words the query matched marked. Needs `highlight: true` in the request, which
40
+ * the hooks set. */
41
+ export function Highlight({ hit, field, separator = ", ", mark = "mark", className }) {
42
+ return _jsx("span", { className: className, children: _jsx(Marked, { html: fieldHtml(hit, field, separator), mark: mark }) });
43
+ }
44
+ /** A few words of highlighted text where the most query words matched, so a phrase wins over a lone
45
+ * typo match. The API marks single words, so cutting at spaces never splits a tag or an escape. */
46
+ export function excerpt(html, words = 16) {
47
+ const parts = html.split(/\s+/);
48
+ const marked = parts.map((part) => part.includes("<mark>"));
49
+ let from = -1;
50
+ let best = 0;
51
+ for (let at = 0; at < parts.length; at++) {
52
+ if (!marked[at])
53
+ continue;
54
+ const start = Math.max(0, at - 4);
55
+ const count = marked.slice(start, start + words).filter(Boolean).length;
56
+ if (count > best)
57
+ [from, best] = [start, count];
58
+ }
59
+ // Without a match, the field's opening.
60
+ if (from < 0)
61
+ from = 0;
62
+ const to = Math.min(parts.length, from + words);
63
+ return `${from > 0 ? "…" : ""}${parts.slice(from, to).join(" ")}${to < parts.length ? "…" : ""}`;
64
+ }
65
+ /** A long field, such as a description, cut to the words around its best match. */
66
+ export function Snippet({ hit, field, words = 16, separator = " ", mark = "mark", className }) {
67
+ return _jsx("span", { className: className, children: _jsx(Marked, { html: excerpt(fieldHtml(hit, field, separator), words), mark: mark }) });
68
+ }
@@ -0,0 +1,10 @@
1
+ export { FindlaneProvider, useClient, type ClientOptions } from "./client.js";
2
+ export { useSearch, type UseSearchOptions, type UseSearchResult } from "./use-search.js";
3
+ export { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
4
+ export { SearchProvider, useCurrentRefinements, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
5
+ export { CurrentRefinements, DefaultHit, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
6
+ export { SearchDialog, type SearchDialogProps } from "./dialog.js";
7
+ export { Highlight, Snippet, excerpt, highlightParts, type HighlightProps } from "./highlight.js";
8
+ export { messageFor } from "./messages.js";
9
+ export { emptyState, stateFromUrl, urlFromState, type Hit, type SearchConfig, type SearchState, type UrlParams, } from "./state.js";
10
+ export type { ServerResults, StateChange } from "./store.js";
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ // @findlane/browser/react: hooks that keep searches current, and components built on them.
3
+ export { FindlaneProvider, useClient } from "./client.js";
4
+ export { useSearch } from "./use-search.js";
5
+ export { useAutocomplete } from "./use-autocomplete.js";
6
+ export { SearchProvider, useCurrentRefinements, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
7
+ export { CurrentRefinements, DefaultHit, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
8
+ export { SearchDialog } from "./dialog.js";
9
+ export { Highlight, Snippet, excerpt, highlightParts } from "./highlight.js";
10
+ export { messageFor } from "./messages.js";
11
+ export { emptyState, stateFromUrl, urlFromState, } from "./state.js";
@@ -0,0 +1,2 @@
1
+ /** A failed search as a visitor should read it. The details for developers are on the error. */
2
+ export declare function messageFor(error: unknown): "That was a lot of searches in a short time. Try again in a minute." | "Search isn't available right now." | "The search didn't go through. Check your connection and try again.";
@@ -0,0 +1,13 @@
1
+ import { FindlaneError } from "../index.js";
2
+ /** A failed search as a visitor should read it. The details for developers are on the error. */
3
+ export function messageFor(error) {
4
+ if (error instanceof FindlaneError) {
5
+ if (error.status === 429)
6
+ return "That was a lot of searches in a short time. Try again in a minute.";
7
+ // 403 (this site isn't an allowed origin) and 402 (the plan's searches are used up) aren't
8
+ // something a visitor can fix.
9
+ if (error.status === 403 || error.status === 402)
10
+ return "Search isn't available right now.";
11
+ }
12
+ return "The search didn't go through. Check your connection and try again.";
13
+ }