@findlane/browser 0.13.0 → 0.15.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
@@ -31,15 +31,26 @@ 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
+ ## Query suggestions
35
+
36
+ Whole searches to offer as visitors type, and the index's popular searches for an empty box. They don't count as searches. Recent searches stay in the visitor's browser.
37
+
38
+ ```ts
39
+ await client.querySuggestions({ q: "hrek" }) // [{ q: "hrekkjavöku búningar", typed: [0, 4] }]
40
+ await client.popularSearches() // [{ q: "gríma" }, { q: "latex" }]
41
+ client.recentSearches.add("hárkolla")
42
+ ```
43
+
34
44
  ## React
35
45
 
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.
46
+ `@findlane/browser/react` has a search box that suggests searches and 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
47
 
38
48
  ```tsx
39
49
  import { SearchBox } from "@findlane/browser/react"
40
50
  import "@findlane/browser/react/styles.css" // optional
41
51
 
42
- <SearchBox publicId="pub_your_public_id" getHref={(hit) => hit.record.url} action="/search" />
52
+ <SearchBox publicId="pub_your_public_id" getHref={(hit) => hit.record.url} action="/search"
53
+ querySuggestions={4} popularSearches={6} recentSearches />
43
54
  ```
44
55
 
45
56
  `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. On a product page, `useProductView(objectID)`, or `<ProductView>` in a server component, sends the page's view for the index's Events page. Docs: https://findlane.dev/docs/react
package/dist/index.d.ts CHANGED
@@ -74,6 +74,9 @@ export type SearchResponse<T> = {
74
74
  totalRelation?: "eq" | "gte";
75
75
  hasMore?: boolean;
76
76
  facets: Record<string, Record<string, number>>;
77
+ /** True when the facet counts are estimates: on an index of 100,000 records or more, a search with more
78
+ * than 2,000 matches counts its facets on a sample of them, scaled to the total. */
79
+ facetsEstimated?: boolean;
77
80
  /** With `facetStats`: each field's smallest and largest value across the matches, without the field's
78
81
  * own filter. A field no match has is left out. */
79
82
  facetStats?: Record<string, {
@@ -109,6 +112,31 @@ export type DidYouMean = {
109
112
  to: string;
110
113
  }[];
111
114
  };
115
+ /** A whole search to offer as a shopper types, or in an empty box (query suggestions). `typed`: where
116
+ * what was typed is in `q`, as UTF-16 offsets (end exclusive), so the rest can be set apart, as Google
117
+ * bolds it; popular searches have none. */
118
+ export type QuerySuggestion = {
119
+ q: string;
120
+ typed?: [number, number];
121
+ };
122
+ /** A query suggestion's text in parts: what was typed (`typed: true`) and the rest. */
123
+ export declare function suggestionParts(suggestion: QuerySuggestion): {
124
+ text: string;
125
+ typed: boolean;
126
+ }[];
127
+ /** The shopper's recent searches, kept in this browser only (localStorage, per public ID) and never sent
128
+ * to Findlane: the newest first, without repeats, up to 10. Add a search when it's submitted, picked from
129
+ * suggestions, or leads to a result being opened, not on every keystroke. */
130
+ export declare class RecentSearches {
131
+ private readonly key;
132
+ constructor(publicId: string);
133
+ /** Up to `limit` of them (5 by default), the newest first. */
134
+ list(limit?: number): string[];
135
+ add(q: string): void;
136
+ remove(q: string): void;
137
+ clear(): void;
138
+ private save;
139
+ }
112
140
  /** How a product's recommendations are served (the recommendations add-on). */
113
141
  export type RecommendationsRequest = {
114
142
  /** Up to this many products in each list, 1 to 20; 8 by default. */
@@ -145,6 +173,45 @@ export type RecommendationsResponse<T> = {
145
173
  /** What `together`'s counts are of. */
146
174
  togetherBasis?: "orders";
147
175
  };
176
+ /** One of an index's lists, which its owner sets up in the dashboard: a row of products, such as Popular or
177
+ * New arrivals, from a filter and a sort, with products pinned first and others hidden; or a facet field's
178
+ * values with how many products each has, for a menu. `updatedAt` is when what the lists hold last changed. */
179
+ export type ProductList<T = Record<string, unknown>> = {
180
+ id: string;
181
+ title: string;
182
+ kind: "products";
183
+ hits: {
184
+ id: string;
185
+ record: T;
186
+ }[];
187
+ /** The list's filters and sort, for a "See all" link to the same products as a search. */
188
+ browse: {
189
+ sort: string;
190
+ filters: Record<string, unknown>;
191
+ };
192
+ updatedAt: string;
193
+ };
194
+ export type ValueList = {
195
+ id: string;
196
+ title: string;
197
+ kind: "values";
198
+ /** The facet field the values are of. */
199
+ field: string;
200
+ values: {
201
+ value: string;
202
+ count: number;
203
+ }[];
204
+ browse: {
205
+ filters: Record<string, unknown>;
206
+ };
207
+ updatedAt: string;
208
+ };
209
+ export type List<T = Record<string, unknown>> = ProductList<T> | ValueList;
210
+ export type ListOptions = {
211
+ /** At most this many products or values from each list (each list's own limit otherwise). */
212
+ limit?: number;
213
+ signal?: AbortSignal;
214
+ };
148
215
  export declare class FindlaneError extends Error {
149
216
  status: number;
150
217
  constructor(status: number, message: string);
@@ -170,6 +237,12 @@ export type BrowserEvents = {
170
237
  * (React's double mount in development, a re-render), so it isn't sent. */
171
238
  view(objectID: string): void;
172
239
  click(objectID: string, details?: ClickDetails): void;
240
+ /** A query suggestion was picked: the search, its place in its list (from 1), and whether it was
241
+ * suggested for what was typed, or was a popular or recent search in an empty box. */
242
+ suggestion(query: string, details?: {
243
+ position?: number;
244
+ kind?: "typed" | "popular" | "recent";
245
+ }): void;
173
246
  addToCart(items: EventItem[], options?: {
174
247
  currency?: string;
175
248
  }): void;
@@ -215,8 +288,12 @@ export declare class SearchBrowserClient {
215
288
  private readonly fetcher;
216
289
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
217
290
  readonly events: BrowserEvents;
291
+ /** The shopper's recent searches, kept in this browser only. */
292
+ readonly recentSearches: RecentSearches;
293
+ private popular;
218
294
  constructor(options: BrowserClientOptions);
219
295
  private post;
296
+ private get;
220
297
  search<T = Record<string, unknown>>(request: SearchRequest, options?: {
221
298
  signal?: AbortSignal;
222
299
  }): Promise<SearchResponse<T>>;
@@ -226,10 +303,32 @@ export declare class SearchBrowserClient {
226
303
  suggest(request: SearchRequest, options?: {
227
304
  signal?: AbortSignal;
228
305
  }): Promise<DidYouMean | null>;
306
+ /** Query suggestions: whole searches to offer as the shopper types `q`, the best first, from what
307
+ * shoppers search and the catalog's words and names. Not counted as searches, and answered from memory
308
+ * near the shopper, so ask on each pause in typing. `limit`: 1 to 10, 5 by default. */
309
+ querySuggestions(request: {
310
+ q: string;
311
+ limit?: number;
312
+ }, options?: {
313
+ signal?: AbortSignal;
314
+ }): Promise<QuerySuggestion[]>;
315
+ /** The index's popular searches, for an empty search box: what shoppers search most lately, with the
316
+ * owner's pinned searches first. Empty until the index has enough searches. The list changes daily,
317
+ * so this client asks once per `limit` and keeps the answer. */
318
+ popularSearches(options?: {
319
+ limit?: number;
320
+ }): Promise<QuerySuggestion[]>;
229
321
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar
230
322
  * products, for a product page or a cart. Not counted as a search. */
231
323
  recommendations<T = Record<string, unknown>>(objectID: string, request?: RecommendationsRequest, options?: {
232
324
  signal?: AbortSignal;
233
325
  }): Promise<RecommendationsResponse<T>>;
326
+ /** One of the index's lists, by its ID: a row of products, such as Popular, or a facet field's values for
327
+ * a menu. Built ahead and answered from a cache near the shopper, so it isn't a search and isn't counted.
328
+ * Throws a FindlaneError with status 404 when the index has no such list. */
329
+ list<T = Record<string, unknown>>(id: string, options?: ListOptions): Promise<List<T>>;
330
+ /** Several of the index's lists in one request (up to 10), in the order asked; IDs the index has no list
331
+ * for are left out. Without IDs, all of its lists, in the owner's order. Not counted as searches. */
332
+ lists<T = Record<string, unknown>>(ids?: string[], options?: ListOptions): Promise<List<T>[]>;
234
333
  }
235
334
  export {};
package/dist/index.js CHANGED
@@ -1,3 +1,49 @@
1
+ /** A query suggestion's text in parts: what was typed (`typed: true`) and the rest. */
2
+ export function suggestionParts(suggestion) {
3
+ const [start, end] = suggestion.typed ?? [0, 0];
4
+ return [{ text: suggestion.q.slice(0, start), typed: false }, { text: suggestion.q.slice(start, end), typed: true },
5
+ { text: suggestion.q.slice(end), typed: false }].filter((part) => part.text);
6
+ }
7
+ /** The shopper's recent searches, kept in this browser only (localStorage, per public ID) and never sent
8
+ * to Findlane: the newest first, without repeats, up to 10. Add a search when it's submitted, picked from
9
+ * suggestions, or leads to a result being opened, not on every keystroke. */
10
+ export class RecentSearches {
11
+ key;
12
+ constructor(publicId) {
13
+ this.key = `findlane:recent:${publicId}`;
14
+ }
15
+ /** Up to `limit` of them (5 by default), the newest first. */
16
+ list(limit = 5) {
17
+ try {
18
+ const value = JSON.parse(localStorage.getItem(this.key) ?? "[]");
19
+ return Array.isArray(value) ? value.filter((item) => typeof item == "string").slice(0, limit) : [];
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }
25
+ add(q) {
26
+ const query = q.trim().replace(/\s+/g, " ");
27
+ if (!query || query.length > 100)
28
+ return;
29
+ this.save([query, ...this.list(10).filter((item) => item.toLowerCase() !== query.toLowerCase())]);
30
+ }
31
+ remove(q) {
32
+ this.save(this.list(10).filter((item) => item !== q));
33
+ }
34
+ clear() {
35
+ try {
36
+ localStorage.removeItem(this.key);
37
+ }
38
+ catch { /* storage that can't be used keeps nothing */ }
39
+ }
40
+ save(items) {
41
+ try {
42
+ localStorage.setItem(this.key, JSON.stringify(items.slice(0, 10)));
43
+ }
44
+ catch { /* as above */ }
45
+ }
46
+ }
1
47
  export class FindlaneError extends Error {
2
48
  status;
3
49
  constructor(status, message) {
@@ -90,6 +136,7 @@ function eventSender(url, fetcher, mode, ids) {
90
136
  add({ type: "view", objectID });
91
137
  },
92
138
  click: (objectID, details) => add({ type: "click", objectID, ...details }),
139
+ suggestion: (query, details) => add({ type: "suggestion", query, ...details }),
93
140
  addToCart: (items, options) => add({ type: "addToCart", items, ...options }),
94
141
  purchase: (order) => add({ type: "purchase", ...order }),
95
142
  consent(granted) {
@@ -114,12 +161,16 @@ export class SearchBrowserClient {
114
161
  fetcher;
115
162
  /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
116
163
  events;
164
+ /** The shopper's recent searches, kept in this browser only. */
165
+ recentSearches;
166
+ popular = new Map();
117
167
  constructor(options) {
118
168
  if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
119
169
  throw new Error("A valid public index ID (pub_…) is required");
120
170
  this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
121
171
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
122
172
  this.events = eventSender(options.eventsUrl ?? `${this.url}/events`, options.fetch, options.events ?? true, options.visitorIds ? deviceIds(options.publicId) : null);
173
+ this.recentSearches = new RecentSearches(options.publicId);
123
174
  }
124
175
  async post(operation, request, signal) {
125
176
  const response = await this.fetcher(`${this.url}/${operation}`, {
@@ -135,6 +186,15 @@ export class SearchBrowserClient {
135
186
  throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
136
187
  return data;
137
188
  }
189
+ // Lists are read with GET, which browsers send without a preflight and may keep for 30 seconds.
190
+ async get(path, params, signal) {
191
+ const query = new URLSearchParams(params).toString();
192
+ const response = await this.fetcher(`${this.url}/${path}${query ? `?${query}` : ""}`, { method: "GET", signal });
193
+ const data = await response.json();
194
+ if (!response.ok)
195
+ throw new FindlaneError(response.status, data.error ?? `HTTP ${response.status}`);
196
+ return data;
197
+ }
138
198
  search(request, options = {}) {
139
199
  return this.post("search", request, options.signal);
140
200
  }
@@ -144,9 +204,40 @@ export class SearchBrowserClient {
144
204
  async suggest(request, options = {}) {
145
205
  return (await this.post("suggest", request, options.signal)).didYouMean;
146
206
  }
207
+ /** Query suggestions: whole searches to offer as the shopper types `q`, the best first, from what
208
+ * shoppers search and the catalog's words and names. Not counted as searches, and answered from memory
209
+ * near the shopper, so ask on each pause in typing. `limit`: 1 to 10, 5 by default. */
210
+ async querySuggestions(request, options = {}) {
211
+ return (await this.post("query-suggestions", request, options.signal)).suggestions;
212
+ }
213
+ /** The index's popular searches, for an empty search box: what shoppers search most lately, with the
214
+ * owner's pinned searches first. Empty until the index has enough searches. The list changes daily,
215
+ * so this client asks once per `limit` and keeps the answer. */
216
+ popularSearches(options = {}) {
217
+ const limit = options.limit ?? 6;
218
+ let asked = this.popular.get(limit);
219
+ if (!asked) {
220
+ asked = this.post("query-suggestions", { q: "", limit }).then((data) => data.suggestions);
221
+ asked.catch(() => this.popular.delete(limit));
222
+ this.popular.set(limit, asked);
223
+ }
224
+ return asked;
225
+ }
147
226
  /** A product's recommendations (the recommendations add-on): products that go with it, and similar
148
227
  * products, for a product page or a cart. Not counted as a search. */
149
228
  recommendations(objectID, request = {}, options = {}) {
150
229
  return this.post("recommendations", { objectID, ...request }, options.signal);
151
230
  }
231
+ /** One of the index's lists, by its ID: a row of products, such as Popular, or a facet field's values for
232
+ * a menu. Built ahead and answered from a cache near the shopper, so it isn't a search and isn't counted.
233
+ * Throws a FindlaneError with status 404 when the index has no such list. */
234
+ list(id, options = {}) {
235
+ return this.get(`lists/${encodeURIComponent(id)}`, options.limit ? { limit: String(options.limit) } : {}, options.signal);
236
+ }
237
+ /** Several of the index's lists in one request (up to 10), in the order asked; IDs the index has no list
238
+ * for are left out. Without IDs, all of its lists, in the owner's order. Not counted as searches. */
239
+ async lists(ids, options = {}) {
240
+ return (await this.get("lists", { ...(ids ? { ids: ids.join(",") } : {}),
241
+ ...(options.limit ? { limit: String(options.limit) } : {}) }, options.signal)).lists;
242
+ }
152
243
  }
@@ -1,9 +1,12 @@
1
- import type { RecommendationsRequest, RecommendationsResponse, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
1
+ import type { QuerySuggestion, RecommendationsRequest, RecommendationsResponse, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
2
2
  /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
3
3
  * the query's first eight lowercase words, defaults left out, and list order ignored. */
4
4
  export declare function searchKey(request: SearchRequest): string;
5
5
  /** A product's recommendations share a key when only defaults differ: the limit of 8 and empty filters. */
6
6
  export declare function recommendationsKey(objectID: string, request?: RecommendationsRequest): string;
7
+ /** Query suggestion requests that the API answers the same way share a key: the text in lowercase with
8
+ * single spaces, and whether a space ends it (which makes the last word whole). */
9
+ export declare const suggestionsKey: (q: string, limit: number) => string;
7
10
  /** A cached response, if there's a recent one. */
8
11
  export declare function peek<T>(client: SearchBrowserClient, request: SearchRequest): SearchResponse<T> | null;
9
12
  export declare function peekRecommendations<T>(client: SearchBrowserClient, objectID: string, request?: RecommendationsRequest): RecommendationsResponse<T> | null;
@@ -12,3 +15,6 @@ export declare function peekRecommendations<T>(client: SearchBrowserClient, obje
12
15
  export declare function cachedSearch<T>(client: SearchBrowserClient, request: SearchRequest, signal?: AbortSignal): Promise<SearchResponse<T>>;
13
16
  /** A product's recommendations, kept and shared the same way. */
14
17
  export declare function cachedRecommendations<T>(client: SearchBrowserClient, objectID: string, request?: RecommendationsRequest, signal?: AbortSignal): Promise<RecommendationsResponse<T>>;
18
+ /** Query suggestions for what was typed, kept and shared the same way. */
19
+ export declare function peekQuerySuggestions(client: SearchBrowserClient, q: string, limit: number): QuerySuggestion[] | null;
20
+ export declare function cachedQuerySuggestions(client: SearchBrowserClient, q: string, limit: number, signal?: AbortSignal): Promise<QuerySuggestion[]>;
@@ -4,6 +4,7 @@ const maxAge = 5 * 60_000;
4
4
  const store = () => ({ responses: new WeakMap(), flights: new WeakMap() });
5
5
  const searches = store();
6
6
  const recommendations = store();
7
+ const suggestions = store();
7
8
  /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
8
9
  * the query's first eight lowercase words, defaults left out, and list order ignored. */
9
10
  export function searchKey(request) {
@@ -27,6 +28,9 @@ export function recommendationsKey(objectID, request = {}) {
27
28
  return canonical({ objectID, ...rest, ...(limit !== undefined && limit !== 8 ? { limit } : {}),
28
29
  ...(filters && Object.keys(filters).length ? { filters } : {}) });
29
30
  }
31
+ /** Query suggestion requests that the API answers the same way share a key: the text in lowercase with
32
+ * single spaces, and whether a space ends it (which makes the last word whole). */
33
+ export const suggestionsKey = (q, limit) => `${limit}\n${q.toLowerCase().replace(/\s+/g, " ").replace(/^ /, "")}`;
30
34
  function lookup(from, client, key) {
31
35
  const entry = from.responses.get(client)?.get(key);
32
36
  return entry && Date.now() - entry.at < maxAge ? entry.response : null;
@@ -47,6 +51,13 @@ export function cachedSearch(client, request, signal) {
47
51
  export function cachedRecommendations(client, objectID, request = {}, signal) {
48
52
  return shared(recommendations, client, recommendationsKey(objectID, request), (stop) => client.recommendations(objectID, request, { signal: stop }), signal);
49
53
  }
54
+ /** Query suggestions for what was typed, kept and shared the same way. */
55
+ export function peekQuerySuggestions(client, q, limit) {
56
+ return lookup(suggestions, client, suggestionsKey(q, limit));
57
+ }
58
+ export function cachedQuerySuggestions(client, q, limit, signal) {
59
+ return shared(suggestions, client, suggestionsKey(q, limit), (stop) => client.querySuggestions({ q, limit }, { signal: stop }), signal);
60
+ }
50
61
  function shared(from, client, key, load, signal) {
51
62
  const found = lookup(from, client, key);
52
63
  if (found)
@@ -1,5 +1,5 @@
1
1
  import { type MouseEvent, type ReactNode } from "react";
2
- import type { DidYouMean as Correction } from "../index.js";
2
+ import { type DidYouMean as Correction } from "../index.js";
3
3
  import { type Refinement } from "./search-provider.js";
4
4
  import type { Hit } from "./state.js";
5
5
  import { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
@@ -19,19 +19,31 @@ export type SearchBoxProps<T> = AutocompleteOptions<T> & {
19
19
  empty?: (query: string) => ReactNode;
20
20
  /** The link to the results page (default "See all N results"). */
21
21
  seeAll?: (total: number) => ReactNode;
22
+ /** The sections' headings and buttons, for other languages. */
23
+ labels?: Partial<SuggestionLabels>;
22
24
  className?: string;
23
25
  autoFocus?: boolean;
24
26
  };
27
+ export type SuggestionLabels = {
28
+ recent: string;
29
+ popular: string;
30
+ suggestions: string;
31
+ results: string;
32
+ clear: string;
33
+ remove: string;
34
+ };
25
35
  /** A results page's address for a query. */
26
36
  export declare const resultsHref: (action: string, query: string) => string;
27
- /** The suggestions under a search box: the hits, "no results", or an error, then a link to the
28
- * results page. Shared by SearchBox and SearchDialog. */
29
- export declare function Suggestions<T>({ box, label, action, renderHit, empty, seeAll }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "empty" | "seeAll"> & {
37
+ /** The suggestions under a search box: recent and popular searches with an empty box; while typing,
38
+ * suggested searches, then the results, "no results", or an error; then a link to the results page.
39
+ * Each kind is a section of one list, which the arrow keys move through. Shared by SearchBox and
40
+ * SearchDialog. */
41
+ export declare function Suggestions<T>({ box, label, action, renderHit, empty, seeAll, labels }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "empty" | "seeAll" | "labels"> & {
30
42
  box: ReturnType<typeof useAutocomplete<T>>;
31
43
  label: string;
32
44
  }): import("react").JSX.Element;
33
45
  /** A search box that suggests results as visitors type. Works on its own with a `publicId`. */
34
- export declare function SearchBox<T = Record<string, unknown>>({ placeholder, label, action, renderHit, empty, seeAll, className, autoFocus, ...options }: SearchBoxProps<T>): import("react").JSX.Element;
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;
35
47
  /** The query input of the surrounding SearchProvider. */
36
48
  export declare function SearchInput({ placeholder, label, className, autoFocus }: {
37
49
  placeholder?: string;
@@ -3,6 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { useState } from "react";
4
4
  import { Highlight } from "./highlight.js";
5
5
  import { messageFor } from "./messages.js";
6
+ import { suggestionParts } from "../index.js";
6
7
  import { useCurrentRefinements, useDidYouMean, useHits, useLoadMore, useRefinementList, useSearchBox, useSortBy, } from "./search-provider.js";
7
8
  import { useAutocomplete } from "./use-autocomplete.js";
8
9
  // The components render plain elements marked with data-findlane-* attributes and no styles of
@@ -15,23 +16,52 @@ export function DefaultHit({ hit }) {
15
16
  const field = ["title", "name"].find((name) => name in record || hit.highlights?.[name] !== undefined);
16
17
  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
  }
19
+ const defaultLabels = { recent: "Recent searches", popular: "Popular searches", suggestions: "Searches",
20
+ results: "Results", clear: "Clear", remove: "Remove" };
18
21
  /** A results page's address for a query. */
19
22
  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` }))] }));
