@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 +13 -2
- package/dist/index.d.ts +99 -0
- package/dist/index.js +91 -0
- package/dist/react/cache.d.ts +7 -1
- package/dist/react/cache.js +11 -0
- package/dist/react/components.d.ts +17 -5
- package/dist/react/components.js +42 -12
- package/dist/react/dialog.d.ts +1 -1
- package/dist/react/dialog.js +2 -2
- package/dist/react/index.d.ts +2 -2
- package/dist/react/styles.css +43 -0
- package/dist/react/use-autocomplete.d.ts +49 -7
- package/dist/react/use-autocomplete.js +137 -18
- package/package.json +1 -1
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
|
}
|
package/dist/react/cache.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/react/cache.js
CHANGED
|
@@ -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
|
|
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:
|
|
28
|
-
* results
|
|
29
|
-
|
|
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;
|
package/dist/react/components.js
CHANGED
|
@@ -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:
|
|
21
|
-
* results
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 }) {
|
package/dist/react/dialog.d.ts
CHANGED
|
@@ -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;
|
package/dist/react/dialog.js
CHANGED
|
@@ -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
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/react/styles.css
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
76
|
-
setActiveIndex((current) => ((current + 1 + step +
|
|
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
|
|
90
|
-
if (
|
|
180
|
+
const item = isOpen && activeIndex >= 0 ? items[activeIndex] : undefined;
|
|
181
|
+
if (item) {
|
|
91
182
|
event.preventDefault();
|
|
92
|
-
|
|
183
|
+
choose(item.index);
|
|
93
184
|
}
|
|
94
|
-
else
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
|
170
|
-
if (event.defaultPrevented || !
|
|
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.
|
|
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",
|