@findlane/browser 0.20.0 → 0.22.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/dist/index.d.ts CHANGED
@@ -59,6 +59,18 @@ export type SearchRequest = {
59
59
  boost?: SearchRequest["filters"] | SearchRequest["filters"][];
60
60
  /** Records matching these come last, the same way (0.20.0). */
61
61
  bury?: SearchRequest["filters"] | SearchRequest["filters"][];
62
+ /** The index's subindexes to search too, such as a store's categories and pages beside its products, by
63
+ * name: each with its own search, which takes this one's `q` and `highlight` unless it says, and 5 results
64
+ * unless it says, or `true` for that. Up to 5. Their results come back in `subindexes`; the search and its
65
+ * subindexes count as one search (0.22.0). */
66
+ subindexes?: Record<string, SubindexSearch | true>;
67
+ };
68
+ /** A subindex's own search, in a search that asks for it. */
69
+ export type SubindexSearch = Omit<SearchRequest, "subindexes">;
70
+ /** A subindex whose search failed, such as one the index doesn't have (404). */
71
+ export type SubindexError = {
72
+ error: string;
73
+ status: number;
62
74
  };
63
75
  /** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
64
76
  export type GeoPoint = {
@@ -139,7 +151,19 @@ export type SearchResponse<T> = {
139
151
  hits: SearchHit<T>[];
140
152
  total: number;
141
153
  };
154
+ /** With `subindexes`: each subindex's results by name, or its error (0.22.0). */
155
+ subindexes?: Record<string, SearchResponse<Record<string, unknown>> | SubindexError>;
156
+ };
157
+ /** One search of a multi-index search (multiSearch): its index's public ID, the client's own when left out. */
158
+ export type MultiSearchQuery = SearchRequest & {
159
+ index?: string;
142
160
  };
161
+ /** One search's answer in a multi-index search: its results, or its error and status. */
162
+ export type MultiSearchResult<T> = (SearchResponse<T> & {
163
+ index: string;
164
+ }) | (SubindexError & {
165
+ index: string;
166
+ });
143
167
  /** A query rule a search fired, with its `userData`, if it has any. */
144
168
  export type AppliedRule = {
145
169
  id: string;
@@ -295,6 +319,12 @@ export type BrowserEvents = {
295
319
  position?: number;
296
320
  kind?: "typed" | "popular" | "recent";
297
321
  }): void;
322
+ /** A rule's banner was clicked: its rule (each banner in a response says which), the search's query, and
323
+ * its place. The Merchandising page shows each banner's clicks beside how often it was shown. */
324
+ bannerClick(rule: string, details?: {
325
+ query?: string;
326
+ position?: number;
327
+ }): void;
298
328
  addToCart(items: EventItem[], options?: {
299
329
  currency?: string;
300
330
  }): void;