23
+ /** The suggestions under a search box: recent and popular searches with an empty box; while typing,
24
+ * suggested searches, then the results, "no results", or an error; then a link to the results page.
25
+ * Each kind is a section of one list, which the arrow keys move through. Shared by SearchBox and
26
+ * SearchDialog. */
27
+ export function Suggestions({ box, label, action, renderHit, empty, seeAll, labels }) {
28
+ const { hits, query, total, error, items } = box;
29
+ const text = { ...defaultLabels, ...labels };
30
+ // Consecutive options of one kind make a section; results get a heading only below searches.
31
+ const sections = [];
32
+ for (const item of items) {
33
+ const last = sections.at(-1);
34
+ if (last?.kind === item.kind)
35
+ last.items.push(item);
36
+ else
37
+ sections.push({ kind: item.kind, items: [item] });
38
+ }
39
+ const searching = query.trim() !== "";
40
+ const headings = { recent: text.recent, popular: text.popular,
41
+ suggestion: text.suggestions, hit: text.results };
42
+ const option = (item) => {
43
+ if (item.kind === "hit") {
44
+ const href = box.hrefFor(item.hit);
45
+ const content = renderHit ? renderHit(item.hit) : _jsx(DefaultHit, { hit: item.hit });
46
+ return href ? (_jsx("a", { href: href, tabIndex: -1, ...box.getOptionProps(item.index), children: content }, item.hit.id)) : (_jsx("div", { ...box.getOptionProps(item.index), children: content }, item.hit.id));
47
+ }
48
+ return (_jsxs("div", { "data-findlane-query": item.kind, ...box.getOptionProps(item.index), children: [_jsx("span", { "data-findlane-query-text": "", children: item.kind === "suggestion" && item.typed
49
+ ? suggestionParts(item).map((part, at) => part.typed ? _jsx("span", { children: part.text }, at) : _jsx("b", { children: part.text }, at))
50
+ : item.q }), item.kind === "recent" && (_jsx("button", { type: "button", tabIndex: -1, "aria-label": `${text.remove} ${item.q}`, "data-findlane-remove": "", onClick: (event) => { event.stopPropagation(); box.removeRecent(item.q); }, children: "\u00D7" }))] }, `${item.kind}:${item.q}`));
51
+ };
52
+ 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
+ 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));
56
+ }) })), 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` }))] }));
29
57
  }
30
58
  /** 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);
59
+ export function SearchBox({ placeholder, label = "Search", action, renderHit, empty, seeAll, labels, className, autoFocus, ...options }) {
60
+ // A search chosen from the suggestions goes to the results page, as Enter does.
61
+ const box = useAutocomplete({ ...options,
62
+ onSubmit: options.onSubmit ?? (action ? (query) => window.location.assign(resultsHref(action, query)) : undefined) });
33
63
  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 }))] }));
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 }))] }));
35
65
  }
36
66
  /** The query input of the surrounding SearchProvider. */
37
67
  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, className, ...options }: SearchDialogProps<T>): import("react").JSX.Element;
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;
@@ -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, className, ...options }) {
9
+ export function SearchDialog({ trigger, shortcuts = true, footer, placeholder, label = "Search", action, renderHit, 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 }))] })), open && footer && _jsx("div", { "data-findlane-dialog-footer": "", children: footer })] })] }));
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 })] })] }));
63
63
  }
@@ -1,11 +1,11 @@
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 AutocompleteOptions } from "./use-autocomplete.js";
3
+ export { useAutocomplete, type AutocompleteItem, type AutocompleteOptions } 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";
7
7
  export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
8
- export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
8
+ export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, type SuggestionLabels, } from "./components.js";
9
9
  export { SearchDialog, type SearchDialogProps } from "./dialog.js";
10
10
  export { Highlight, Snippet, excerpt, highlightParts, type HighlightProps } from "./highlight.js";
11
11
  export { messageFor } from "./messages.js";
@@ -86,6 +86,49 @@
86
86
  border-top: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 10%, transparent));
87
87
  }
88
88
 
89
+ /* Searches (suggested, recent, popular) and results are sections of one list, each under a small heading. */
90
+ :where([data-findlane-section] + [data-findlane-section]) {
91
+ margin-top: 4px;
92
+ padding-top: 4px;
93
+ border-top: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 10%, transparent));
94
+ }
95
+ :where([data-findlane-section-title]) {
96
+ display: flex;
97
+ justify-content: space-between;
98
+ align-items: center;
99
+ padding: 6px 12px 2px;
100
+ color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
101
+ font-size: 0.8em;
102
+ font-weight: 600;
103
+ }
104
+ :where([data-findlane-section-title] [data-findlane-clear], [data-findlane-remove]) {
105
+ padding: 0 4px;
106
+ color: inherit;
107
+ font: inherit;
108
+ background: none;
109
+ border: 0;
110
+ cursor: pointer;
111
+ }
112
+ :where([data-findlane-panel] [data-findlane-query]) {
113
+ justify-content: space-between;
114
+ padding: 6px 12px;
115
+ }
116
+ :where([data-findlane-query-text]) {
117
+ min-width: 0;
118
+ overflow: hidden;
119
+ text-overflow: ellipsis;
120
+ white-space: nowrap;
121
+ }
122
+ /* What was typed is plain, and the rest of the suggestion bold, as search engines show it. */
123
+ :where([data-findlane-query-text] b) {
124
+ font-weight: 600;
125
+ }
126
+ :where([data-findlane-remove]) {
127
+ color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
128
+ font-size: 1.1em;
129
+ line-height: 1;
130
+ }
131
+
89
132
  :where([data-findlane-dialog-trigger]) {
90
133
  display: inline-flex;
91
134
  gap: 12px;
@@ -1,8 +1,9 @@
1
1
  import { type HTMLAttributes, type InputHTMLAttributes } from "react";
2
+ import type { QuerySuggestion } from "../index.js";
2
3
  import type { Hit } from "./state.js";
3
4
  import { type ClientOptions } from "./client.js";
4
5
  export type AutocompleteOptions<T> = ClientOptions & {
5
- /** Suggestions to show: 1–50 (default 6). */
6
+ /** Results to show: 1–50 (default 6). */
6
7
  limit?: number;
7
8
  /** The record fields to return (`attributesToRetrieve`). */
8
9
  fields?: string[];
@@ -14,23 +15,51 @@ export type AutocompleteOptions<T> = ClientOptions & {
14
15
  defaultQuery?: string;
15
16
  /** A base for the elements' IDs; one is generated otherwise. */
16
17
  id?: string;
17
- /** A hit's page. Enter on a suggestion goes there unless `onSelect` is given. */
18
+ /** A hit's page. Enter on a result goes there unless `onSelect` is given. */
18
19
  getHref?: (hit: Hit<T>) => string | undefined;
19
20
  /** A hit's page as a template, for server components, which can't pass `getHref`:
20
21
  * "/products/{handle}" puts in the record's `handle`, URL-encoded, and `{objectID}` the hit's ID.
21
22
  * A template that is one field, such as "{url}", takes that field's URL as it is. */
22
23
  hrefTemplate?: string;
23
- /** Called when a suggestion is chosen, instead of following its link; for client-side routers. */
24
+ /** Called when a result is chosen, instead of following its link; for client-side routers. */
24
25
  onSelect?: (hit: Hit<T>) => void;
25
- /** Called on Enter without a chosen suggestion. Without it, Enter submits the input's form. */
26
+ /** Called on Enter without a chosen result, and when a search is chosen from the suggestions. Without
27
+ * it, Enter submits the input's form, and a chosen search fills the box and shows its results. */
26
28
  onSubmit?: (query: string) => void;
29
+ /** Query suggestions: whole searches above the results as the shopper types, up to this many (1–10).
30
+ * Off unless set. */
31
+ querySuggestions?: number;
32
+ /** The store's popular searches when the box is empty, up to this many (1–10). Off unless set. */
33
+ popularSearches?: number;
34
+ /** The shopper's recent searches when the box is empty, kept in this browser only: true for the last
35
+ * five, or how many (up to 10). Off unless set. */
36
+ recentSearches?: boolean | number;
37
+ };
38
+ /** One of the combobox's options, in the order shown: a search to run (suggested for what was typed, or
39
+ * with an empty box a recent or popular one), or a result. `index` is its place among all of them, for
40
+ * getOptionProps. */
41
+ export type AutocompleteItem<T> = {
42
+ kind: "suggestion";
43
+ q: string;
44
+ typed?: [number, number];
45
+ index: number;
46
+ } | {
47
+ kind: "recent" | "popular";
48
+ q: string;
49
+ index: number;
50
+ } | {
51
+ kind: "hit";
52
+ hit: Hit<T>;
53
+ index: number;
27
54
  };
28
55
  /** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
29
56
  export declare function fillTemplate<T>(template: string, hit: Hit<T>): string | undefined;
30
57
  /** A search box with suggestions, as the WAI-ARIA combobox pattern: the hook keeps the state and
31
- * the prop getters give the input, list, and options their attributes and events. Choosing a
32
- * suggestion sends a click event with its place and the query, for click-through in Analytics (the
33
- * client's `events` option turns them off, or holds them until consent). */
58
+ * the prop getters give the input, list, and options their attributes and events. Choosing a result
59
+ * sends a click event with its place and the query, and choosing a search a suggestion event, for
60
+ * Analytics (the client's `events` option turns them off, or holds them until consent). With query
61
+ * suggestions, popular or recent searches on, `items` lists the searches before the results, and
62
+ * options are numbered across both. */
34
63
  export declare function useAutocomplete<T = Record<string, unknown>>(options?: AutocompleteOptions<T>): {
35
64
  query: string;
36
65
  setQuery: (value: string) => void;
@@ -50,9 +79,22 @@ export declare function useAutocomplete<T = Record<string, unknown>>(options?: A
50
79
  setActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
51
80
  close: () => void;
52
81
  select: (hit: Hit<T>) => void;
82
+ /** Every option in the order shown: searches, then results. */
83
+ items: AutocompleteItem<T>[];
84
+ /** Query suggestions for what's typed, recent searches, and popular searches, as the API gave them. */
85
+ suggestions: QuerySuggestion[];
86
+ recent: string[];
87
+ popular: QuerySuggestion[];
88
+ /** Runs an option: follows a result, or runs a search. */
89
+ choose: (index: number) => void;
90
+ /** Removes one of the shopper's recent searches, or all of them. */
91
+ removeRecent: (q: string) => void;
92
+ clearRecent: () => void;
53
93
  /** A hit's page, from `getHref` or `hrefTemplate`. */
54
94
  hrefFor: (hit: Hit<T>) => string | undefined;
55
95
  getInputProps: (props?: InputHTMLAttributes<HTMLInputElement>) => InputHTMLAttributes<HTMLInputElement>;
56
96
  getListboxProps: (props?: HTMLAttributes<HTMLElement>) => HTMLAttributes<HTMLElement>;
97
+ /** An option's attributes and events, by its place among all options (`index` of `items`); with
98
+ * only results, a result's place among the hits. */
57
99
  getOptionProps: (index: number, props?: HTMLAttributes<HTMLElement>) => HTMLAttributes<HTMLElement>;
58
100
  };
@@ -3,6 +3,7 @@ import { useEffect, useId, useState, } from "react";
3
3
  import { valuesAt } from "./highlight.js";
4
4
  import { useSearch } from "./use-search.js";
5
5
  import { useClient } from "./client.js";
6
+ import { cachedQuerySuggestions, peekQuerySuggestions } from "./cache.js";
6
7
  /** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
7
8
  export function fillTemplate(template, hit) {
8
9
  const value = (field) => (field === "objectID" ? hit.id : valuesAt(hit.record, field)[0] ?? "");
@@ -10,12 +11,18 @@ export function fillTemplate(template, hit) {
10
11
  const href = whole ? value(whole[1]) : template.replace(/\{([^}]+)\}/g, (_, field) => encodeURIComponent(value(field)));
11
12
  return /^[a-z][a-z\d+.-]*:/i.test(href.trim()) && !/^https?:/i.test(href.trim()) ? undefined : href || undefined;
12
13
  }
14
+ const noSuggestions = [];
13
15
  /** A search box with suggestions, as the WAI-ARIA combobox pattern: the hook keeps the state and
14
- * the prop getters give the input, list, and options their attributes and events. Choosing a
15
- * suggestion sends a click event with its place and the query, for click-through in Analytics (the
16
- * client's `events` option turns them off, or holds them until consent). */
16
+ * the prop getters give the input, list, and options their attributes and events. Choosing a result
17
+ * sends a click event with its place and the query, and choosing a search a suggestion event, for
18
+ * Analytics (the client's `events` option turns them off, or holds them until consent). With query
19
+ * suggestions, popular or recent searches on, `items` lists the searches before the results, and
20
+ * options are numbered across both. */
17
21
  export function useAutocomplete(options = {}) {
18
22
  const { limit = 6, fields, filters, sort, minLength = 1, debounceMs, hrefTemplate, onSelect, onSubmit } = options;
23
+ const suggestionLimit = Math.min(Math.max(options.querySuggestions ?? 0, 0), 10);
24
+ const popularLimit = Math.min(Math.max(options.popularSearches ?? 0, 0), 10);
25
+ const recentLimit = options.recentSearches === true ? 5 : Math.min(Math.max(Number(options.recentSearches) || 0, 0), 10);
19
26
  const getHref = options.getHref ?? (hrefTemplate ? (hit) => fillTemplate(hrefTemplate, hit) : undefined);
20
27
  const generated = useId();
21
28
  const id = options.id ?? `findlane${generated.replace(/:/g, "")}`;
@@ -38,10 +45,66 @@ export function useAutocomplete(options = {}) {
38
45
  });
39
46
  const client = useClient({ publicId: options.publicId, baseUrl: options.baseUrl, client: options.client });
40
47
  const hits = search.data?.hits ?? [];
41
- // Open once there's something to show: suggestions, "no results", or an error.
42
- const isOpen = focused && !dismissed && request !== null && (search.data !== null || search.error !== null);
48
+ const emptyBox = query.trim() === "";
49
+ // Query suggestions for what's typed, after the same pause as the search; the last ones stay while
50
+ // the next load. Popular searches are asked for on the first focus (the client keeps them), and recent
51
+ // ones read from this browser on each.
52
+ const [suggestions, setSuggestions] = useState(noSuggestions);
53
+ const typed = request && suggestionLimit ? query : null;
54
+ useEffect(() => {
55
+ if (typed === null)
56
+ return setSuggestions(noSuggestions);
57
+ const cached = peekQuerySuggestions(client, typed, suggestionLimit);
58
+ if (cached)
59
+ return setSuggestions(cached);
60
+ let active = true;
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => {
63
+ cachedQuerySuggestions(client, typed, suggestionLimit, controller.signal)
64
+ .then((items) => { if (active)
65
+ setSuggestions(items); }, () => undefined);
66
+ }, debounceMs ?? 150);
67
+ return () => {
68
+ active = false;
69
+ clearTimeout(timer);
70
+ controller.abort();
71
+ };
72
+ }, [client, typed, suggestionLimit, debounceMs]);
73
+ const [popular, setPopular] = useState(noSuggestions);
74
+ useEffect(() => {
75
+ if (!popularLimit || !focused)
76
+ return;
77
+ let active = true;
78
+ client.popularSearches({ limit: popularLimit }).then((items) => { if (active)
79
+ setPopular(items); }, () => undefined);
80
+ return () => { active = false; };
81
+ }, [client, popularLimit, focused]);
82
+ const [recent, setRecent] = useState([]);
83
+ useEffect(() => {
84
+ if (recentLimit && focused)
85
+ setRecent(client.recentSearches.list(recentLimit));
86
+ }, [client, recentLimit, focused]);
87
+ // The searches first, then the results; with an empty box, recent searches and popular ones not among them.
88
+ const searches = [];
89
+ if (emptyBox) {
90
+ const shown = new Set(recent.map((q) => q.toLowerCase()));
91
+ for (const q of recent)
92
+ searches.push({ kind: "recent", q, index: searches.length });
93
+ for (const item of popular)
94
+ if (!shown.has(item.q.toLowerCase()))
95
+ searches.push({ kind: "popular", q: item.q, index: searches.length });
96
+ }
97
+ else if (request) {
98
+ for (const item of suggestions)
99
+ searches.push({ kind: "suggestion", q: item.q, typed: item.typed, index: searches.length });
100
+ }
101
+ const items = [...searches,
102
+ ...hits.map((hit, position) => ({ kind: "hit", hit, index: searches.length + position }))];
103
+ // Open once there's something to show: searches, results, "no results", or an error.
104
+ const isOpen = focused && !dismissed && (emptyBox ? searches.length > 0
105
+ : request !== null && (search.data !== null || search.error !== null || searches.length > 0));
43
106
  // New suggestions start unselected.
44
- useEffect(() => setActiveIndex(-1), [search.data]);
107
+ useEffect(() => setActiveIndex(-1), [search.data, suggestions, emptyBox]);
45
108
  const listboxId = `${id}-listbox`;
46
109
  const optionId = (index) => `${id}-option-${index}`;
47
110
  const setQuery = (value) => {
@@ -53,12 +116,20 @@ export function useAutocomplete(options = {}) {
53
116
  setDismissed(true);
54
117
  setActiveIndex(-1);
55
118
  };
119
+ /** A search the shopper made: kept among their recent searches, when those are on. */
120
+ const remember = (q) => {
121
+ if (!recentLimit || !q.trim())
122
+ return;
123
+ client.recentSearches.add(q);
124
+ setRecent(client.recentSearches.list(recentLimit));
125
+ };
56
126
  const clicked = (hit) => {
57
127
  const index = hits.indexOf(hit);
58
128
  client.events.click(hit.id, { query, ...(index >= 0 ? { position: index + 1 } : {}) });
59
129
  };
60
130
  const select = (hit) => {
61
131
  clicked(hit);
132
+ remember(query);
62
133
  close();
63
134
  if (onSelect)
64
135
  return onSelect(hit);
@@ -66,14 +137,34 @@ export function useAutocomplete(options = {}) {
66
137
  if (href)
67
138
  window.location.assign(href);
68
139
  };
140
+ /** Runs a suggested, popular or recent search: onSubmit gets it, or it fills the box and its results show. */
141
+ const pick = (item) => {
142
+ const position = searches.filter((other) => other.kind === item.kind).indexOf(item) + 1;
143
+ client.events.suggestion(item.q, { kind: item.kind === "suggestion" ? "typed" : item.kind, ...(position ? { position } : {}) });
144
+ remember(item.q);
145
+ setQuery(item.q);
146
+ if (onSubmit) {
147
+ close();
148
+ onSubmit(item.q);
149
+ }
150
+ };
151
+ const choose = (index) => {
152
+ const item = items[index];
153
+ if (!item)
154
+ return;
155
+ if (item.kind === "hit")
156
+ select(item.hit);
157
+ else
158
+ pick(item);
159
+ };
69
160
  function onKeyDown(event) {
70
161
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
71
162
  event.preventDefault();
72
163
  if (!isOpen)
73
164
  return setDismissed(false);
74
165
  const step = event.key === "ArrowDown" ? 1 : -1;
75
- // -1 is the input itself, between the last suggestion and the first.
76
- setActiveIndex((current) => ((current + 1 + step + hits.length + 1) % (hits.length + 1)) - 1);
166
+ // -1 is the input itself, between the last option and the first.
167
+ setActiveIndex((current) => ((current + 1 + step + items.length + 1) % (items.length + 1)) - 1);
77
168
  }
78
169
  else if (event.key === "Escape") {
79
170
  if (isOpen) {
@@ -86,15 +177,18 @@ export function useAutocomplete(options = {}) {
86
177
  }
87
178
  }
88
179
  else if (event.key === "Enter" && !event.nativeEvent.isComposing) {
89
- const hit = isOpen && activeIndex >= 0 ? hits[activeIndex] : undefined;
90
- if (hit) {
180
+ const item = isOpen && activeIndex >= 0 ? items[activeIndex] : undefined;
181
+ if (item) {
91
182
  event.preventDefault();
92
- select(hit);
183
+ choose(item.index);
93
184
  }
94
- else if (onSubmit) {
95
- event.preventDefault();
96
- close();
97
- onSubmit(query);
185
+ else {
186
+ remember(query);
187
+ if (onSubmit) {
188
+ event.preventDefault();
189
+ close();
190
+ onSubmit(query);
191
+ }
98
192
  }
99
193
  }
100
194
  }
@@ -111,6 +205,23 @@ export function useAutocomplete(options = {}) {
111
205
  setActiveIndex,
112
206
  close,
113
207
  select,
208
+ /** Every option in the order shown: searches, then results. */
209
+ items,
210
+ /** Query suggestions for what's typed, recent searches, and popular searches, as the API gave them. */
211
+ suggestions,
212
+ recent,
213
+ popular,
214
+ /** Runs an option: follows a result, or runs a search. */
215
+ choose,
216
+ /** Removes one of the shopper's recent searches, or all of them. */
217
+ removeRecent: (q) => {
218
+ client.recentSearches.remove(q);
219
+ setRecent(client.recentSearches.list(recentLimit));
220
+ },
221
+ clearRecent: () => {
222
+ client.recentSearches.clear();
223
+ setRecent([]);
224
+ },
114
225
  /** A hit's page, from `getHref` or `hrefTemplate`. */
115
226
  hrefFor: (hit) => getHref?.(hit),
116
227
  getInputProps: (props = {}) => ({
@@ -148,12 +259,14 @@ export function useAutocomplete(options = {}) {
148
259
  ...props,
149
260
  id: listboxId,
150
261
  role: "listbox",
151
- // Keeps focus in the input, so choosing a suggestion doesn't close the list first.
262
+ // Keeps focus in the input, so choosing an option doesn't close the list first.
152
263
  onMouseDown: (event) => {
153
264
  props.onMouseDown?.(event);
154
265
  event.preventDefault();
155
266
  },
156
267
  }),
268
+ /** An option's attributes and events, by its place among all options (`index` of `items`); with
269
+ * only results, a result's place among the hits. */
157
270
  getOptionProps: (index, props = {}) => ({
158
271
  ...props,
159
272
  id: optionId(index),
@@ -166,9 +279,14 @@ export function useAutocomplete(options = {}) {
166
279
  },
167
280
  onClick: (event) => {
168
281
  props.onClick?.(event);
169
- const hit = hits[index];
170
- if (event.defaultPrevented || !hit)
282
+ const item = items[index];
283
+ if (event.defaultPrevented || !item)
171
284
  return;
285
+ if (item.kind !== "hit") {
286
+ event.preventDefault();
287
+ return pick(item);
288
+ }
289
+ const hit = item.hit;
172
290
  // A link opened in a new tab or window is left to the browser.
173
291
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
174
292
  return clicked(hit);
@@ -179,6 +297,7 @@ export function useAutocomplete(options = {}) {
179
297
  }
180
298
  else {
181
299
  clicked(hit);
300
+ remember(query);
182
301
  close();
183
302
  }
184
303
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",