@findlane/browser 0.7.0 → 0.9.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
@@ -110,30 +110,79 @@ export type RecommendationHit<T> = {
110
110
  score: number | null;
111
111
  record: T;
112
112
  };
113
+ /** A Bought together pick: `count` is the orders in the last year that had both products. */
114
+ export type TogetherHit<T> = RecommendationHit<T> & {
115
+ count: number;
116
+ };
113
117
  export type RecommendationsResponse<T> = {
114
118
  objectID: string;
115
- /** false until the product's lists are built; both lists are empty meanwhile. */
119
+ /** false until the product's lists are built; the lists are empty meanwhile. */
116
120
  ready: boolean;
117
121
  /** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
118
122
  goesWith: RecommendationHit<T>[];
119
123
  /** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
120
124
  similar: RecommendationHit<T>[];
125
+ /** Products bought in the same orders as this one, the most orders first, from the orders the store
126
+ * sends as events. Empty without orders. */
127
+ together?: TogetherHit<T>[];
128
+ /** What `together`'s counts are of. */
129
+ togetherBasis?: "orders";
121
130
  };
122
131
  export declare class FindlaneError extends Error {
123
132
  status: number;
124
133
  constructor(status: number, message: string);
125
134
  }
135
+ /** A product in a cart or an order. */
136
+ export type EventItem = {
137
+ objectID: string;
138
+ quantity?: number;
139
+ price?: number;
140
+ };
141
+ /** Where a clicked product was: its place in the results (from 1), and the search's query or the
142
+ * recommendation list (and the product whose list it was). */
143
+ export type ClickDetails = {
144
+ position?: number;
145
+ query?: string;
146
+ list?: "goesWith" | "similar" | "together";
147
+ from?: string;
148
+ };
149
+ /** Events from the page, sent in the background: a batch a second, and what's left when the page is
150
+ * hidden or closed. They carry no cookie or ID. */
151
+ export type BrowserEvents = {
152
+ view(objectID: string): void;
153
+ click(objectID: string, details?: ClickDetails): void;
154
+ addToCart(items: EventItem[], options?: {
155
+ currency?: string;
156
+ }): void;
157
+ /** Counts for Bought together only when the index allows orders from browsers (usually a server
158
+ * sends them); always counts on the index's Events page. */
159
+ purchase(order: {
160
+ orderID: string;
161
+ items: EventItem[];
162
+ currency?: string;
163
+ }): void;
164
+ /** With `events: "afterConsent"`: true sends the events held so far and those after; false drops them. */
165
+ consent(granted: boolean): void;
166
+ /** Sends the events waiting now. */
167
+ flush(): void;
168
+ };
169
+ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
126
170
  /** The hosted Findlane API, used unless `baseUrl` says otherwise. */
127
171
  export declare const defaultBaseUrl = "https://api.findlane.dev";
128
172
  export type BrowserClientOptions = {
129
173
  /** The API to call; defaults to the hosted Findlane API. */
130
174
  baseUrl?: string;
131
175
  publicId: string;
132
- fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
176
+ fetch?: Fetch;
177
+ /** Whether `events` sends anything: true (the default), false, or "afterConsent" to hold events until
178
+ * `events.consent(true)`. */
179
+ events?: boolean | "afterConsent";
133
180
  };