@@ -338,6 +368,7 @@ export type BrowserClientOptions = {
338
368
  export declare class SearchBrowserClient {
339
369
  private readonly url;
340
370
  private readonly origin;
371
+ private readonly publicId;
341
372
  private readonly fetcher;
342
373
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
343
374
  readonly events: BrowserEvents;
@@ -353,6 +384,13 @@ export declare class SearchBrowserClient {
353
384
  search<T = Record<string, unknown>>(request: SearchRequest, options?: {
354
385
  signal?: AbortSignal;
355
386
  }): Promise<SearchResponse<T>>;
387
+ /** Several indexes searched in one request, such as products and a blog (0.22.0): up to 10 searches, each
388
+ * naming its index's public ID (this client's when left out), whose allowed sites must include the page's.
389
+ * Each counts as a search of its own; one that asks for its index's subindexes counts once with them. The
390
+ * answers come in the order asked, each its results or its error; it throws only when every search failed. */
391
+ multiSearch<T = Record<string, unknown>>(queries: MultiSearchQuery[], options?: {
392
+ signal?: AbortSignal;
393
+ }): Promise<MultiSearchResult<T>[]>;
356
394
  /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
357
395
  * request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
358
396
  * Ask once the shopper stops typing, not for every keystroke. */
package/dist/index.js CHANGED
@@ -137,6 +137,7 @@ function eventSender(url, fetcher, mode, ids) {
137
137
  },
138
138
  click: (objectID, details) => add({ type: "click", objectID, ...details }),
139
139
  suggestion: (query, details) => add({ type: "suggestion", query, ...details }),
140
+ bannerClick: (rule, details) => add({ type: "bannerClick", rule, ...details }),
140
141
  addToCart: (items, options) => add({ type: "addToCart", items, ...options }),
141
142
  purchase: (order) => add({ type: "purchase", ...order }),
142
143
  consent(granted) {
@@ -161,6 +162,7 @@ const warmed = new Set();
161
162
  export class SearchBrowserClient {
162
163
  url;
163
164
  origin;
165
+ publicId;
164
166
  fetcher;
165
167
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
166
168
  events;
@@ -170,6 +172,7 @@ export class SearchBrowserClient {
170
172
  constructor(options) {
171
173
  if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
172
174
  throw new Error("A valid public index ID (pub_…) is required");
175
+ this.publicId = options.publicId;
173
176
  this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
174
177
  this.origin = new URL(this.url).origin;
175
178
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
@@ -215,6 +218,24 @@ export class SearchBrowserClient {
215
218
  search(request, options = {}) {
216
219
  return this.post("search", request, options.signal);
217
220
  }
221
+ /** Several indexes searched in one request, such as products and a blog (0.22.0): up to 10 searches, each
222
+ * naming its index's public ID (this client's when left out), whose allowed sites must include the page's.
223
+ * Each counts as a search of its own; one that asks for its index's subindexes counts once with them. The
224
+ * answers come in the order asked, each its results or its error; it throws only when every search failed. */
225
+ async multiSearch(queries, options = {}) {
226
+ const response = await this.fetcher(`${this.url.slice(0, this.url.indexOf("/api/public/"))}/api/public/search`, {
227
+ method: "POST",
228
+ headers: { "content-type": "text/plain" },
229
+ body: JSON.stringify({ queries: queries.map(({ index, ...search }) => ({ index: index ?? this.publicId, ...search })) }),
230
+ signal: options.signal,
231
+ });
232
+ const data = await response.json();
233
+ if (!response.ok) {
234
+ const failed = data.results?.find((result) => "error" in result);
235
+ throw new FindlaneError(response.status, data.error ?? failed?.error ?? `HTTP ${response.status}`);
236
+ }
237
+ return data.results ?? [];
238
+ }
218
239
  /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
219
240
  * request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
220
241
  * Ask once the shopper stops typing, not for every keystroke. */
@@ -15,6 +15,8 @@ export type SearchBoxProps<T> = AutocompleteOptions<T> & {
15
15
  action?: string;
16
16
  /** A suggestion's content; DefaultHit shows an image and a title. */
17
17
  renderHit?: (hit: Hit<T>) => ReactNode;
18
+ /** A subindex result's content, by the subindex's name; DefaultHit by default (0.22.0). */
19
+ renderSubindexHit?: (subindex: string, hit: Hit<Record<string, unknown>>) => ReactNode;
18
20
  /** Shown when nothing matches (default "No results for “…”"). */
19
21
  empty?: (query: string) => ReactNode;
20
22
  /** The link to the results page (default "See all N results"). */
@@ -38,12 +40,12 @@ export declare const resultsHref: (action: string, query: string) => string;
38
40
  * suggested searches, then the results, "no results", or an error; then a link to the results page.
39
41
  * Each kind is a section of one list, which the arrow keys move through. Shared by SearchBox and
40
42
  * SearchDialog. */
41
- export declare function Suggestions<T>({ box, label, action, renderHit, empty, seeAll, labels }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "empty" | "seeAll" | "labels"> & {
43
+ export declare function Suggestions<T>({ box, label, action, renderHit, renderSubindexHit, empty, seeAll, labels }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "renderSubindexHit" | "empty" | "seeAll" | "labels"> & {
42
44
  box: ReturnType<typeof useAutocomplete<T>>;
43
45
  label: string;
44
46
  }): import("react").JSX.Element;
45
47
  /** A search box that suggests results as visitors type. Works on its own with a `publicId`. */
46
- export declare function SearchBox<T = Record<string, unknown>>({ placeholder, label, action, renderHit, empty, seeAll, labels, className, autoFocus, ...options }: SearchBoxProps<T>): import("react").JSX.Element;
48
+ export declare function SearchBox<T = Record<string, unknown>>({ placeholder, label, action, renderHit, renderSubindexHit, empty, seeAll, labels, className, autoFocus, ...options }: SearchBoxProps<T>): import("react").JSX.Element;
47
49
  /** The query input of the surrounding SearchProvider. */
48
50
  export declare function SearchInput({ placeholder, label, className, autoFocus }: {
49
51
  placeholder?: string;
@@ -73,7 +75,7 @@ export declare function DefaultBanner({ banner }: {
73
75
  banner: Banner;
74
76
  }): import("react").JSX.Element;
75
77
  /** The results. Following a link in a hit sends a click event, with its place and the query. Rules'
76
- * banners show at their places among the first page's hits. */
78
+ * banners show at their places among the first page's hits, and following a banner's link sends a banner click. */
77
79
  export declare function Hits<T = Record<string, unknown>>({ render, renderBanner, empty, relatedTitle, loading, className }: HitsProps<T>): import("react").JSX.Element;
78
80
  export type RefinementListProps = {
79
81
  field: string;
@@ -24,22 +24,35 @@ export const resultsHref = (action, query) => `${action}${action.includes("?") ?
24
24
  * suggested searches, then the results, "no results", or an error; then a link to the results page.
25
25
  * Each kind is a section of one list, which the arrow keys move through. Shared by SearchBox and
26
26
  * SearchDialog. */
27
- export function Suggestions({ box, label, action, renderHit, empty, seeAll, labels }) {
27
+ export function Suggestions({ box, label, action, renderHit, renderSubindexHit, empty, seeAll, labels }) {
28
28
  const { hits, query, total, error, items } = box;
29
29
  const text = { ...defaultLabels, ...labels };
30
- // Consecutive options of one kind make a section; results get a heading only below searches.
30
+ // Consecutive options of one kind make a section, each subindex its own; results get a heading only below others.
31
31
  const sections = [];
32
32
  for (const item of items) {
33
+ const key = item.kind === "subindex" ? `subindex-${item.subindex}` : item.kind;
33
34
  const last = sections.at(-1);
34
- if (last?.kind === item.kind)
35
+ if (last?.key === key)
35
36
  last.items.push(item);
36
37
  else
37
- sections.push({ kind: item.kind, items: [item] });
38
+ sections.push({ kind: item.kind, key, items: [item] });
38
39
  }
39
40
  const searching = query.trim() !== "";
40
41
  const headings = { recent: text.recent, popular: text.popular,
41
42
  suggestion: text.suggestions, hit: text.results };
43
+ const heading = (section) => {
44
+ const first = section.items[0];
45
+ if (first.kind !== "subindex")
46
+ return headings[first.kind];
47
+ return box.subindexLabel(first.subindex);
48
+ };
42
49
  const option = (item) => {
50
+ if (item.kind === "subindex") {
51
+ const href = box.hrefForSubindex(item);
52
+ const content = renderSubindexHit ? renderSubindexHit(item.subindex, item.hit) : _jsx(DefaultHit, { hit: item.hit });
53
+ const key = `${item.subindex}:${item.hit.id}`;
54
+ return href ? (_jsx("a", { href: href, tabIndex: -1, "data-findlane-subindex": item.subindex, ...box.getOptionProps(item.index), children: content }, key)) : (_jsx("div", { "data-findlane-subindex": item.subindex, ...box.getOptionProps(item.index), children: content }, key));
55
+ }
43
56
  if (item.kind === "hit") {
44
57
  const href = box.hrefFor(item.hit);
45
58
  const content = renderHit ? renderHit(item.hit) : _jsx(DefaultHit, { hit: item.hit });
@@ -51,17 +64,17 @@ export function Suggestions({ box, label, action, renderHit, empty, seeAll, labe
51
64
  };
52
65
  return (_jsxs("div", { "data-findlane-panel": "", children: [error ? _jsx("p", { "data-findlane-note": "", children: messageFor(error) }) : null, items.length > 0 && (_jsx("div", { ...box.getListboxProps({ "aria-label": label }), children: sections.map((section) => {
53
66
  const titled = section.kind !== "hit" || sections.length > 1;
54
- const headingId = `${box.getListboxProps().id}-${section.kind}`;
55
- return (_jsxs("div", { role: "group", "aria-labelledby": titled ? headingId : undefined, "aria-label": titled ? undefined : label, "data-findlane-section": section.kind, children: [titled && (_jsxs("div", { "data-findlane-section-title": "", id: headingId, role: "presentation", children: [_jsx("span", { children: headings[section.kind] }), section.kind === "recent" && (_jsx("button", { type: "button", tabIndex: -1, "data-findlane-clear": "", onClick: box.clearRecent, children: text.clear }))] })), section.items.map(option)] }, section.kind));
67
+ const headingId = `${box.getListboxProps().id}-${section.key}`;
68
+ return (_jsxs("div", { role: "group", "aria-labelledby": titled ? headingId : undefined, "aria-label": titled ? undefined : label, "data-findlane-section": section.kind, children: [titled && (_jsxs("div", { "data-findlane-section-title": "", id: headingId, role: "presentation", children: [_jsx("span", { children: heading(section) }), section.kind === "recent" && (_jsx("button", { type: "button", tabIndex: -1, "data-findlane-clear": "", onClick: box.clearRecent, children: text.clear }))] })), section.items.map(option)] }, section.key));
56
69
  }) })), searching && !error && !hits.length && box.response && (_jsx("p", { "data-findlane-note": "", children: empty ? empty(query) : _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) })), action && searching && 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` }))] }));
57
70
  }
58
71
  /** A search box that suggests results as visitors type. Works on its own with a `publicId`. */
59
- export function SearchBox({ placeholder, label = "Search", action, renderHit, empty, seeAll, labels, className, autoFocus, ...options }) {
72
+ export function SearchBox({ placeholder, label = "Search", action, renderHit, renderSubindexHit, empty, seeAll, labels, className, autoFocus, ...options }) {
60
73
  // A search chosen from the suggestions goes to the results page, as Enter does.
61
74
  const box = useAutocomplete({ ...options,
62
75
  onSubmit: options.onSubmit ?? (action ? (query) => window.location.assign(resultsHref(action, query)) : undefined) });
63
76
  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)
64
- 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, labels: labels }))] }));
77
+ 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, renderSubindexHit: renderSubindexHit, empty: empty, seeAll: seeAll, labels: labels }))] }));
65
78
  }
