@findlane/browser 0.4.2 → 0.5.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 +31 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +1 -1
- package/dist/react/search-provider.d.ts +16 -2
- package/dist/react/search-provider.js +18 -0
- package/dist/react/state.d.ts +10 -1
- package/dist/react/state.js +38 -1
- package/dist/react/store.d.ts +3 -2
- package/dist/react/store.js +4 -3
- package/dist/react/use-autocomplete.d.ts +1 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type SearchRequest = {
|
|
2
2
|
q?: string;
|
|
3
|
-
/** A sort ID from the index settings, or "
|
|
3
|
+
/** A sort ID from the index settings, "relevance", or "distance" (with `near`). */
|
|
4
4
|
sort?: string;
|
|
5
5
|
/** Field values to keep: a value, `{ in: [...] }`, or a range `{ gte, lte }` for numbers and dates. */
|
|
6
6
|
filters?: Record<string, unknown>;
|
|
@@ -26,12 +26,40 @@ export type SearchRequest = {
|
|
|
26
26
|
preTag?: string;
|
|
27
27
|
postTag?: string;
|
|
28
28
|
};
|
|
29
|
+
/** Nearest first from a point, with each hit's `distance` in meters; `radius` (meters) keeps only what's
|
|
30
|
+
* within it. With a query, the best matches come first and distance orders them. Needs the index's
|
|
31
|
+
* location field. Records without a location are left out.
|
|
32
|
+
* `{ visitor: true }` uses the visitor's approximate location from their IP address (about their city's
|
|
33
|
+
* center); `near` in the response says where that was, or null when it isn't known. */
|
|
34
|
+
near?: GeoPoint & {
|
|
35
|
+
radius?: number;
|
|
36
|
+
} | {
|
|
37
|
+
visitor: true;
|
|
38
|
+
radius?: number;
|
|
39
|
+
};
|
|
40
|
+
/** Only records inside a box, in degrees, such as a map's visible area. West greater than east crosses the
|
|
41
|
+
* antimeridian. */
|
|
42
|
+
within?: GeoBox;
|
|
43
|
+
};
|
|
44
|
+
/** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
|
|
45
|
+
export type GeoPoint = {
|
|
46
|
+
lat: number;
|
|
47
|
+
lng: number;
|
|
48
|
+
};
|
|
49
|
+
/** A box in degrees, such as a map's bounds. */
|
|
50
|
+
export type GeoBox = {
|
|
51
|
+
north: number;
|
|
52
|
+
south: number;
|
|
53
|
+
east: number;
|
|
54
|
+
west: number;
|
|
29
55
|
};
|
|
30
56
|
export type SearchResponse<T> = {
|
|
31
57
|
hits: {
|
|
32
58
|
id: string;
|
|
33
59
|
score: number | null;
|
|
34
60
|
record: T;
|
|
61
|
+
/** With `near`: meters from the point. */
|
|
62
|
+
distance?: number;
|
|
35
63
|
/** With `highlight`: matched searchable fields by path; list fields give one string per value. */
|
|
36
64
|
highlights?: Record<string, string | string[]>;
|
|
37
65
|
}[];
|
|
@@ -48,6 +76,8 @@ export type SearchResponse<T> = {
|
|
|
48
76
|
}>;
|
|
49
77
|
tookMs: number;
|
|
50
78
|
sort?: string;
|
|
79
|
+
/** With `near`: the point distances are from, or null when the visitor's location isn't known. */
|
|
80
|
+
near?: GeoPoint | null;
|
|
51
81
|
/** With the index's `didYouMean` setting: the query with the words typo tolerance corrected, when the
|
|
52
82
|
* results matched some words only by typo ("Showing results for …"), else null. */
|
|
53
83
|
didYouMean?: DidYouMean | null;
|
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
3
|
export { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
|
|
4
|
-
export { SearchProvider, useCurrentRefinements, useDidYouMean, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
|
|
4
|
+
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
|
|
5
5
|
export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
|
|
6
6
|
export { SearchDialog, type SearchDialogProps } from "./dialog.js";
|
|
7
7
|
export { Highlight, Snippet, excerpt, highlightParts, type HighlightProps } from "./highlight.js";
|
|
8
8
|
export { messageFor } from "./messages.js";
|
|
9
|
-
export { emptyState, stateFromUrl, urlFromState, type Hit, type SearchConfig, type SearchState, type UrlParams, } from "./state.js";
|
|
9
|
+
export { emptyState, stateFromUrl, urlFromState, type Hit, type Near, type SearchConfig, type SearchState, type UrlParams, } from "./state.js";
|
|
10
10
|
export type { ServerResults, StateChange } from "./store.js";
|
package/dist/react/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export { FindlaneProvider, useClient } from "./client.js";
|
|
4
4
|
export { useSearch } from "./use-search.js";
|
|
5
5
|
export { useAutocomplete } from "./use-autocomplete.js";
|
|
6
|
-
export { SearchProvider, useCurrentRefinements, useDidYouMean, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
|
|
6
|
+
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
|
|
7
7
|
export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
|
|
8
8
|
export { SearchDialog } from "./dialog.js";
|
|
9
9
|
export { Highlight, Snippet, excerpt, highlightParts } from "./highlight.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import type { DidYouMean } from "../index.js";
|
|
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 SearchConfig, type SearchState } from "./state.js";
|
|
5
|
+
import { 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
|
|
@@ -90,6 +90,20 @@ export declare function useToggle(field: string, value?: unknown): {
|
|
|
90
90
|
set: (next: boolean) => void;
|
|
91
91
|
toggle: () => void;
|
|
92
92
|
};
|
|
93
|
+
/** Location search, with the index's location field: nearest first from a point or the visitor, and
|
|
94
|
+
* only what a map shows. `center` is where distances are from: the point asked for, or the visitor's
|
|
95
|
+
* location once known (null when it isn't). A map's moves (`setWithin`) replace the page's history
|
|
96
|
+
* entry rather than adding one, so Back doesn't step through every drag. */
|
|
97
|
+
export declare function useGeoSearch(): {
|
|
98
|
+
near: Near | undefined;
|
|
99
|
+
within: GeoBox | undefined;
|
|
100
|
+
center: import("../index.js").GeoPoint | null | undefined;
|
|
101
|
+
setNear: (near: Near | undefined) => void;
|
|
102
|
+
/** Near the visitor's approximate location, from their IP address; with radius (meters), only within it. */
|
|
103
|
+
nearVisitor: (radius?: number) => void;
|
|
104
|
+
setWithin: (within: GeoBox | undefined) => void;
|
|
105
|
+
clear: () => void;
|
|
106
|
+
};
|
|
93
107
|
export declare function useSortBy(): {
|
|
94
108
|
/** The chosen sort ID; undefined for the index's default. */
|
|
95
109
|
sort: string | undefined;
|
|
@@ -169,6 +169,24 @@ export function useToggle(field, value = true) {
|
|
|
169
169
|
const set = (next) => store.update(withFilter(field, next ? value : undefined));
|
|
170
170
|
return { on, set, toggle: () => set(!on) };
|
|
171
171
|
}
|
|
172
|
+
/** Location search, with the index's location field: nearest first from a point or the visitor, and
|
|
173
|
+
* only what a map shows. `center` is where distances are from: the point asked for, or the visitor's
|
|
174
|
+
* location once known (null when it isn't). A map's moves (`setWithin`) replace the page's history
|
|
175
|
+
* entry rather than adding one, so Back doesn't step through every drag. */
|
|
176
|
+
export function useGeoSearch() {
|
|
177
|
+
const [{ state, results }, store] = useSnapshot();
|
|
178
|
+
const setNear = (near) => store.update(({ near: _, ...current }) => ({ ...current, page: 0, ...(near ? { near } : {}) }));
|
|
179
|
+
return {
|
|
180
|
+
near: state.near,
|
|
181
|
+
within: state.within,
|
|
182
|
+
center: results?.response.near,
|
|
183
|
+
setNear,
|
|
184
|
+
/** Near the visitor's approximate location, from their IP address; with radius (meters), only within it. */
|
|
185
|
+
nearVisitor: (radius) => setNear({ visitor: true, ...(radius ? { radius } : {}) }),
|
|
186
|
+
setWithin: (within) => store.update(({ within: _, ...current }) => ({ ...current, page: 0, ...(within ? { within } : {}) }), false),
|
|
187
|
+
clear: () => store.update(({ near: _, within: __, ...current }) => ({ ...current, page: 0 })),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
172
190
|
export function useSortBy() {
|
|
173
191
|
const [{ state }, store] = useSnapshot();
|
|
174
192
|
return {
|
package/dist/react/state.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { SearchRequest, SearchResponse } from "../index.js";
|
|
1
|
+
import type { GeoBox, SearchRequest, SearchResponse } from "../index.js";
|
|
2
2
|
export type Hit<T> = SearchResponse<T>["hits"][number];
|
|
3
|
+
/** A point (or the visitor's location) to search near; see `near` in SearchRequest. */
|
|
4
|
+
export type Near = NonNullable<SearchRequest["near"]>;
|
|
3
5
|
/** What a visitor has chosen on a results page. It round-trips through the URL. */
|
|
4
6
|
export type SearchState = {
|
|
5
7
|
q: string;
|
|
@@ -11,6 +13,10 @@ export type SearchState = {
|
|
|
11
13
|
filters: Record<string, unknown>;
|
|
12
14
|
/** The page, from 0. */
|
|
13
15
|
page: number;
|
|
16
|
+
/** Nearest first from a point or the visitor (useGeoSearch). */
|
|
17
|
+
near?: Near;
|
|
18
|
+
/** Only records inside a box, such as a map's visible area (useGeoSearch). */
|
|
19
|
+
within?: GeoBox;
|
|
14
20
|
};
|
|
15
21
|
export type SearchConfig = {
|
|
16
22
|
/** Fields to count values for. A facet's own selections don't narrow its counts, so its other
|
|
@@ -23,6 +29,9 @@ export type SearchConfig = {
|
|
|
23
29
|
facetStats?: string[];
|
|
24
30
|
/** Filters always applied, such as `{ published: true }`. They aren't shown as refinements. */
|
|
25
31
|
filters?: Record<string, unknown>;
|
|
32
|
+
/** Where searches are near unless the state says otherwise, such as `{ visitor: true }` for the
|
|
33
|
+
* visitor's approximate location. Needs the index's location field. */
|
|
34
|
+
near?: Near;
|
|
26
35
|
/** Hits per page: 1–50 (default 20). */
|
|
27
36
|
pageSize?: number;
|
|
28
37
|
/** The record fields to return (`attributesToRetrieve`); all of them when left out. */
|
package/dist/react/state.js
CHANGED
|
@@ -16,6 +16,8 @@ export function planSearch(config, state) {
|
|
|
16
16
|
return {
|
|
17
17
|
q: state.q,
|
|
18
18
|
...(state.sort ? { sort: state.sort } : {}),
|
|
19
|
+
...(state.near ?? config.near ? { near: state.near ?? config.near } : {}),
|
|
20
|
+
...(state.within ? { within: state.within } : {}),
|
|
19
21
|
...(Object.keys(filters).length ? { filters } : {}),
|
|
20
22
|
...(facets.length ? { facets } : {}),
|
|
21
23
|
...(disjunctive.length ? { disjunctiveFacets: disjunctive } : {}),
|
|
@@ -62,6 +64,33 @@ function formatValue(value) {
|
|
|
62
64
|
return value;
|
|
63
65
|
return JSON.stringify(value);
|
|
64
66
|
}
|
|
67
|
+
// Locations in the URL: near=51.5074,-0.1278 (with ,5000 for a radius in meters) or near=visitor (,5000),
|
|
68
|
+
// and within=north,east,south,west, the order CSS gives a box's sides.
|
|
69
|
+
const coordinate = (value) => String(Math.round(value * 1e6) / 1e6);
|
|
70
|
+
function parseNear(text) {
|
|
71
|
+
const parts = text?.split(",") ?? [];
|
|
72
|
+
const numbers = parts.map(Number);
|
|
73
|
+
const radius = (value) => value !== undefined && value > 0 ? { radius: value } : {};
|
|
74
|
+
if (parts[0] === "visitor" && parts.length <= 2 && (parts.length === 1 || numbers[1] > 0))
|
|
75
|
+
return { visitor: true, ...radius(numbers[1]) };
|
|
76
|
+
const [lat, lng] = numbers;
|
|
77
|
+
if (parts.length < 2 || parts.length > 3 || numbers.some((value) => !Number.isFinite(value)) ||
|
|
78
|
+
Math.abs(lat) > 90 || Math.abs(lng) > 180)
|
|
79
|
+
return undefined;
|
|
80
|
+
return { lat: lat, lng: lng, ...radius(numbers[2]) };
|
|
81
|
+
}
|
|
82
|
+
function parseWithin(text) {
|
|
83
|
+
const sides = text?.split(",").map(Number) ?? [];
|
|
84
|
+
if (sides.length !== 4 || sides.some((value) => !Number.isFinite(value)))
|
|
85
|
+
return undefined;
|
|
86
|
+
const [north, east, south, west] = sides;
|
|
87
|
+
return north > south && north <= 90 && south >= -90 && Math.abs(east) <= 180 && Math.abs(west) <= 180
|
|
88
|
+
? { north, east, south, west } : undefined;
|
|
89
|
+
}
|
|
90
|
+
function formatNear(near) {
|
|
91
|
+
const at = "visitor" in near ? ["visitor"] : [coordinate(near.lat), coordinate(near.lng)];
|
|
92
|
+
return [...at, ...(near.radius ? [String(near.radius)] : [])].join(",");
|
|
93
|
+
}
|
|
65
94
|
function paramsFrom(search) {
|
|
66
95
|
if (typeof search === "string" || search instanceof URLSearchParams)
|
|
67
96
|
return new URLSearchParams(search);
|
|
@@ -87,12 +116,16 @@ export function stateFromUrl(search, config) {
|
|
|
87
116
|
filters[field] = parseValue(value);
|
|
88
117
|
}
|
|
89
118
|
const page = Number(params.get("page"));
|
|
119
|
+
const near = parseNear(params.get("near"));
|
|
120
|
+
const within = parseWithin(params.get("within"));
|
|
90
121
|
return {
|
|
91
122
|
q: params.get("q") ?? "",
|
|
92
123
|
...(params.get("sort") ? { sort: params.get("sort") } : {}),
|
|
93
124
|
refinements,
|
|
94
125
|
filters,
|
|
95
126
|
page: Number.isInteger(page) && page > 1 ? page - 1 : 0,
|
|
127
|
+
...(near ? { near } : {}),
|
|
128
|
+
...(within ? { within } : {}),
|
|
96
129
|
};
|
|
97
130
|
}
|
|
98
131
|
/** The query string for a state, followed by the URL's other parameters. */
|
|
@@ -111,7 +144,11 @@ export function urlFromState(state, config, current = "") {
|
|
|
111
144
|
params.set(field, formatValue(value));
|
|
112
145
|
if (state.page > 0)
|
|
113
146
|
params.set("page", String(state.page + 1));
|
|
114
|
-
|
|
147
|
+
if (state.near)
|
|
148
|
+
params.set("near", formatNear(state.near));
|
|
149
|
+
if (state.within)
|
|
150
|
+
params.set("within", [state.within.north, state.within.east, state.within.south, state.within.west].map(coordinate).join(","));
|
|
151
|
+
const own = new Set(["q", "sort", "page", "near", "within", ...(config.facets ?? []), ...(config.filterFields ?? [])]);
|
|
115
152
|
for (const [key, value] of new URLSearchParams(current))
|
|
116
153
|
if (!own.has(key))
|
|
117
154
|
params.append(key, value);
|
package/dist/react/store.d.ts
CHANGED
|
@@ -47,8 +47,9 @@ export declare class SearchStore<T = unknown> {
|
|
|
47
47
|
subscribe: (listener: () => void) => () => void;
|
|
48
48
|
getSnapshot: () => Snapshot<T>;
|
|
49
49
|
private set;
|
|
50
|
-
/** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
|
|
51
|
-
|
|
50
|
+
/** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
|
|
51
|
+
* `push` overrides how it reaches the history, as a map's moves replace the entry. */
|
|
52
|
+
update(change: (state: SearchState) => SearchState, push?: boolean): void;
|
|
52
53
|
/** A state from outside, such as the URL after Back. */
|
|
53
54
|
adopt(state: SearchState): void;
|
|
54
55
|
/** A new client or config, from changed props. */
|
package/dist/react/store.js
CHANGED
|
@@ -30,15 +30,16 @@ export class SearchStore {
|
|
|
30
30
|
for (const listener of this.listeners)
|
|
31
31
|
listener();
|
|
32
32
|
}
|
|
33
|
-
/** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
|
|
34
|
-
|
|
33
|
+
/** A visitor's change. Changes to the query wait for a pause in typing; the others search at once.
|
|
34
|
+
* `push` overrides how it reaches the history, as a map's moves replace the entry. */
|
|
35
|
+
update(change, push) {
|
|
35
36
|
const previous = this.snapshot.state;
|
|
36
37
|
const state = change(previous);
|
|
37
38
|
if (canonical(state) === canonical(previous))
|
|
38
39
|
return;
|
|
39
40
|
const typing = state.q !== previous.q && canonical({ ...state, q: "", page: 0 }) === canonical({ ...previous, q: "", page: 0 });
|
|
40
41
|
this.set({ state });
|
|
41
|
-
this.onChange?.(state, { push: !typing });
|
|
42
|
+
this.onChange?.(state, { push: push ?? !typing });
|
|
42
43
|
this.search(typing);
|
|
43
44
|
}
|
|
44
45
|
/** A state from outside, such as the URL after Back. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@findlane/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|