@findlane/browser 0.21.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>;
142
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;
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;
@@ -344,6 +368,7 @@ export type BrowserClientOptions = {
344
368
  export declare class SearchBrowserClient {
345
369
  private readonly url;
346
370
  private readonly origin;
371
+ private readonly publicId;
347
372
  private readonly fetcher;
348
373
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
349
374
  readonly events: BrowserEvents;
@@ -359,6 +384,13 @@ export declare class SearchBrowserClient {
359
384
  search<T = Record<string, unknown>>(request: SearchRequest, options?: {
360
385
  signal?: AbortSignal;
361
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>[]>;
362
394
  /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
363
395
  * request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
364
396
  * Ask once the shopper stops typing, not for every keystroke. */
package/dist/index.js CHANGED
@@ -162,6 +162,7 @@ const warmed = new Set();
162
162
  export class SearchBrowserClient {
163
163
  url;
164
164
  origin;
165
+ publicId;
165
166
  fetcher;
166
167
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
167
168
  events;
@@ -171,6 +172,7 @@ export class SearchBrowserClient {
171
172
  constructor(options) {
172
173
  if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
173
174
  throw new Error("A valid public index ID (pub_…) is required");
175
+ this.publicId = options.publicId;
174
176
  this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
175
177
  this.origin = new URL(this.url).origin;
176
178
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
@@ -216,6 +218,24 @@ export class SearchBrowserClient {
216
218
  search(request, options = {}) {
217
219
  return this.post("search", request, options.signal);
218
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
+ }
219
239
  /** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
220
240
  * request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
221
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;
@@ -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 }) {
@@ -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";
@@ -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.21.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",