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