134
181
  export declare class SearchBrowserClient {
135
182
  private readonly url;
136
183
  private readonly fetcher;
184
+ /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
185
+ readonly events: BrowserEvents;
137
186
  constructor(options: BrowserClientOptions);
138
187
  private post;
139
188
  search<T = Record<string, unknown>>(request: SearchRequest, options?: {
@@ -151,3 +200,4 @@ export declare class SearchBrowserClient {
151
200
  signal?: AbortSignal;
152
201
  }): Promise<RecommendationsResponse<T>>;
153
202
  }
203
+ export {};
package/dist/index.js CHANGED
@@ -8,14 +8,60 @@ export class FindlaneError extends Error {
8
8
  }
9
9
  /** The hosted Findlane API, used unless `baseUrl` says otherwise. */
10
10
  export const defaultBaseUrl = "https://api.findlane.dev";
11
+ function eventSender(url, fetcher, mode) {
12
+ let queue = [];
13
+ let allowed = mode === "afterConsent" ? undefined : mode;
14
+ let timer;
15
+ let listening = false;
16
+ const flush = () => {
17
+ clearTimeout(timer);
18
+ timer = undefined;
19
+ while (allowed && queue.length) {
20
+ const body = JSON.stringify({ events: queue.splice(0, 100) });
21
+ // A beacon outlives the page; text/plain needs no CORS preflight.
22
+ if (fetcher || !(typeof navigator == "object" && navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" }))))
23
+ (fetcher ?? fetch)(url, { method: "POST", headers: { "content-type": "text/plain" }, body, keepalive: true })
24
+ .catch(() => undefined);
25
+ }
26
+ };
27
+ const add = (event) => {
28
+ if (allowed === false)
29
+ return;
30
+ if (queue.push(event) > 500)
31
+ queue.shift();
32
+ if (!listening && typeof document == "object") {
33
+ listening = true;
34
+ addEventListener("pagehide", flush);
35
+ document.addEventListener("visibilitychange", () => document.hidden && flush());
36
+ }
37
+ timer ??= setTimeout(flush, 1000);
38
+ };
39
+ return {
40
+ view: (objectID) => add({ type: "view", objectID }),
41
+ click: (objectID, details) => add({ type: "click", objectID, ...details }),
42
+ addToCart: (items, options) => add({ type: "addToCart", items, ...options }),
43
+ purchase: (order) => add({ type: "purchase", ...order }),
44
+ consent(granted) {
45
+ allowed = granted;
46
+ if (granted)
47
+ flush();
48
+ else
49
+ queue = [];
50
+ },
51
+ flush,
52
+ };
53
+ }
11
54
  export class SearchBrowserClient {
12
55
  url;
13
56
  fetcher;
57
+ /** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
58
+ events;
14
59
  constructor(options) {
15
60
  if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
16
61
  throw new Error("A valid public index ID (pub_…) is required");
17
62
  this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
18
63
  this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
64
+ this.events = eventSender(`${this.url}/events`, options.fetch, options.events ?? true);
19
65
  }
20
66
  async post(operation, request, signal) {
21
67
  const response = await this.fetcher(`${this.url}/${operation}`, {
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { type MouseEvent, type ReactNode } from "react";
2
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";
@@ -48,6 +48,9 @@ export type HitsProps<T> = {
48
48
  loading?: ReactNode;
49
49
  className?: string;
50
50
  };
51
+ /** Whether a click was on a link inside the element that handled it, such as a hit's link. */
52
+ export declare function onLink(event: MouseEvent<HTMLElement>): boolean;
53
+ /** The results. Following a link in a hit sends a click event, with its place and the query. */
51
54
  export declare function Hits<T = Record<string, unknown>>({ render, empty, loading, className }: HitsProps<T>): import("react").JSX.Element;
52
55
  export type RefinementListProps = {
53
56
  field: string;
@@ -38,8 +38,14 @@ export function SearchInput({ placeholder, label = "Search", className, autoFocu
38
38
  const { query, setQuery } = useSearchBox();
39
39
  return (_jsx("form", { role: "search", className: className, "data-findlane-search-input": "", onSubmit: (event) => event.preventDefault(), children: _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: placeholder, "aria-label": label, autoFocus: autoFocus, autoComplete: "off", spellCheck: false, maxLength: 200 }) }));
40
40
  }
41
+ /** Whether a click was on a link inside the element that handled it, such as a hit's link. */
42
+ export function onLink(event) {
43
+ const link = event.target.closest?.("a[href]");
44
+ return Boolean(link && event.currentTarget.contains(link));
45
+ }
46
+ /** The results. Following a link in a hit sends a click event, with its place and the query. */
41
47
  export function Hits({ render, empty, loading = null, className }) {
42
- const { hits, isLoaded, isPending, error } = useHits();
48
+ const { hits, isLoaded, isPending, error, click } = useHits();
43
49
  const { query } = useSearchBox();
44
50
  if (error && !isLoaded)
45
51
  return _jsx("p", { "data-findlane-note": "", children: messageFor(error) });
@@ -47,7 +53,13 @@ export function Hits({ render, empty, loading = null, className }) {
47
53
  return _jsx(_Fragment, { children: loading });
48
54
  if (!hits.length)
49
55
  return _jsx("p", { "data-findlane-empty": "", children: empty ? empty(query) : query ? _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) : "No results" });
50
- return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) => (_jsx("li", { children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id))) }));
56
+ return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) => {
57
+ // A click, and a middle click that opens the link in a new tab.
58
+ const clicked = (event) => { if (!event.defaultPrevented && onLink(event))
59
+ click(hit); };
60
+ return (_jsx("li", { onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
61
+ clicked(event); }, children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id));
62
+ }) }));
51
63
  }
