@findlane/browser 0.21.0 → 0.23.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 +32 -0
- package/dist/index.js +20 -0
- package/dist/react/components.d.ts +4 -2
- package/dist/react/components.js +21 -8
- 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/index.js +1 -1
- package/dist/react/search-provider.d.ts +5 -2
- package/dist/react/search-provider.js +7 -0
- package/dist/react/state.d.ts +3 -0
- package/dist/react/state.js +2 -0
- package/dist/react/store.js +1 -1
- 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>;
|
|
142
156
|
};
|
|
157
|
+
/** One search of a multi-index search (multiSearch): its index's public ID, the client's own when left out. */
|
|
158
|
+
export type MultiSearchQuery = SearchRequest & {
|
|
159
|
+
index?: string;
|
|
160
|
+
};
|
|
161
|
+
/** One search's answer in a multi-index search: its results, or its error and status. */
|
|
162
|
+
export type MultiSearchResult<T> = (SearchResponse<T> & {
|
|
163
|
+
index: string;
|
|
164
|
+
}) | (SubindexError & {
|
|
165
|
+
index: string;
|
|
166
|
+
});
|
|
143
167
|
/** A query rule a search fired, with its `userData`, if it has any. */
|
|
144
168
|
export type AppliedRule = {
|
|
145
169
|
id: string;
|
|
@@ -344,6 +368,7 @@ export type BrowserClientOptions = {
|
|
|
344
368
|
export declare class SearchBrowserClient {
|
|
345
369
|
private readonly url;
|
|
346
370
|
private readonly origin;
|
|
371
|
+
private readonly publicId;
|
|
347
372
|
private readonly fetcher;
|
|
348
373
|
/** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
|
|
349
374
|
readonly events: BrowserEvents;
|
|
@@ -359,6 +384,13 @@ export declare class SearchBrowserClient {
|
|
|
359
384
|
search<T = Record<string, unknown>>(request: SearchRequest, options?: {
|
|
360
385
|
signal?: AbortSignal;
|
|
361
386
|
}): Promise<SearchResponse<T>>;
|
|
387
|
+
/** Several indexes searched in one request, such as products and a blog (0.22.0): up to 10 searches, each
|
|
388
|
+
* naming its index's public ID (this client's when left out), whose allowed sites must include the page's.
|
|
389
|
+
* Each counts as a search of its own; one that asks for its index's subindexes counts once with them. The
|
|
390
|
+
* answers come in the order asked, each its results or its error; it throws only when every search failed. */
|
|
391
|
+
multiSearch<T = Record<string, unknown>>(queries: MultiSearchQuery[], options?: {
|
|
392
|
+
signal?: AbortSignal;
|
|
393
|
+
}): Promise<MultiSearchResult<T>[]>;
|
|
362
394
|
/** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
|
|
363
395
|
* request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
|
|
364
396
|
* Ask once the shopper stops typing, not for every keystroke. */
|
package/dist/index.js
CHANGED
|
@@ -162,6 +162,7 @@ const warmed = new Set();
|
|
|
162
162
|
export class SearchBrowserClient {
|
|
163
163
|
url;
|
|
164
164
|
origin;
|
|
165
|
+
publicId;
|
|
165
166
|
fetcher;
|
|
166
167
|
/** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
|
|
167
168
|
events;
|
|
@@ -171,6 +172,7 @@ export class SearchBrowserClient {
|
|
|
171
172
|
constructor(options) {
|
|
172
173
|
if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
|
|
173
174
|
throw new Error("A valid public index ID (pub_…) is required");
|
|
175
|
+
this.publicId = options.publicId;
|
|
174
176
|
this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
|
|
175
177
|
this.origin = new URL(this.url).origin;
|
|
176
178
|
this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
@@ -216,6 +218,24 @@ export class SearchBrowserClient {
|
|
|
216
218
|
search(request, options = {}) {
|
|
217
219
|
return this.post("search", request, options.signal);
|
|
218
220
|
}
|
|
221
|
+
/** Several indexes searched in one request, such as products and a blog (0.22.0): up to 10 searches, each
|
|
222
|
+
* naming its index's public ID (this client's when left out), whose allowed sites must include the page's.
|
|
223
|
+
* Each counts as a search of its own; one that asks for its index's subindexes counts once with them. The
|
|
224
|
+
* answers come in the order asked, each its results or its error; it throws only when every search failed. */
|
|
225
|
+
async multiSearch(queries, options = {}) {
|
|
226
|
+
const response = await this.fetcher(`${this.url.slice(0, this.url.indexOf("/api/public/"))}/api/public/search`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "text/plain" },
|
|
229
|
+
body: JSON.stringify({ queries: queries.map(({ index, ...search }) => ({ index: index ?? this.publicId, ...search })) }),
|
|
230
|
+
signal: options.signal,
|
|
231
|
+
});
|
|
232
|
+
const data = await response.json();
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const failed = data.results?.find((result) => "error" in result);
|
|
235
|
+
throw new FindlaneError(response.status, data.error ?? failed?.error ?? `HTTP ${response.status}`);
|
|
236
|
+
}
|
|
237
|
+
return data.results ?? [];
|
|
238
|
+
}
|
|
219
239
|
/** Did you mean: for a search that found nothing, a query that finds something, or null. Takes the same
|
|
220
240
|
* request (its q and filters matter). Needs the index's `didYouMean` setting; not counted as a search.
|
|
221
241
|
* Ask once the shopper stops typing, not for every keystroke. */
|
|
@@ -15,6 +15,8 @@ export type SearchBoxProps<T> = AutocompleteOptions<T> & {
|
|
|
15
15
|
action?: string;
|
|
16
16
|
/** A suggestion's content; DefaultHit shows an image and a title. */
|
|
17
17
|
renderHit?: (hit: Hit<T>) => ReactNode;
|
|
18
|
+
/** A subindex result's content, by the subindex's name; DefaultHit by default (0.22.0). */
|
|
19
|
+
renderSubindexHit?: (subindex: string, hit: Hit<Record<string, unknown>>) => ReactNode;
|
|
18
20
|
/** Shown when nothing matches (default "No results for “…”"). */
|
|
19
21
|
empty?: (query: string) => ReactNode;
|
|
20
22
|
/** The link to the results page (default "See all N results"). */
|
|
@@ -38,12 +40,12 @@ export declare const resultsHref: (action: string, query: string) => string;
|
|
|
38
40
|
* suggested searches, then the results, "no results", or an error; then a link to the results page.
|
|
39
41
|
* Each kind is a section of one list, which the arrow keys move through. Shared by SearchBox and
|
|
40
42
|
* SearchDialog. */
|
|
41
|
-
export declare function Suggestions<T>({ box, label, action, renderHit, empty, seeAll, labels }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "empty" | "seeAll" | "labels"> & {
|
|
43
|
+
export declare function Suggestions<T>({ box, label, action, renderHit, renderSubindexHit, empty, seeAll, labels }: Pick<SearchBoxProps<T>, "action" | "renderHit" | "renderSubindexHit" | "empty" | "seeAll" | "labels"> & {
|
|
42
44
|
box: ReturnType<typeof useAutocomplete<T>>;
|
|
43
45
|
label: string;
|
|
44
46
|
}): import("react").JSX.Element;
|
|
45
47
|
/** A search box that suggests results as visitors type. Works on its own with a `publicId`. */
|
|
46
|
-
export declare function SearchBox<T = Record<string, unknown>>({ placeholder, label, action, renderHit, empty, seeAll, labels, className, autoFocus, ...options }: SearchBoxProps<T>): import("react").JSX.Element;
|
|
48
|
+
export declare function SearchBox<T = Record<string, unknown>>({ placeholder, label, action, renderHit, renderSubindexHit, empty, seeAll, labels, className, autoFocus, ...options }: SearchBoxProps<T>): import("react").JSX.Element;
|
|
47
49
|
/** The query input of the surrounding SearchProvider. */
|
|
48
50
|
export declare function SearchInput({ placeholder, label, className, autoFocus }: {
|
|
49
51
|
placeholder?: string;
|
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 }) {
|
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,10 +1,10 @@
|
|
|
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";
|
|
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";
|
|
7
|
+
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useSubindex, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
|
|
8
8
|
export { CurrentRefinements, DefaultBanner, 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";
|
package/dist/react/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { useAutocomplete } from "./use-autocomplete.js";
|
|
|
6
6
|
export { useRecommendations } from "./use-recommendations.js";
|
|
7
7
|
export { Recommendations } from "./recommendations.js";
|
|
8
8
|
export { ProductView, useProductView } from "./product-view.js";
|
|
9
|
-
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
|
|
9
|
+
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useSubindex, useToggle, } from "./search-provider.js";
|
|
10
10
|
export { CurrentRefinements, DefaultBanner, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
|
|
11
11
|
export { SearchDialog } from "./dialog.js";
|
|
12
12
|
export { Highlight, Snippet, excerpt, highlightParts } from "./highlight.js";
|
|
@@ -2,7 +2,7 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import type { DidYouMean, GeoBox } from "../index.js";
|
|
3
3
|
import { type ClientOptions } from "./client.js";
|
|
4
4
|
import { type ServerResults, type StateChange } from "./store.js";
|
|
5
|
-
import { type Near, type SearchConfig, type SearchState } from "./state.js";
|
|
5
|
+
import { type Hit, type Near, type SearchConfig, type SearchState } from "./state.js";
|
|
6
6
|
export type SearchProviderProps<T> = SearchConfig & ClientOptions & {
|
|
7
7
|
children: ReactNode;
|
|
8
8
|
/** Keeps the state in the page's URL (`?q=shoe&brand=Acme`), so results can be shared and Back
|
|
@@ -32,8 +32,11 @@ export declare function useSearchBox(): {
|
|
|
32
32
|
/** Opens the connection to the API ahead of the first search: call it when the box gets focus. */
|
|
33
33
|
warm: () => void;
|
|
34
34
|
};
|
|
35
|
+
/** A subindex's results for the current query (SearchProvider's `subindexes`), such as categories to suggest
|
|
36
|
+
* above the results; none without a query, or when its search failed (0.23.0). */
|
|
37
|
+
export declare function useSubindex<T = Record<string, unknown>>(name: string): Hit<T>[];
|
|
35
38
|
export declare function useHits<T = Record<string, unknown>>(): {
|
|
36
|
-
hits:
|
|
39
|
+
hits: Hit<T>[];
|
|
37
40
|
total: number;
|
|
38
41
|
/** With the index's related results on, when nothing matched: the products closest in meaning. */
|
|
39
42
|
related: import("../index.js").SearchHit<T>[];
|
|
@@ -77,6 +77,13 @@ export function useSearchBox() {
|
|
|
77
77
|
/** Opens the connection to the API ahead of the first search: call it when the box gets focus. */
|
|
78
78
|
warm: () => store.warm() };
|
|
79
79
|
}
|
|
80
|
+
/** A subindex's results for the current query (SearchProvider's `subindexes`), such as categories to suggest
|
|
81
|
+
* above the results; none without a query, or when its search failed (0.23.0). */
|
|
82
|
+
export function useSubindex(name) {
|
|
83
|
+
const [{ results }] = useSnapshot();
|
|
84
|
+
const answer = results?.response.subindexes?.[name];
|
|
85
|
+
return answer && "hits" in answer ? answer.hits : [];
|
|
86
|
+
}
|
|
80
87
|
export function useHits() {
|
|
81
88
|
const [{ results, pending, error }, store] = useSnapshot();
|
|
82
89
|
return {
|
package/dist/react/state.d.ts
CHANGED
|
@@ -41,6 +41,9 @@ export type SearchConfig = {
|
|
|
41
41
|
facetMode?: "page" | "exact";
|
|
42
42
|
/** Contexts the page sends, which the index's rules may fire on, such as "category-page" (0.19.0). */
|
|
43
43
|
context?: string | string[];
|
|
44
|
+
/** The index's subindexes to search with each query, such as categories to suggest above the results, by
|
|
45
|
+
* name (0.23.0): in the same request, counted as one search. Read them with useSubindex. */
|
|
46
|
+
subindexes?: SearchRequest["subindexes"];
|
|
44
47
|
};
|
|
45
48
|
export declare const emptyState: SearchState;
|
|
46
49
|
/** The API's largest offset. */
|
package/dist/react/state.js
CHANGED
|
@@ -28,6 +28,8 @@ export function planSearch(config, state) {
|
|
|
28
28
|
countMode: config.countMode ?? "exact",
|
|
29
29
|
...(facets.length ? { facetMode: config.facetMode ?? "exact" } : {}),
|
|
30
30
|
...(config.context?.length ? { context: config.context } : {}),
|
|
31
|
+
// Subindexes only for a query: a page without words has nothing to suggest.
|
|
32
|
+
...(config.subindexes && state.q.trim() ? { subindexes: config.subindexes } : {}),
|
|
31
33
|
highlight: true,
|
|
32
34
|
};
|
|
33
35
|
}
|
package/dist/react/store.js
CHANGED
|
@@ -164,7 +164,7 @@ export class SearchStore {
|
|
|
164
164
|
const { results, loadingMore, pending } = this.snapshot;
|
|
165
165
|
if (!results?.hasMore || loadingMore || pending)
|
|
166
166
|
return;
|
|
167
|
-
const { facets: _, disjunctiveFacets: __, facetMode: ___, facetStats: ____, ...request } = results.request;
|
|
167
|
+
const { facets: _, disjunctiveFacets: __, facetMode: ___, facetStats: ____, subindexes: _____, ...request } = results.request;
|
|
168
168
|
const offset = (request.offset ?? 0) + results.hits.length;
|
|
169
169
|
const run = this.run;
|
|
170
170
|
this.set({ loadingMore: true });
|
|
@@ -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.23.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",
|