@findlane/browser 0.16.1 → 0.18.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 CHANGED
@@ -46,6 +46,11 @@ export type SearchRequest = {
46
46
  /** Only records inside a box, in degrees, such as a map's visible area. West greater than east crosses the
47
47
  * antimeridian. */
48
48
  within?: GeoBox;
49
+ /** With the index's related results on: false asks for none (0.17.0). */
50
+ related?: boolean;
51
+ /** With the index's `distinct` setting, which shows each product once, such as one of its colours: false
52
+ * shows every record, a number 1 to 4 how many of each group's records. */
53
+ distinct?: boolean | number;
49
54
  };
50
55
  /** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
51
56
  export type GeoPoint = {
@@ -59,17 +64,34 @@ export type GeoBox = {
59
64
  east: number;
60
65
  west: number;
61
66
  };
67
+ /** A result. */
68
+ export type SearchHit<T> = {
69
+ id: string;
70
+ score: number | null;
71
+ record: T;
72
+ /** With `near`: meters from the point. */
73
+ distance?: number;
74
+ /** With `highlight`: matched searchable fields by path; list fields give one string per value. */
75
+ highlights?: Record<string, string | string[]>;
76
+ /** With the index's `distinct` setting: the hit's group (its value of the group field, null for a record
77
+ * without one) and how many of the group's records matched. */
78
+ group?: {
79
+ value: string | number | null;
80
+ matched: number;
81
+ };
82
+ /** With the index's `variants` setting: which item of the record's variants list the search matched, by
83
+ * its words or by filters on the variants' fields, such as the flavour a shopper typed. Absent when none
84
+ * stands out. */
85
+ variant?: {
86
+ index: number;
87
+ };
88
+ };
62
89
  export type SearchResponse<T> = {
63
- hits: {
64
- id: string;
65
- score: number | null;
66
- record: T;
67
- /** With `near`: meters from the point. */
68
- distance?: number;
69
- /** With `highlight`: matched searchable fields by path; list fields give one string per value. */
70
- highlights?: Record<string, string | string[]>;
71
- }[];
90
+ hits: SearchHit<T>[];
91
+ /** The matches; with the index's `distinct` setting, the groups. */
72
92
  total: number;
93
+ /** With `distinct`: the matching records, which `total` counts as groups. */
94
+ totalRecords?: number;
73
95
  /** "gte" when `total` is a lower bound (page count mode). */
74
96
  totalRelation?: "eq" | "gte";
75
97
  hasMore?: boolean;
@@ -95,6 +117,13 @@ export type SearchResponse<T> = {
95
117
  appliedRules?: AppliedRule[];
96
118
  /** When a rule replaced words: the query that was searched. */
97
119
  rewrittenQuery?: string;
120
+ /** With the index's related results on (0.17.0): when the query's words found nothing (`hits` empty, `total`
121
+ * 0), the products closest to it in meaning, best first, with their similarity as `score`. Show them under
122
+ * a heading of their own, such as "No exact matches. Related products". */
123
+ related?: {
124
+ hits: SearchHit<T>[];
125
+ total: number;
126
+ };
98
127
  };
99
128
  /** A query rule a search fired, with its `userData`, if it has any. */
100
129
  export type AppliedRule = {
@@ -227,7 +256,7 @@ export type EventItem = {
227
256
  export type ClickDetails = {
228
257
  position?: number;
229
258
  query?: string;
230
- list?: "goesWith" | "similar" | "together";
259
+ list?: "goesWith" | "similar" | "together" | "related";
231
260
  from?: string;
232
261
  };
233
262
  /** Events from the page, sent in the background: a batch a second, and what's left when the page is
@@ -56,6 +56,9 @@ export type HitsProps<T> = {
56
56
  render?: (hit: Hit<T>, index: number) => ReactNode;
57
57
  /** Shown when nothing matches (default "No results for “…”"). */
58
58
  empty?: (query: string) => ReactNode;
59
+ /** The heading over related results, shown when nothing matched but the index's related results found
60
+ * products close in meaning (default "No exact matches. Related products"). */
61
+ relatedTitle?: (query: string) => ReactNode;
59
62
  /** Shown before the first results arrive. */
60
63
  loading?: ReactNode;
61
64
  className?: string;
@@ -63,7 +66,7 @@ export type HitsProps<T> = {
63
66
  /** Whether a click was on a link inside the element that handled it, such as a hit's link. */
64
67
  export declare function onLink(event: MouseEvent<HTMLElement>): boolean;
65
68
  /** The results. Following a link in a hit sends a click event, with its place and the query. */
66
- export declare function Hits<T = Record<string, unknown>>({ render, empty, loading, className }: HitsProps<T>): import("react").JSX.Element;
69
+ export declare function Hits<T = Record<string, unknown>>({ render, empty, relatedTitle, loading, className }: HitsProps<T>): import("react").JSX.Element;
67
70
  export type RefinementListProps = {
68
71
  field: string;
69
72
  /** The heading (default the field). */
@@ -74,22 +74,27 @@ export function onLink(event) {
74
74
  return Boolean(link && event.currentTarget.contains(link));
75
75
  }
76
76
  /** The results. Following a link in a hit sends a click event, with its place and the query. */
77
- export function Hits({ render, empty, loading = null, className }) {
78
- const { hits, isLoaded, isPending, error, click } = useHits();
77
+ export function Hits({ render, empty, relatedTitle, loading = null, className }) {
78
+ const { hits, related, isLoaded, isPending, error, click } = useHits();
79
79
  const { query } = useSearchBox();
80
80
  if (error && !isLoaded)
81
81
  return _jsx("p", { "data-findlane-note": "", children: messageFor(error) });
82
82
  if (!isLoaded)
83
83
  return _jsx(_Fragment, { children: loading });
84
- if (!hits.length)
84
+ if (!hits.length && !related.length)
85
85
  return _jsx("p", { "data-findlane-empty": "", children: empty ? empty(query) : query ? _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) : "No results" });
86
- return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) => {
86
+ // Nothing matched, but related results found products close in meaning: those, under their own heading.
87
+ const shown = hits.length ? hits : related;
88
+ const list = (_jsx("ol", { className: className, "data-findlane-hits": "", "data-findlane-related": !hits.length || undefined, "data-pending": isPending || undefined, children: shown.map((hit, index) => {
87
89
  // A click, and a middle click that opens the link in a new tab.
88
90
  const clicked = (event) => { if (!event.defaultPrevented && onLink(event))
89
91
  click(hit); };
90
92
  return (_jsx("li", { onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
91
93
  clicked(event); }, children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id));
92
94
  }) }));
95
+ if (hits.length)
96
+ return list;
97
+ return (_jsxs(_Fragment, { children: [_jsx("p", { "data-findlane-related-title": "", children: relatedTitle ? relatedTitle(query) : "No exact matches. Related products" }), list] }));
93
98
  }
94
99
  /** Checkboxes for a facet's values, with counts. */
95
100
  export function RefinementList({ field, label, limit = 8, format, className }) {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
- // The API escapes highlighted values (dashboard/search-terms.ts) and wraps matched words in
3
+ // The API escapes highlighted values (the engine's, engine/src/present.rs) and wraps matched words in
4
4
  // <mark>. Splitting on the tags and decoding the five escapes gives text React can render, so
5
5
  // nothing is inserted as HTML.
6
6
  const escapes = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
@@ -35,6 +35,8 @@ export declare function useSearchBox(): {
35
35
  export declare function useHits<T = Record<string, unknown>>(): {
36
36
  hits: import("./state.js").Hit<T>[];
37
37
  total: number;
38
+ /** With the index's related results on, when nothing matched: the products closest in meaning. */
39
+ related: import("../index.js").SearchHit<T>[];
38
40
  /** "gte" when `total` is a lower bound (with `countMode: "page"`). */
39
41
  totalRelation: "eq" | "gte";
40
42
  response: import("../index.js").SearchResponse<T> | null;
@@ -82,6 +82,8 @@ export function useHits() {
82
82
  return {
83
83
  hits: results?.hits ?? [],
84
84
  total: results?.response.total ?? 0,
85
+ /** With the index's related results on, when nothing matched: the products closest in meaning. */
86
+ related: results?.response.related?.hits ?? [],
85
87
  /** "gte" when `total` is a lower bound (with `countMode: "page"`). */
86
88
  totalRelation: results?.response.totalRelation ?? "eq",
87
89
  response: results?.response ?? null,
@@ -62,7 +62,8 @@ export declare class SearchStore<T = unknown> {
62
62
  get settings(): SearchConfig;
63
63
  /** Opens the connection to the API before the first search needs it (the client's warm). */
64
64
  warm(): void;
65
- /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
65
+ /** Sends a click on a result: its place in the results, from 1, and the query they're for. A related
66
+ * result's click says so (list "related"), with its place among them. */
66
67
  click(objectID: string): void;
67
68
  /** Searches unless the results already answer the current state; with a state, such as the
68
69
  * URL's, adopts it first. */
@@ -67,12 +67,15 @@ export class SearchStore {
67
67
  get settings() { return this.config; }
68
68
  /** Opens the connection to the API before the first search needs it (the client's warm). */
69
69
  warm() { this.client.warm(); }
70
- /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
70
+ /** Sends a click on a result: its place in the results, from 1, and the query they're for. A related
71
+ * result's click says so (list "related"), with its place among them. */
71
72
  click(objectID) {
72
73
  const results = this.snapshot.results;
73
74
  const index = results ? results.hits.findIndex((hit) => hit.id === objectID) : -1;
75
+ const related = index < 0 && results ? (results.response.related?.hits ?? []).findIndex((hit) => hit.id === objectID) : -1;
74
76
  this.client.events.click(objectID, { query: results?.request.q ?? "",
75
- ...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}) });
77
+ ...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}),
78
+ ...(related >= 0 ? { position: related + 1, list: "related" } : {}) });
76
79
  }
77
80
  /** Searches unless the results already answer the current state; with a state, such as the
78
81
  * URL's, adopts it first. */
@@ -63,13 +63,7 @@ export declare function fillTemplate<T>(template: string, hit: Hit<T>): string |
63
63
  export declare function useAutocomplete<T = Record<string, unknown>>(options?: AutocompleteOptions<T>): {
64
64
  query: string;
65
65
  setQuery: (value: string) => void;
66
- hits: {
67
- id: string;
68
- score: number | null;
69
- record: T;
70
- distance?: number;
71
- highlights?: Record<string, string | string[]>;
72
- }[];
66
+ hits: import("../index.js").SearchHit<T>[];
73
67
  total: number;
74
68
  response: import("../index.js").SearchResponse<T> | null;
75
69
  error: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.16.1",
3
+ "version": "0.18.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",