52
64
  /** Checkboxes for a facet's values, with counts. */
53
65
  export function RefinementList({ field, label, limit = 8, format, className }) {
@@ -1,21 +1,36 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { RecommendationsRequest } from "../index.js";
3
- import type { ClientOptions } from "./client.js";
3
+ import { type ClientOptions } from "./client.js";
4
4
  import type { Hit } from "./state.js";
5
+ type List = "goesWith" | "similar" | "together";
5
6
  export type RecommendationsProps<T> = ClientOptions & RecommendationsRequest & {
6
7
  /** The product whose recommendations to show. */
7
8
  objectID: string;
8
- /** Products that go with it (the default), or similar products. */
9
- list?: "goesWith" | "similar";
10
- /** The heading (default "Goes well with" or "Similar products"); null for none. */
9
+ /** Products that go with it (the default), similar products, or products bought in the same orders
10
+ * ("together", from the orders the store sends as events). */
11
+ list?: List;
12
+ /** Another list that fills a short one, from the same response: `list="together" fill="goesWith"`
13
+ * gives a product with few orders a full list too. */
14
+ fill?: "goesWith" | "similar";
15
+ /** The heading; null for none. By default the list's: "Goes well with", "Similar products", or
16
+ * "Frequently bought together" when most products shown come from orders (otherwise the fill list's). */
11
17
  title?: ReactNode;
12
18
  /** A product's content; DefaultHit shows an image and a title. */
13
19
  render?: (hit: Hit<T>, index: number) => ReactNode;
20
+ /** A product's page: each product (DefaultHit, or what `render` gives) becomes a link to it. Leave it
21
+ * out when `render` has its own link. */
22
+ getHref?: (hit: Hit<T>) => string | undefined;
23
+ /** A product's page as a template, for server components, which can't pass `getHref`:
24
+ * "/products/{handle}" puts in the record's `handle`, URL-encoded, and `{objectID}` the product's ID. */
25
+ hrefTemplate?: string;
14
26
  /** Shown while the lists load. */
15
27
  loading?: ReactNode;
16
28
  className?: string;
17
29
  };
18
30
  /** One of a product's recommendation lists (the recommendations add-on). It renders nothing while the
19
- * product has none, so a product page can always include it. A Goes with and a Similar list for the
20
- * same product share one request. */
21
- export declare function Recommendations<T = Record<string, unknown>>({ objectID, list, title, render, loading, className, ...options }: RecommendationsProps<T>): import("react").JSX.Element | null;
31
+ * product has none, so a product page can always include it. Lists for the same product share one
32
+ * request. Each product says which list it came from, in data-findlane-source. Following a link in a
33
+ * product sends a click event with its list, place, and the product the list is for (the client's
34
+ * `events` option turns them off, or holds them until consent). */
35
+ export declare function Recommendations<T = Record<string, unknown>>({ objectID, list, fill, title, render, getHref, hrefTemplate, loading, className, ...options }: RecommendationsProps<T>): import("react").JSX.Element | null;
36
+ export {};
@@ -1,17 +1,39 @@
1
1
  "use client";
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { DefaultHit } from "./components.js";
3
+ import { useClient } from "./client.js";
4
+ import { DefaultHit, onLink } from "./components.js";
5
+ import { fillTemplate } from "./use-autocomplete.js";
4
6
  import { useRecommendations } from "./use-recommendations.js";
7
+ const headings = { goesWith: "Goes well with", similar: "Similar products", together: "Frequently bought together" };
5
8
  /** One of a product's recommendation lists (the recommendations add-on). It renders nothing while the
6
- * product has none, so a product page can always include it. A Goes with and a Similar list for the
7
- * same product share one request. */
8
- export function Recommendations({ objectID, list = "goesWith", title, render, loading = null, className, ...options }) {
9
+ * product has none, so a product page can always include it. Lists for the same product share one
10
+ * request. Each product says which list it came from, in data-findlane-source. Following a link in a
11
+ * product sends a click event with its list, place, and the product the list is for (the client's
12
+ * `events` option turns them off, or holds them until consent). */
13
+ export function Recommendations({ objectID, list = "goesWith", fill, title, render, getHref, hrefTemplate, loading = null, className, ...options }) {
9
14
  const { data, isPending } = useRecommendations(objectID, options);
15
+ const client = useClient({ client: options.client, publicId: options.publicId, baseUrl: options.baseUrl });
10
16
  if (!data)
11
17
  return isPending ? _jsx(_Fragment, { children: loading }) : null;
12
- const hits = data[list];
13
- if (!hits.length)
18
+ const limit = options.limit ?? 8;
19
+ const own = (data[list] ?? []).slice(0, limit).map((hit) => ({ hit, source: list }));
20
+ const taken = new Set([objectID, ...own.map((item) => item.hit.id)]);
21
+ const extra = fill && fill !== list ? (data[fill] ?? []).filter((hit) => !taken.has(hit.id))
22
+ .slice(0, limit - own.length).map((hit) => ({ hit, source: fill })) : [];
23
+ const items = [...own, ...extra];
24
+ if (!items.length)
14
25
  return null;
15
- const heading = title === undefined ? (list === "goesWith" ? "Goes well with" : "Similar products") : title;
16
- return (_jsxs("section", { className: className, "data-findlane-recommendations": list, children: [heading !== null && _jsx("h2", { "data-findlane-recommendations-title": "", children: heading }), _jsx("ol", { "data-findlane-hits": "", children: hits.map((hit, index) => _jsx("li", { children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id)) })] }));
26
+ // A heading that says what most of the list is.
27
+ const heading = title === undefined ? headings[fill && own.length * 2 < items.length ? fill : list] : title;
28
+ return (_jsxs("section", { className: className, "data-findlane-recommendations": list, children: [heading !== null && _jsx("h2", { "data-findlane-recommendations-title": "", children: heading }), _jsx("ol", { "data-findlane-hits": "", children: items.map(({ hit, source }, index) => {
29
+ const href = getHref ? getHref(hit) : hrefTemplate ? fillTemplate(hrefTemplate, hit) : undefined;
30
+ const content = render ? render(hit, index) : _jsx(DefaultHit, { hit: hit });
31
+ // A click, and a middle click that opens the link in a new tab.
32
+ const clicked = (event) => {
33
+ if (!event.defaultPrevented && onLink(event))
34
+ client.events.click(hit.id, { list: source, from: objectID, position: index + 1 });
35
+ };
36
+ return (_jsx("li", { "data-findlane-source": source, onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
37
+ clicked(event); }, children: href ? _jsx("a", { href: href, "data-findlane-link": "", children: content }) : content }, hit.id));
38
+ }) })] }));
17
39
  }
@@ -45,6 +45,11 @@ export declare function useHits<T = Record<string, unknown>>(): {
45
45
  isLoaded: boolean;
46
46
  isPending: boolean;
47
47
  error: unknown;
48
+ /** Sends a click event for a hit, with its place and the query, for click-through in Analytics.
49
+ * Hits does it for links in a hit; call it from a hit rendered without a link. */
50
+ click: (hit: {
51
+ id: string;
52
+ }) => void;
48
53
  };
49
54
  /** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
50
55
  * corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
@@ -76,7 +76,7 @@ export function useSearchBox() {
76
76
  return { query: state.q, setQuery, clear: () => setQuery(""), isPending: pending };
77
77
  }
78
78
  export function useHits() {
79
- const [{ results, pending, error }] = useSnapshot();
79
+ const [{ results, pending, error }, store] = useSnapshot();
80
80
  return {
81
81
  hits: results?.hits ?? [],
82
82
  total: results?.response.total ?? 0,
@@ -92,6 +92,9 @@ export function useHits() {
92
92
  isLoaded: results !== null,
93
93
  isPending: pending,
94
94
  error,
95
+ /** Sends a click event for a hit, with its place and the query, for click-through in Analytics.
96
+ * Hits does it for links in a hit; call it from a hit rendered without a link. */
97
+ click: (hit) => store.click(hit.id),
95
98
  };
96
99
  }