66
79
  /** The query input of the surrounding SearchProvider. */
67
80
  export function SearchInput({ placeholder, label = "Search", className, autoFocus }) {
@@ -79,9 +92,9 @@ export function DefaultBanner({ banner }) {
79
92
  return banner.link ? _jsx("a", { href: banner.link, children: content }) : content;
80
93
  }
81
94
  /** The results. Following a link in a hit sends a click event, with its place and the query. Rules'
82
- * banners show at their places among the first page's hits. */
95
+ * banners show at their places among the first page's hits, and following a banner's link sends a banner click. */
83
96
  export function Hits({ render, renderBanner, empty, relatedTitle, loading = null, className }) {
84
- const { hits, related, isLoaded, isPending, error, click, response, request } = useHits();
97
+ const { hits, related, isLoaded, isPending, error, click, bannerClick, response, request } = useHits();
85
98
  const { query } = useSearchBox();
86
99
  if (error && !isLoaded)
87
100
  return _jsx("p", { "data-findlane-note": "", children: messageFor(error) });
@@ -94,7 +107,13 @@ export function Hits({ render, renderBanner, empty, relatedTitle, loading = null
94
107
  // Banners go before the hit at their place, on the first page; past the last hit, after it.
95
108
  const banners = hits.length && !request?.offset ? response?.banners ?? [] : [];
96
109
  const bannerAt = (place) => banners.filter((banner) => Math.min(Math.max(banner.position ?? 1, 1), shown.length) === place);
97
- const bannerItems = (place) => bannerAt(place).map((banner, at) => (_jsx("li", { "data-findlane-banner": "", children: renderBanner ? renderBanner(banner) : _jsx(DefaultBanner, { banner: banner }) }, `banner-${banner.rule}-${at}`)));
110
+ // Following a banner's link sends a banner click, as a hit's sends a click.
111
+ const bannerItems = (place) => bannerAt(place).map((banner, at) => {
112
+ const clicked = (event) => { if (!event.defaultPrevented && onLink(event))
113
+ bannerClick(banner); };
114
+ return (_jsx("li", { "data-findlane-banner": "", onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
115
+ clicked(event); }, children: renderBanner ? renderBanner(banner) : _jsx(DefaultBanner, { banner: banner }) }, `banner-${banner.rule}-${at}`));
116
+ });
98
117
  const list = (_jsx("ol", { className: className, "data-findlane-hits": "", "data-findlane-related": !hits.length || undefined, "data-pending": isPending || undefined, children: shown.map((hit, index) => {
99
118
  // A click, and a middle click that opens the link in a new tab.
100
119
  const clicked = (event) => { if (!event.defaultPrevented && onLink(event))
@@ -10,4 +10,4 @@ export type SearchDialogProps<T> = Omit<SearchBoxProps<T>, "autoFocus"> & {
10
10
  };
11
11
  /** A search button that opens a dialog with a search box, as on docs sites; ⌘K and "/" open it too.
12
12
  * The dialog is the browser's own, so focus stays in it and the page behind can't be used. */
13
- export declare function SearchDialog<T = Record<string, unknown>>({ trigger, shortcuts, footer, placeholder, label, action, renderHit, empty, seeAll, labels, className, ...options }: SearchDialogProps<T>): import("react").JSX.Element;
13
+ export declare function SearchDialog<T = Record<string, unknown>>({ trigger, shortcuts, footer, placeholder, label, action, renderHit, renderSubindexHit, empty, seeAll, labels, className, ...options }: SearchDialogProps<T>): import("react").JSX.Element;
@@ -6,7 +6,7 @@ import { useAutocomplete } from "./use-autocomplete.js";
6
6
  const onMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
7
7
  /** A search button that opens a dialog with a search box, as on docs sites; ⌘K and "/" open it too.
8
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, footer, placeholder, label = "Search", action, renderHit, empty, seeAll, labels, className, ...options }) {
9
+ export function SearchDialog({ trigger, shortcuts = true, footer, placeholder, label = "Search", action, renderHit, renderSubindexHit, empty, seeAll, labels, className, ...options }) {
10
10
  const dialog = useRef(null);
11
11
  const [open, setOpen] = useState(false);
12
12
  const close = () => dialog.current?.close();
@@ -59,5 +59,5 @@ export function SearchDialog({ trigger, shortcuts = true, footer, placeholder, l
59
59
  close();
60
60
  }
61
61
  },
62
- }) }) }), box.isOpen && (_jsx(Suggestions, { box: box, label: label, action: action, renderHit: renderHit, empty: empty, seeAll: seeAll, labels: labels }))] })), open && footer && _jsx("div", { "data-findlane-dialog-footer": "", children: footer })] })] }));
62
+ }) }) }), box.isOpen && (_jsx(Suggestions, { box: box, label: label, action: action, renderHit: renderHit, renderSubindexHit: renderSubindexHit, empty: empty, seeAll: seeAll, labels: labels }))] })), open && footer && _jsx("div", { "data-findlane-dialog-footer": "", children: footer })] })] }));
63
63
  }
@@ -1,6 +1,6 @@
1
1
  export { FindlaneProvider, useClient, type ClientOptions } from "./client.js";
2
2
  export { useSearch, type UseSearchOptions, type UseSearchResult } from "./use-search.js";
3
- export { useAutocomplete, type AutocompleteItem, type AutocompleteOptions } from "./use-autocomplete.js";
3
+ export { useAutocomplete, type AutocompleteItem, type AutocompleteOptions, type AutocompleteSubindex } from "./use-autocomplete.js";
4
4
  export { useRecommendations, type UseRecommendationsOptions, type UseRecommendationsResult } from "./use-recommendations.js";
5
5
  export { Recommendations, type RecommendationsProps } from "./recommendations.js";
6
6
  export { ProductView, useProductView } from "./product-view.js";
@@ -54,6 +54,11 @@ export declare function useHits<T = Record<string, unknown>>(): {
54
54
  click: (hit: {
55
55
  id: string;
56
56
  }) => void;
57
+ /** Sends a click on a rule's banner. Hits does it for links in a banner; call it from your own banner. */
58
+ bannerClick: (banner: {
59
+ rule: string;
60
+ position?: number;
61
+ }) => void;
57
62
  };
58
63
  /** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
59
64
  * corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
@@ -99,6 +99,8 @@ export function useHits() {
99
99
  /** Sends a click event for a hit, with its place and the query, for click-through in Analytics.
100
100
  * Hits does it for links in a hit; call it from a hit rendered without a link. */
101
101
  click: (hit) => store.click(hit.id),
102
+ /** Sends a click on a rule's banner. Hits does it for links in a banner; call it from your own banner. */
103
+ bannerClick: (banner) => store.bannerClick(banner),
102
104
  };
103
105
  }
104
106
  /** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
@@ -65,6 +65,11 @@ export declare class SearchStore<T = unknown> {
65
65
  /** Sends a click on a result: its place in the results, from 1, and the query they're for. A related
66
66
  * result's click says so (list "related"), with its place among them. */
67
67
  click(objectID: string): void;
68
+ /** Sends a click on a rule's banner, with the query it showed for. */
69
+ bannerClick(banner: {
70
+ rule: string;
71
+ position?: number;
72
+ }): void;
68
73
  /** Searches unless the results already answer the current state; with a state, such as the
69
74
  * URL's, adopts it first. */
70
75
  start(state?: SearchState): void;
@@ -77,6 +77,11 @@ export class SearchStore {
77
77
  ...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}),
78
78
  ...(related >= 0 ? { position: related + 1, list: "related" } : {}) });
79
79
  }
80
+ /** Sends a click on a rule's banner, with the query it showed for. */
81
+ bannerClick(banner) {
82
+ this.client.events.bannerClick(banner.rule, { query: this.snapshot.results?.request.q ?? "",
83
+ ...(banner.position ? { position: banner.position } : {}) });
84
+ }
80
85
  /** Searches unless the results already answer the current state; with a state, such as the
81
86
  * URL's, adopts it first. */
82
87
  start(state) {
@@ -34,6 +34,20 @@ export type AutocompleteOptions<T> = ClientOptions & {
34
34
  /** The shopper's recent searches when the box is empty, kept in this browser only: true for the last
35
35
  * five, or how many (up to 10). Off unless set. */
36
36
  recentSearches?: boolean | number;
37
+ /** The index's subindexes to show above the results, such as a store's categories and pages, by name, in
38
+ * the order given (0.22.0). They're searched in the same request as the results, and count as one search
39
+ * with them. */
40
+ subindexes?: Record<string, AutocompleteSubindex>;
41
+ };
42
+ /** A subindex in the suggestions: how many of its results to show (1–10, default 3), the fields to return,
43
+ * its results' pages, and its section's heading (default its name). */
44
+ export type AutocompleteSubindex = {
45
+ limit?: number;
46
+ fields?: string[];
47
+ /** A result's page, as `hrefTemplate` takes it, such as "{url}". */
48
+ hrefTemplate?: string;
49
+ getHref?: (hit: Hit<Record<string, unknown>>) => string | undefined;
50
+ label?: string;
37
51
  };
38
52
  /** One of the combobox's options, in the order shown: a search to run (suggested for what was typed, or
39
53
  * with an empty box a recent or popular one), or a result. `index` is its place among all of them, for
@@ -51,7 +65,15 @@ export type AutocompleteItem<T> = {
51
65
  kind: "hit";
52
66
  hit: Hit<T>;
53
67
  index: number;
68
+ } | {
69
+ kind: "subindex";
70
+ subindex: string;
71
+ hit: Hit<Record<string, unknown>>;
72
+ index: number;
54
73
  };
74
+ type SubindexItem = Extract<AutocompleteItem<unknown>, {
75
+ kind: "subindex";
76
+ }>;
55
77
  /** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
56
78
  export declare function fillTemplate<T>(template: string, hit: Hit<T>): string | undefined;
57
79
  /** A search box with suggestions, as the WAI-ARIA combobox pattern: the hook keeps the state and
@@ -73,8 +95,14 @@ export declare function useAutocomplete<T = Record<string, unknown>>(options?: A
73
95
  setActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
74
96
  close: () => void;
75
97
  select: (hit: Hit<T>) => void;
76
- /** Every option in the order shown: searches, then results. */
98
+ /** Every option in the order shown: searches, then subindexes' results, then the index's. */
77
99
  items: AutocompleteItem<T>[];
100
+ /** Each subindex's results, by name (0.22.0). */
101
+ subindexes: Record<string, Hit<Record<string, unknown>>[]>;
102
+ /** A subindex result's page, from its `getHref` or `hrefTemplate`. */
103
+ hrefForSubindex: (item: SubindexItem) => string | undefined;
104
+ /** A subindex's section heading: its `label`, or its name with a capital. */
105
+ subindexLabel: (name: string) => string;
78
106
  /** Query suggestions for what's typed, recent searches, and popular searches, as the API gave them. */
79
107
  suggestions: QuerySuggestion[];
80
108
  recent: string[];
@@ -92,3 +120,4 @@ export declare function useAutocomplete<T = Record<string, unknown>>(options?: A
92
120
  * only results, a result's place among the hits. */
93
121
  getOptionProps: (index: number, props?: HTMLAttributes<HTMLElement>) => HTMLAttributes<HTMLElement>;
94
122
  };
123
+ export {};
@@ -25,6 +25,7 @@ export function useAutocomplete(options = {}) {
25
25
  const popularLimit = Math.min(Math.max(options.popularSearches ?? 0, 0), 10);
26
26
  const recentLimit = options.recentSearches === true ? 5 : Math.min(Math.max(Number(options.recentSearches) || 0, 0), 10);
27
27
  const getHref = options.getHref ?? (hrefTemplate ? (hit) => fillTemplate(hrefTemplate, hit) : undefined);
28
+ const subindexes = Object.entries(options.subindexes ?? {}).slice(0, 5);
28
29
  const generated = useId();
29
30
  const id = options.id ?? `findlane${generated.replace(/:/g, "")}`;
30
31
  const [query, setQueryState] = useState(options.defaultQuery ?? "");
@@ -39,6 +40,9 @@ export function useAutocomplete(options = {}) {
39
40
  ...(fields ? { attributesToRetrieve: fields } : {}),
40
41
  ...(filters ? { filters } : {}),
41
42
  ...(sort ? { sort } : {}),
43
+ ...(subindexes.length ? { subindexes: Object.fromEntries(subindexes.map(([name, sub]) => [name, {
44
+ limit: Math.min(Math.max(sub.limit ?? 3, 1), 10), ...(sub.fields ? { attributesToRetrieve: sub.fields } : {})
45
+ }])) } : {}),
42
46
  }
43
47
  : null;
44
48
  const search = useSearch(request, {
@@ -101,8 +105,21 @@ export function useAutocomplete(options = {}) {
101
105
  for (const item of suggestions)
102
106
  searches.push({ kind: "suggestion", q: item.q, typed: item.typed, index: searches.length });
103
107
  }
104
- const items = [...searches,
105
- ...hits.map((hit, position) => ({ kind: "hit", hit, index: searches.length + position }))];
108
+ // Then the subindexes' results, each a section of its own, then the index's.
109
+ const subindexHits = {};
110
+ const found = [];
111
+ for (const [name] of subindexes) {
112
+ const answer = search.data?.subindexes?.[name];
113
+ subindexHits[name] = answer && "hits" in answer ? answer.hits : [];
114
+ for (const hit of subindexHits[name])
115
+ found.push({ kind: "subindex", subindex: name, hit, index: searches.length + found.length });
116
+ }
117
+ const items = [...searches, ...found,
118
+ ...hits.map((hit, position) => ({ kind: "hit", hit, index: searches.length + found.length + position }))];
119
+ const subindexHref = (item) => {
120
+ const sub = options.subindexes?.[item.subindex];
121
+ return sub?.getHref ? sub.getHref(item.hit) : sub?.hrefTemplate ? fillTemplate(sub.hrefTemplate, item.hit) : undefined;
122
+ };
106
123
  // Open once there's something to show: searches, results, "no results", or an error.
107
124
  const isOpen = focused && !dismissed && (emptyBox ? searches.length > 0
108
125
  : request !== null && (search.data !== null || search.error !== null || searches.length > 0));
@@ -151,12 +168,22 @@ export function useAutocomplete(options = {}) {
151
168
  onSubmit(item.q);
152
169
  }
153
170
  };
171
+ /** Follows a subindex's result to its page. */
172
+ const visit = (item) => {
173
+ remember(query);
174
+ close();
175
+ const href = subindexHref(item);
176
+ if (href)
177
+ window.location.assign(href);
178
+ };
154
179
  const choose = (index) => {
155
180
  const item = items[index];
156
181
  if (!item)
157
182
  return;
158
183
  if (item.kind === "hit")
159
184
  select(item.hit);
185
+ else if (item.kind === "subindex")
186
+ visit(item);
160
187
  else
161
188
  pick(item);
162
189
  };
@@ -215,8 +242,14 @@ export function useAutocomplete(options = {}) {
215
242
  setActiveIndex,
216
243
  close,
217
244
  select,
218
- /** Every option in the order shown: searches, then results. */
245
+ /** Every option in the order shown: searches, then subindexes' results, then the index's. */
219
246
  items,
247
+ /** Each subindex's results, by name (0.22.0). */
248
+ subindexes: subindexHits,
249
+ /** A subindex result's page, from its `getHref` or `hrefTemplate`. */
250
+ hrefForSubindex: (item) => subindexHref(item),
251
+ /** A subindex's section heading: its `label`, or its name with a capital. */
252
+ subindexLabel: (name) => options.subindexes?.[name]?.label ?? name.charAt(0).toUpperCase() + name.slice(1),
220
253
  /** Query suggestions for what's typed, recent searches, and popular searches, as the API gave them. */
221
254
  suggestions,
222
255
  recent,
@@ -294,6 +327,17 @@ export function useAutocomplete(options = {}) {
294
327
  const item = items[index];
295
328
  if (event.defaultPrevented || !item)
296
329
  return;
330
+ if (item.kind === "subindex") {
331
+ // A link is followed as usual; one opened in a new tab or window is left to the browser.
332
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
333
+ return;
334
+ if (event.currentTarget.href) {
335
+ remember(query);
336
+ return close();
337
+ }
338
+ event.preventDefault();
339
+ return visit(item);
340
+ }
297
341
  if (item.kind !== "hit") {
298
342
  event.preventDefault();
299
343
  return pick(item);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Findlane browser SDK: fast, typo-tolerant public search from the browser with an index's public ID, plus React hooks and components.",
5
5
  "keywords": ["findlane", "search", "full-text search", "typo tolerance", "instant search", "ecommerce search", "browser", "react", "autocomplete"],
6
6
  "license": "MIT",