@findlane/browser 0.13.0 → 0.14.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 +49 -0
- package/dist/index.js +70 -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
|
@@ -109,6 +109,31 @@ export type DidYouMean = {
|
|
|
109
109
|
to: string;
|
|
110
110
|
}[];
|
|
111
111
|
};
|
|
112
|
+
/** A whole search to offer as a shopper types, or in an empty box (query suggestions). `typed`: where
|
|
113
|
+
* what was typed is in `q`, as UTF-16 offsets (end exclusive), so the rest can be set apart, as Google
|
|
114
|
+
* bolds it; popular searches have none. */
|
|
115
|
+
export type QuerySuggestion = {
|
|
116
|
+
q: string;
|
|
117
|
+
typed?: [number, number];
|
|
118
|
+
};
|
|
119
|
+
/** A query suggestion's text in parts: what was typed (`typed: true`) and the rest. */
|
|
120
|
+
export declare function suggestionParts(suggestion: QuerySuggestion): {
|
|
121
|
+
text: string;
|
|
122
|
+
typed: boolean;
|
|
123
|
+
}[];
|
|
124
|
+
/** The shopper's recent searches, kept in this browser only (localStorage, per public ID) and never sent
|
|
125
|
+
* to Findlane: the newest first, without repeats, up to 10. Add a search when it's submitted, picked from
|
|
126
|
+
* suggestions, or leads to a result being opened, not on every keystroke. */
|
|
127
|
+
export declare class RecentSearches {
|
|
128
|
+
private readonly key;
|
|
129
|
+
constructor(publicId: string);
|
|
130
|
+
/** Up to `limit` of them (5 by default), the newest first. */
|
|
131
|
+
list(limit?: number): string[];
|
|
132
|
+
add(q: string): void;
|
|
133
|
+
remove(q: string): void;
|
|
134
|
+
clear(): void;
|
|
135
|
+
private save;
|
|
136
|
+
}
|
|
112
137
|
/** How a product's recommendations are served (the recommendations add-on). */
|
|
113
138
|
export type RecommendationsRequest = {
|
|
114
139
|
/** Up to this many products in each list, 1 to 20; 8 by default. */
|
|
@@ -170,6 +195,12 @@ export type BrowserEvents = {
|
|
|
170
195
|
* (React's double mount in development, a re-render), so it isn't sent. */
|
|
171
196
|
view(objectID: string): void;
|
|
172
197
|
click(objectID: string, details?: ClickDetails): void;
|
|
198
|
+
/** A query suggestion was picked: the search, its place in its list (from 1), and whether it was
|
|
199
|
+
* suggested for what was typed, or was a popular or recent search in an empty box. */
|
|
200
|
+
suggestion(query: string, details?: {
|
|
201
|
+
position?: number;
|
|
202
|
+
kind?: "typed" | "popular" | "recent";
|
|
203
|
+
}): void;
|
|
173
204
|
addToCart(items: EventItem[], options?: {
|
|
174
205
|
currency?: string;
|
|
175
206
|
}): void;
|
|
@@ -215,6 +246,9 @@ export declare class SearchBrowserClient {
|
|
|
215
246
|
private readonly fetcher;
|
|
216
247
|
/** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
|
|
217
248
|
readonly events: BrowserEvents;
|
|
249
|
+
/** The shopper's recent searches, kept in this browser only. */
|
|
250
|
+
readonly recentSearches: RecentSearches;
|
|
251
|
+
private popular;
|
|
218
252
|
constructor(options: BrowserClientOptions);
|
|
219
253
|
private post;
|
|
220
254
|
search<T = Record<string, unknown>>(request: SearchRequest, options?: {
|
|
@@ -226,6 +260,21 @@ export declare class SearchBrowserClient {
|
|
|
226
260
|
suggest(request: SearchRequest, options?: {
|
|
227
261
|
signal?: AbortSignal;
|
|
228
262
|
}): Promise<DidYouMean | null>;
|
|
263
|
+
/** Query suggestions: whole searches to offer as the shopper types `q`, the best first, from what
|
|
264
|
+
* shoppers search and the catalog's words and names. Not counted as searches, and answered from memory
|
|
265
|
+
* near the shopper, so ask on each pause in typing. `limit`: 1 to 10, 5 by default. */
|
|
266
|
+
querySuggestions(request: {
|
|
267
|
+
q: string;
|
|
268
|
+
limit?: number;
|
|
269
|
+
}, options?: {
|
|
270
|
+
signal?: AbortSignal;
|
|
271
|
+
}): Promise<QuerySuggestion[]>;
|
|
272
|
+
/** The index's popular searches, for an empty search box: what shoppers search most lately, with the
|
|
273
|
+
* owner's pinned searches first. Empty until the index has enough searches. The list changes daily,
|
|
274
|
+
* so this client asks once per `limit` and keeps the answer. */
|
|
275
|
+
popularSearches(options?: {
|
|
276
|
+
limit?: number;
|
|
277
|
+
}): Promise<QuerySuggestion[]>;
|
|
229
278
|
/** A product's recommendations (the recommendations add-on): products that go with it, and similar
|
|
230
279
|
* products, for a product page or a cart. Not counted as a search. */
|
|
231
280
|
recommendations<T = Record<string, unknown>>(objectID: string, request?: RecommendationsRequest, options?: {
|
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}`, {
|
|
@@ -144,6 +195,25 @@ export class SearchBrowserClient {
|
|
|
144
195
|
async suggest(request, options = {}) {
|
|
145
196
|
return (await this.post("suggest", request, options.signal)).didYouMean;
|
|
146
197
|
}
|
|
198
|
+
/** Query suggestions: whole searches to offer as the shopper types `q`, the best first, from what
|
|
199
|
+
* shoppers search and the catalog's words and names. Not counted as searches, and answered from memory
|
|
200
|
+
* near the shopper, so ask on each pause in typing. `limit`: 1 to 10, 5 by default. */
|
|
201
|
+
async querySuggestions(request, options = {}) {
|
|
202
|
+
return (await this.post("query-suggestions", request, options.signal)).suggestions;
|
|
203
|
+
}
|
|
204
|
+
/** The index's popular searches, for an empty search box: what shoppers search most lately, with the
|
|
205
|
+
* owner's pinned searches first. Empty until the index has enough searches. The list changes daily,
|
|
206
|
+
* so this client asks once per `limit` and keeps the answer. */
|
|
207
|
+
popularSearches(options = {}) {
|
|
208
|
+
const limit = options.limit ?? 6;
|
|
209
|
+
let asked = this.popular.get(limit);
|
|
210
|
+
if (!asked) {
|
|
211
|
+
asked = this.post("query-suggestions", { q: "", limit }).then((data) => data.suggestions);
|
|
212
|
+
asked.catch(() => this.popular.delete(limit));
|
|
213
|
+
this.popular.set(limit, asked);
|
|
214
|
+
}
|
|
215
|
+
return asked;
|
|
216
|
+
}
|
|
147
217
|
/** A product's recommendations (the recommendations add-on): products that go with it, and similar
|
|
148
218
|
* products, for a product page or a cart. Not counted as a search. */
|
|
149
219
|
recommendations(objectID, request = {}, options = {}) {
|
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.14.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",
|