97
100
  /** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
@@ -55,6 +55,8 @@ export declare class SearchStore<T = unknown> {
55
55
  /** A new client or config, from changed props. */
56
56
  configure(client: SearchBrowserClient, config: SearchConfig): void;
57
57
  get settings(): SearchConfig;
58
+ /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
59
+ click(objectID: string): void;
58
60
  /** Searches unless the results already answer the current state; with a state, such as the
59
61
  * URL's, adopts it first. */
60
62
  start(state?: SearchState): void;
@@ -58,6 +58,13 @@ export class SearchStore {
58
58
  this.search(false);
59
59
  }
60
60
  get settings() { return this.config; }
61
+ /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
62
+ click(objectID) {
63
+ const results = this.snapshot.results;
64
+ const index = results ? results.hits.findIndex((hit) => hit.id === objectID) : -1;
65
+ this.client.events.click(objectID, { query: results?.request.q ?? "",
66
+ ...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}) });
67
+ }
61
68
  /** Searches unless the results already answer the current state; with a state, such as the
62
69
  * URL's, adopts it first. */
63
70
  start(state) {
@@ -163,11 +163,19 @@
163
163
  :where([data-findlane-recommendations] [data-findlane-hits]) {
164
164
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
165
165
  }
166
- :where([data-findlane-recommendations] [data-findlane-hits] > li) {
166
+ :where([data-findlane-recommendations] [data-findlane-hits] > li),
167
+ :where([data-findlane-recommendations] [data-findlane-link]) {
167
168
  display: grid;
168
169
  gap: 6px;
169
170
  align-content: start;
170
171
  }
172
+ :where([data-findlane-recommendations] [data-findlane-link]) {
173
+ color: inherit;
174
+ text-decoration: none;
175
+ }
176
+ :where([data-findlane-recommendations] [data-findlane-link]:hover [data-findlane-title]) {
177
+ text-decoration: underline;
178
+ }
171
179
  :where([data-findlane-recommendations] [data-findlane-image]) {
172
180
  width: 100%;
173
181
  height: auto;
@@ -1,6 +1,6 @@
1
1
  import { type HTMLAttributes, type InputHTMLAttributes } from "react";
2
2
  import type { Hit } from "./state.js";
3
- import type { ClientOptions } from "./client.js";
3
+ import { type ClientOptions } from "./client.js";
4
4
  export type AutocompleteOptions<T> = ClientOptions & {
5
5
  /** Suggestions to show: 1–50 (default 6). */
6
6
  limit?: number;
@@ -28,7 +28,9 @@ export type AutocompleteOptions<T> = ClientOptions & {
28
28
  /** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
29
29
  export declare function fillTemplate<T>(template: string, hit: Hit<T>): string | undefined;
30
30
  /** 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. */
31
+ * the prop getters give the input, list, and options their attributes and events. Choosing a
32
+ * suggestion sends a click event with its place and the query, for click-through in Analytics (the
33
+ * client's `events` option turns them off, or holds them until consent). */
32
34
  export declare function useAutocomplete<T = Record<string, unknown>>(options?: AutocompleteOptions<T>): {
33
35
  query: string;
34
36
  setQuery: (value: string) => void;
@@ -2,6 +2,7 @@
2
2
  import { useEffect, useId, useState, } from "react";
3
3
  import { valuesAt } from "./highlight.js";
4
4
  import { useSearch } from "./use-search.js";
5
+ import { useClient } from "./client.js";
5
6
  /** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
6
7
  export function fillTemplate(template, hit) {
7
8
  const value = (field) => (field === "objectID" ? hit.id : valuesAt(hit.record, field)[0] ?? "");
@@ -10,7 +11,9 @@ export function fillTemplate(template, hit) {
10
11
  return /^[a-z][a-z\d+.-]*:/i.test(href.trim()) && !/^https?:/i.test(href.trim()) ? undefined : href || undefined;
11
12
  }
12
13
  /** A search box with suggestions, as the WAI-ARIA combobox pattern: the hook keeps the state and
13
- * the prop getters give the input, list, and options their attributes and events. */
14
+ * the prop getters give the input, list, and options their attributes and events. Choosing a
15
+ * suggestion sends a click event with its place and the query, for click-through in Analytics (the
16
+ * client's `events` option turns them off, or holds them until consent). */
14
17
  export function useAutocomplete(options = {}) {
15
18
  const { limit = 6, fields, filters, sort, minLength = 1, debounceMs, hrefTemplate, onSelect, onSubmit } = options;
16
19
  const getHref = options.getHref ?? (hrefTemplate ? (hit) => fillTemplate(hrefTemplate, hit) : undefined);
@@ -33,6 +36,7 @@ export function useAutocomplete(options = {}) {
33
36
  const search = useSearch(request, {
34
37
  publicId: options.publicId, baseUrl: options.baseUrl, client: options.client, debounceMs,
35
38
  });
39
+ const client = useClient({ publicId: options.publicId, baseUrl: options.baseUrl, client: options.client });
36
40
  const hits = search.data?.hits ?? [];
37
41
  // Open once there's something to show: suggestions, "no results", or an error.
38
42
  const isOpen = focused && !dismissed && request !== null && (search.data !== null || search.error !== null);
@@ -49,7 +53,12 @@ export function useAutocomplete(options = {}) {
49
53
  setDismissed(true);
50
54
  setActiveIndex(-1);
51
55
  };
56
+ const clicked = (hit) => {
57
+ const index = hits.indexOf(hit);
58
+ client.events.click(hit.id, { query, ...(index >= 0 ? { position: index + 1 } : {}) });
59
+ };
52
60
  const select = (hit) => {
61
+ clicked(hit);
53
62
  close();
54
63
  if (onSelect)
55
64
  return onSelect(hit);
@@ -158,16 +167,20 @@ export function useAutocomplete(options = {}) {
158
167
  onClick: (event) => {
159
168
  props.onClick?.(event);
160
169
  const hit = hits[index];
161
- // A link opened in a new tab or window is left to the browser.
162
- if (event.defaultPrevented || !hit || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
170
+ if (event.defaultPrevented || !hit)
163
171
  return;
172
+ // A link opened in a new tab or window is left to the browser.
173
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
174
+ return clicked(hit);
164
175
  // A link without onSelect is followed as usual.
165
176
  if (onSelect || !event.currentTarget.href) {
166
177
  event.preventDefault();
167
178
  select(hit);
168
179
  }
169
- else
180
+ else {
181
+ clicked(hit);
170
182
  close();
183
+ }
171
184
  },
172
185
  }),
173
186
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",