@findlane/browser 0.1.0 → 0.3.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/README.md +15 -1
- package/dist/index.d.ts +24 -0
- package/dist/index.js +39 -36
- package/dist/react/cache.d.ts +7 -0
- package/dist/react/cache.js +42 -0
- package/dist/react/client.d.ts +14 -0
- package/dist/react/client.js +23 -0
- package/dist/react/components.d.ts +97 -0
- package/dist/react/components.js +93 -0
- package/dist/react/dialog.d.ts +11 -0
- package/dist/react/dialog.js +63 -0
- package/dist/react/highlight.d.ts +29 -0
- package/dist/react/highlight.js +68 -0
- package/dist/react/index.d.ts +10 -0
- package/dist/react/index.js +11 -0
- package/dist/react/messages.d.ts +2 -0
- package/dist/react/messages.js +13 -0
- package/dist/react/search-provider.d.ts +121 -0
- package/dist/react/search-provider.js +214 -0
- package/dist/react/ssr.d.ts +12 -0
- package/dist/react/ssr.js +13 -0
- package/dist/react/state.d.ts +52 -0
- package/dist/react/state.js +127 -0
- package/dist/react/store.d.ts +67 -0
- package/dist/react/store.js +136 -0
- package/dist/react/styles.css +260 -0
- package/dist/react/use-autocomplete.d.ts +55 -0
- package/dist/react/use-autocomplete.js +174 -0
- package/dist/react/use-search.d.ts +19 -0
- package/dist/react/use-search.js +46 -0
- package/package.json +28 -5
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// @findlane/browser/react: hooks that keep searches current, and components built on them.
|
|
3
|
+
export { FindlaneProvider, useClient } from "./client.js";
|
|
4
|
+
export { useSearch } from "./use-search.js";
|
|
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";
|
|
7
|
+
export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
|
|
8
|
+
export { SearchDialog } from "./dialog.js";
|
|
9
|
+
export { Highlight, Snippet, excerpt, highlightParts } from "./highlight.js";
|
|
10
|
+
export { messageFor } from "./messages.js";
|
|
11
|
+
export { emptyState, stateFromUrl, urlFromState, } from "./state.js";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** A failed search as a visitor should read it. The details for developers are on the error. */
|
|
2
|
+
export declare function messageFor(error: unknown): "That was a lot of searches in a short time. Try again in a minute." | "Search isn't available right now." | "The search didn't go through. Check your connection and try again.";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FindlaneError } from "../index.js";
|
|
2
|
+
/** A failed search as a visitor should read it. The details for developers are on the error. */
|
|
3
|
+
export function messageFor(error) {
|
|
4
|
+
if (error instanceof FindlaneError) {
|
|
5
|
+
if (error.status === 429)
|
|
6
|
+
return "That was a lot of searches in a short time. Try again in a minute.";
|
|
7
|
+
// 403 (this site isn't an allowed origin) and 402 (the plan's searches are used up) aren't
|
|
8
|
+
// something a visitor can fix.
|
|
9
|
+
if (error.status === 403 || error.status === 402)
|
|
10
|
+
return "Search isn't available right now.";
|
|
11
|
+
}
|
|
12
|
+
return "The search didn't go through. Check your connection and try again.";
|
|
13
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { DidYouMean } from "../index.js";
|
|
3
|
+
import { type ClientOptions } from "./client.js";
|
|
4
|
+
import { type ServerResults, type StateChange } from "./store.js";
|
|
5
|
+
import { type SearchConfig, type SearchState } from "./state.js";
|
|
6
|
+
export type SearchProviderProps<T> = SearchConfig & ClientOptions & {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Keeps the state in the page's URL (`?q=shoe&brand=Acme`), so results can be shared and Back
|
|
9
|
+
* undoes a filter. */
|
|
10
|
+
routing?: boolean;
|
|
11
|
+
/** A state to adopt whenever it changes, such as one from your router. */
|
|
12
|
+
state?: SearchState;
|
|
13
|
+
/** Called after each change a visitor makes; `push` is false while typing. */
|
|
14
|
+
onStateChange?: (state: SearchState, change: StateChange) => void;
|
|
15
|
+
/** Results from getServerResults (`@findlane/browser/react/ssr`), so the first render doesn't
|
|
16
|
+
* search again. */
|
|
17
|
+
initialResults?: ServerResults<T>;
|
|
18
|
+
};
|
|
19
|
+
/** A results page's search. The hooks and components inside it share its state and results. */
|
|
20
|
+
export declare function SearchProvider<T = Record<string, unknown>>(props: SearchProviderProps<T>): import("react").JSX.Element;
|
|
21
|
+
type Update = (state: SearchState) => SearchState;
|
|
22
|
+
/** The whole state, for anything the other hooks don't cover. */
|
|
23
|
+
export declare function useSearchState(): {
|
|
24
|
+
state: SearchState;
|
|
25
|
+
setState: (change: SearchState | Update) => void;
|
|
26
|
+
};
|
|
27
|
+
export declare function useSearchBox(): {
|
|
28
|
+
query: string;
|
|
29
|
+
setQuery: (q: string) => void;
|
|
30
|
+
clear: () => void;
|
|
31
|
+
isPending: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare function useHits<T = Record<string, unknown>>(): {
|
|
34
|
+
hits: import("./state.js").Hit<T>[];
|
|
35
|
+
total: number;
|
|
36
|
+
/** "gte" when `total` is a lower bound (with `countMode: "page"`). */
|
|
37
|
+
totalRelation: "eq" | "gte";
|
|
38
|
+
response: import("../index.js").SearchResponse<T> | null;
|
|
39
|
+
/** The request behind `response`. */
|
|
40
|
+
request: import("../index.js").SearchRequest | null;
|
|
41
|
+
/** How long the search took from request to response, in milliseconds; undefined when the
|
|
42
|
+
* results came from memory or the server. */
|
|
43
|
+
ms: number | undefined;
|
|
44
|
+
/** Whether the first results have arrived. */
|
|
45
|
+
isLoaded: boolean;
|
|
46
|
+
isPending: boolean;
|
|
47
|
+
error: unknown;
|
|
48
|
+
};
|
|
49
|
+
/** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
|
|
50
|
+
* corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
|
|
51
|
+
* finds results, asked for once the visitor has stopped typing for `delay` ms. `apply` searches for it.
|
|
52
|
+
* Searches carry `didYouMean` only when the setting is on, so other indexes are never asked. */
|
|
53
|
+
export declare function useDidYouMean({ delay }?: {
|
|
54
|
+
delay?: number;
|
|
55
|
+
}): {
|
|
56
|
+
didYouMean: DidYouMean | null;
|
|
57
|
+
apply: () => void;
|
|
58
|
+
};
|
|
59
|
+
/** A facet's values with their counts, most common first. Selected values stay listed (at 0 when the
|
|
60
|
+
* query no longer matches them), so they can be removed. */
|
|
61
|
+
export declare function useRefinementList(field: string): {
|
|
62
|
+
items: {
|
|
63
|
+
value: string;
|
|
64
|
+
count: number;
|
|
65
|
+
selected: boolean;
|
|
66
|
+
}[];
|
|
67
|
+
selected: string[];
|
|
68
|
+
toggle: (value: string) => void;
|
|
69
|
+
clear: () => void;
|
|
70
|
+
};
|
|
71
|
+
export type Range = {
|
|
72
|
+
gte?: number | string;
|
|
73
|
+
lte?: number | string;
|
|
74
|
+
};
|
|
75
|
+
/** A number or date range filter. List the field in `filterFields` to keep it in the URL. */
|
|
76
|
+
export declare function useRange(field: string): {
|
|
77
|
+
range: Range | undefined;
|
|
78
|
+
/** Leave a bound out (or empty) for no limit on that side. */
|
|
79
|
+
setRange: (range: Range | undefined) => void;
|
|
80
|
+
clear: () => void;
|
|
81
|
+
};
|
|
82
|
+
/** A filter on one value, such as in stock only. List the field in `filterFields` to keep it in the
|
|
83
|
+
* URL. */
|
|
84
|
+
export declare function useToggle(field: string, value?: unknown): {
|
|
85
|
+
on: boolean;
|
|
86
|
+
set: (next: boolean) => void;
|
|
87
|
+
toggle: () => void;
|
|
88
|
+
};
|
|
89
|
+
export declare function useSortBy(): {
|
|
90
|
+
/** The chosen sort ID; undefined for the index's default. */
|
|
91
|
+
sort: string | undefined;
|
|
92
|
+
setSort: (sort: string | undefined) => void;
|
|
93
|
+
};
|
|
94
|
+
export type Refinement = {
|
|
95
|
+
field: string;
|
|
96
|
+
value: unknown;
|
|
97
|
+
label: string;
|
|
98
|
+
remove: () => void;
|
|
99
|
+
};
|
|
100
|
+
/** Everything chosen, as removable items (for chips), and a way to clear it all. The query stays. */
|
|
101
|
+
export declare function useCurrentRefinements(): {
|
|
102
|
+
items: Refinement[];
|
|
103
|
+
clear: () => void;
|
|
104
|
+
};
|
|
105
|
+
/** Adds the next page's hits under the current ones. */
|
|
106
|
+
export declare function useLoadMore(): {
|
|
107
|
+
hasMore: boolean;
|
|
108
|
+
loadMore: () => void;
|
|
109
|
+
isLoading: boolean;
|
|
110
|
+
shown: number;
|
|
111
|
+
total: number;
|
|
112
|
+
};
|
|
113
|
+
/** Numbered pages, from 0. The API returns hits up to the 1,000th offset, so pages stop there. */
|
|
114
|
+
export declare function usePagination(): {
|
|
115
|
+
page: number;
|
|
116
|
+
pages: number;
|
|
117
|
+
setPage: (page: number) => void;
|
|
118
|
+
hasPrevious: boolean;
|
|
119
|
+
hasNext: boolean;
|
|
120
|
+
};
|
|
121
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore, } from "react";
|
|
4
|
+
import { useClient } from "./client.js";
|
|
5
|
+
import { SearchStore } from "./store.js";
|
|
6
|
+
import { canonical, emptyState, isRange, maxOffset, stateFromUrl, urlFromState, } from "./state.js";
|
|
7
|
+
const StoreContext = createContext(null);
|
|
8
|
+
// Layout effects don't run on the server, and React 18 warns there.
|
|
9
|
+
const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
10
|
+
/** A results page's search. The hooks and components inside it share its state and results. */
|
|
11
|
+
export function SearchProvider(props) {
|
|
12
|
+
const { children, routing = false, state, onStateChange, initialResults, publicId, baseUrl, client: given, ...rest } = props;
|
|
13
|
+
const client = useClient({ publicId, baseUrl, client: given });
|
|
14
|
+
const configKey = canonical(rest);
|
|
15
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
16
|
+
const config = useMemo(() => rest, [configKey]);
|
|
17
|
+
// The first render matches the server's: the URL's state is adopted after it.
|
|
18
|
+
const [store] = useState(() => new SearchStore(client, config, state ?? initialResults?.state ?? emptyState, initialResults));
|
|
19
|
+
useClientLayoutEffect(() => {
|
|
20
|
+
store.onChange = (next, change) => {
|
|
21
|
+
if (routing) {
|
|
22
|
+
const params = urlFromState(next, config, window.location.search).toString();
|
|
23
|
+
const url = `${window.location.pathname}${params ? `?${params}` : ""}${window.location.hash}`;
|
|
24
|
+
window.history[change.push ? "pushState" : "replaceState"](window.history.state, "", url);
|
|
25
|
+
}
|
|
26
|
+
onStateChange?.(next, change);
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
useClientLayoutEffect(() => {
|
|
30
|
+
store.start(routing && !state ? stateFromUrl(window.location.search, config) : undefined);
|
|
31
|
+
return () => store.stop();
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
}, [store]);
|
|
34
|
+
useEffect(() => store.configure(client, config), [store, client, config]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!routing)
|
|
37
|
+
return;
|
|
38
|
+
const onPopState = () => store.adopt(stateFromUrl(window.location.search, config));
|
|
39
|
+
window.addEventListener("popstate", onPopState);
|
|
40
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
41
|
+
}, [store, routing, config]);
|
|
42
|
+
const stateKey = state ? canonical(state) : null;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (state)
|
|
45
|
+
store.adopt(state);
|
|
46
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
47
|
+
}, [store, stateKey]);
|
|
48
|
+
return _jsx(StoreContext.Provider, { value: store, children: children });
|
|
49
|
+
}
|
|
50
|
+
function useStore() {
|
|
51
|
+
const store = useContext(StoreContext);
|
|
52
|
+
if (!store)
|
|
53
|
+
throw new Error("Put this inside a SearchProvider");
|
|
54
|
+
return store;
|
|
55
|
+
}
|
|
56
|
+
function useSnapshot() {
|
|
57
|
+
const store = useStore();
|
|
58
|
+
return [useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot), store];
|
|
59
|
+
}
|
|
60
|
+
const withFilter = (field, value) => (state) => {
|
|
61
|
+
const { [field]: _, ...filters } = state.filters;
|
|
62
|
+
return { ...state, page: 0, filters: value === undefined ? filters : { ...filters, [field]: value } };
|
|
63
|
+
};
|
|
64
|
+
const withRefinement = (field, values) => (state) => {
|
|
65
|
+
const { [field]: _, ...refinements } = state.refinements;
|
|
66
|
+
return { ...state, page: 0, refinements: values.length ? { ...refinements, [field]: values } : refinements };
|
|
67
|
+
};
|
|
68
|
+
/** The whole state, for anything the other hooks don't cover. */
|
|
69
|
+
export function useSearchState() {
|
|
70
|
+
const [{ state }, store] = useSnapshot();
|
|
71
|
+
return { state, setState: (change) => store.update(typeof change === "function" ? change : () => change) };
|
|
72
|
+
}
|
|
73
|
+
export function useSearchBox() {
|
|
74
|
+
const [{ state, pending }, store] = useSnapshot();
|
|
75
|
+
const setQuery = (q) => store.update((current) => ({ ...current, q, page: 0 }));
|
|
76
|
+
return { query: state.q, setQuery, clear: () => setQuery(""), isPending: pending };
|
|
77
|
+
}
|
|
78
|
+
export function useHits() {
|
|
79
|
+
const [{ results, pending, error }] = useSnapshot();
|
|
80
|
+
return {
|
|
81
|
+
hits: results?.hits ?? [],
|
|
82
|
+
total: results?.response.total ?? 0,
|
|
83
|
+
/** "gte" when `total` is a lower bound (with `countMode: "page"`). */
|
|
84
|
+
totalRelation: results?.response.totalRelation ?? "eq",
|
|
85
|
+
response: results?.response ?? null,
|
|
86
|
+
/** The request behind `response`. */
|
|
87
|
+
request: results?.request ?? null,
|
|
88
|
+
/** How long the search took from request to response, in milliseconds; undefined when the
|
|
89
|
+
* results came from memory or the server. */
|
|
90
|
+
ms: results?.ms,
|
|
91
|
+
/** Whether the first results have arrived. */
|
|
92
|
+
isLoaded: results !== null,
|
|
93
|
+
isPending: pending,
|
|
94
|
+
error,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
|
|
98
|
+
* corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
|
|
99
|
+
* finds results, asked for once the visitor has stopped typing for `delay` ms. `apply` searches for it.
|
|
100
|
+
* Searches carry `didYouMean` only when the setting is on, so other indexes are never asked. */
|
|
101
|
+
export function useDidYouMean({ delay = 400 } = {}) {
|
|
102
|
+
const [{ results, pending }, store] = useSnapshot();
|
|
103
|
+
const [suggested, setSuggested] = useState(null);
|
|
104
|
+
const empty = Boolean(results && !pending && !results.response.total && results.request.q?.trim() &&
|
|
105
|
+
results.response.didYouMean !== undefined);
|
|
106
|
+
const key = results?.key;
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!empty || !results)
|
|
109
|
+
return;
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => store.suggest(results.request, controller.signal)
|
|
112
|
+
.then((didYouMean) => setSuggested({ key: results.key, didYouMean }), () => undefined), delay);
|
|
113
|
+
return () => { clearTimeout(timer); controller.abort(); };
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [store, key, empty, delay]);
|
|
116
|
+
const didYouMean = results?.response.didYouMean ?? (empty && suggested?.key === key ? suggested.didYouMean : null);
|
|
117
|
+
return {
|
|
118
|
+
didYouMean,
|
|
119
|
+
apply: () => { if (didYouMean)
|
|
120
|
+
store.update((current) => ({ ...current, q: didYouMean.q, page: 0 })); },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** A facet's values with their counts, most common first. Selected values stay listed (at 0 when the
|
|
124
|
+
* query no longer matches them), so they can be removed. */
|
|
125
|
+
export function useRefinementList(field) {
|
|
126
|
+
const [{ state, results }, store] = useSnapshot();
|
|
127
|
+
const selected = state.refinements[field] ?? [];
|
|
128
|
+
const counts = results?.response.facets[field] ?? {};
|
|
129
|
+
const items = Object.entries(counts)
|
|
130
|
+
.map(([value, count]) => ({ value, count, selected: selected.includes(value) }))
|
|
131
|
+
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
132
|
+
for (const value of selected)
|
|
133
|
+
if (!(value in counts))
|
|
134
|
+
items.push({ value, count: 0, selected: true });
|
|
135
|
+
return {
|
|
136
|
+
items,
|
|
137
|
+
selected,
|
|
138
|
+
toggle: (value) => store.update((current) => {
|
|
139
|
+
const values = current.refinements[field] ?? [];
|
|
140
|
+
return withRefinement(field, values.includes(value) ? values.filter((item) => item !== value) : [...values, value])(current);
|
|
141
|
+
}),
|
|
142
|
+
clear: () => store.update(withRefinement(field, [])),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** A number or date range filter. List the field in `filterFields` to keep it in the URL. */
|
|
146
|
+
export function useRange(field) {
|
|
147
|
+
const [{ state }, store] = useSnapshot();
|
|
148
|
+
const value = state.filters[field];
|
|
149
|
+
return {
|
|
150
|
+
range: isRange(value) ? value : undefined,
|
|
151
|
+
/** Leave a bound out (or empty) for no limit on that side. */
|
|
152
|
+
setRange: (range) => {
|
|
153
|
+
const bounds = Object.entries(range ?? {}).filter(([, bound]) => bound !== undefined && bound !== "");
|
|
154
|
+
store.update(withFilter(field, bounds.length ? Object.fromEntries(bounds) : undefined));
|
|
155
|
+
},
|
|
156
|
+
clear: () => store.update(withFilter(field, undefined)),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** A filter on one value, such as in stock only. List the field in `filterFields` to keep it in the
|
|
160
|
+
* URL. */
|
|
161
|
+
export function useToggle(field, value = true) {
|
|
162
|
+
const [{ state }, store] = useSnapshot();
|
|
163
|
+
const on = field in state.filters && canonical(state.filters[field]) === canonical(value);
|
|
164
|
+
const set = (next) => store.update(withFilter(field, next ? value : undefined));
|
|
165
|
+
return { on, set, toggle: () => set(!on) };
|
|
166
|
+
}
|
|
167
|
+
export function useSortBy() {
|
|
168
|
+
const [{ state }, store] = useSnapshot();
|
|
169
|
+
return {
|
|
170
|
+
/** The chosen sort ID; undefined for the index's default. */
|
|
171
|
+
sort: state.sort,
|
|
172
|
+
setSort: (sort) => store.update(({ sort: _, ...current }) => ({ ...current, page: 0, ...(sort ? { sort } : {}) })),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function rangeLabel({ gte, lte }) {
|
|
176
|
+
if (gte !== undefined && lte !== undefined)
|
|
177
|
+
return gte === lte ? `${gte}` : `${gte}–${lte}`;
|
|
178
|
+
return gte !== undefined ? `From ${gte}` : `Until ${lte}`;
|
|
179
|
+
}
|
|
180
|
+
/** Everything chosen, as removable items (for chips), and a way to clear it all. The query stays. */
|
|
181
|
+
export function useCurrentRefinements() {
|
|
182
|
+
const [{ state }, store] = useSnapshot();
|
|
183
|
+
const items = [
|
|
184
|
+
...Object.entries(state.refinements).flatMap(([field, values]) => values.map((value) => ({
|
|
185
|
+
field, value, label: value,
|
|
186
|
+
remove: () => store.update((current) => withRefinement(field, (current.refinements[field] ?? []).filter((item) => item !== value))(current)),
|
|
187
|
+
}))),
|
|
188
|
+
...Object.entries(state.filters).map(([field, value]) => ({
|
|
189
|
+
field, value, label: isRange(value) ? rangeLabel(value) : String(value),
|
|
190
|
+
remove: () => store.update(withFilter(field, undefined)),
|
|
191
|
+
})),
|
|
192
|
+
];
|
|
193
|
+
return { items, clear: () => store.update((current) => ({ ...current, page: 0, refinements: {}, filters: {} })) };
|
|
194
|
+
}
|
|
195
|
+
/** Adds the next page's hits under the current ones. */
|
|
196
|
+
export function useLoadMore() {
|
|
197
|
+
const [{ results, loadingMore }, store] = useSnapshot();
|
|
198
|
+
return {
|
|
199
|
+
hasMore: results?.hasMore ?? false,
|
|
200
|
+
loadMore: () => store.loadMore(),
|
|
201
|
+
isLoading: loadingMore,
|
|
202
|
+
shown: results?.hits.length ?? 0,
|
|
203
|
+
total: results?.response.total ?? 0,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Numbered pages, from 0. The API returns hits up to the 1,000th offset, so pages stop there. */
|
|
207
|
+
export function usePagination() {
|
|
208
|
+
const [{ state, results }, store] = useSnapshot();
|
|
209
|
+
const pageSize = store.settings.pageSize ?? 20;
|
|
210
|
+
const total = results?.response.total ?? 0;
|
|
211
|
+
const pages = Math.max(1, Math.min(Math.ceil(total / pageSize), Math.floor(maxOffset / pageSize) + 1));
|
|
212
|
+
const setPage = (page) => store.update((current) => ({ ...current, page: Math.max(0, Math.min(Math.floor(page), pages - 1)) }));
|
|
213
|
+
return { page: state.page, pages, setPage, hasPrevious: state.page > 0, hasNext: state.page + 1 < pages };
|
|
214
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SearchRequest, SearchResponse } from "../index.js";
|
|
2
|
+
import { type SearchConfig, type SearchState } from "./state.js";
|
|
3
|
+
import { type ServerResults } from "./store.js";
|
|
4
|
+
export { emptyState, stateFromUrl, urlFromState, type SearchConfig, type SearchState, type UrlParams } from "./state.js";
|
|
5
|
+
export type { ServerResults } from "./store.js";
|
|
6
|
+
/** Runs the search SearchProvider would run for a state; pass the result as its `initialResults`
|
|
7
|
+
* with the same config. Search with `@findlane/server` and a key with the search scope, since the
|
|
8
|
+
* public route only answers browsers on the index's allowed origins:
|
|
9
|
+
*
|
|
10
|
+
* getServerResults(config, stateFromUrl(searchParams, config), (request) => products.search(request))
|
|
11
|
+
*/
|
|
12
|
+
export declare function getServerResults<T = Record<string, unknown>>(config: SearchConfig, state: SearchState, search: (request: SearchRequest) => Promise<SearchResponse<unknown>>): Promise<ServerResults<T>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { planSearch } from "./state.js";
|
|
2
|
+
import { planKey } from "./store.js";
|
|
3
|
+
export { emptyState, stateFromUrl, urlFromState } from "./state.js";
|
|
4
|
+
/** Runs the search SearchProvider would run for a state; pass the result as its `initialResults`
|
|
5
|
+
* with the same config. Search with `@findlane/server` and a key with the search scope, since the
|
|
6
|
+
* public route only answers browsers on the index's allowed origins:
|
|
7
|
+
*
|
|
8
|
+
* getServerResults(config, stateFromUrl(searchParams, config), (request) => products.search(request))
|
|
9
|
+
*/
|
|
10
|
+
export async function getServerResults(config, state, search) {
|
|
11
|
+
const request = planSearch(config, state);
|
|
12
|
+
return { state, key: planKey(request), response: (await search(request)) };
|
|
13
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SearchRequest, SearchResponse } from "../index.js";
|
|
2
|
+
export type Hit<T> = SearchResponse<T>["hits"][number];
|
|
3
|
+
/** What a visitor has chosen on a results page. It round-trips through the URL. */
|
|
4
|
+
export type SearchState = {
|
|
5
|
+
q: string;
|
|
6
|
+
/** A sort ID from the index settings; left out for the index's default. */
|
|
7
|
+
sort?: string;
|
|
8
|
+
/** Selected values of each facet, by field. */
|
|
9
|
+
refinements: Record<string, string[]>;
|
|
10
|
+
/** Ranges (`{ gte, lte }`) and single values (such as `true` for in stock), by field. */
|
|
11
|
+
filters: Record<string, unknown>;
|
|
12
|
+
/** The page, from 0. */
|
|
13
|
+
page: number;
|
|
14
|
+
};
|
|
15
|
+
export type SearchConfig = {
|
|
16
|
+
/** Fields to count values for. A facet's own selections don't narrow its counts, so its other
|
|
17
|
+
* values stay listed and can be added. */
|
|
18
|
+
facets?: string[];
|
|
19
|
+
/** Fields that useRange and useToggle filter, so the URL can carry them. */
|
|
20
|
+
filterFields?: string[];
|
|
21
|
+
/** Filters always applied, such as `{ published: true }`. They aren't shown as refinements. */
|
|
22
|
+
filters?: Record<string, unknown>;
|
|
23
|
+
/** Hits per page: 1–50 (default 20). */
|
|
24
|
+
pageSize?: number;
|
|
25
|
+
/** The record fields to return (`attributesToRetrieve`); all of them when left out. */
|
|
26
|
+
fields?: string[];
|
|
27
|
+
/** "exact" (default) counts every match; "page" is faster on large indexes. */
|
|
28
|
+
countMode?: "page" | "exact";
|
|
29
|
+
facetMode?: "page" | "exact";
|
|
30
|
+
};
|
|
31
|
+
export declare const emptyState: SearchState;
|
|
32
|
+
/** The API's largest offset. */
|
|
33
|
+
export declare const maxOffset = 1000;
|
|
34
|
+
/** The request behind a state. A facet with selections is counted without them
|
|
35
|
+
* (`disjunctiveFacets`), so its other values stay listed and can be added. */
|
|
36
|
+
export declare function planSearch(config: SearchConfig, state: SearchState): SearchRequest;
|
|
37
|
+
/** Whether a page has hits after it that the API can return. */
|
|
38
|
+
export declare function hasMoreAfter(response: SearchResponse<unknown>, offset: number): boolean;
|
|
39
|
+
type Range = {
|
|
40
|
+
gte?: number | string;
|
|
41
|
+
lte?: number | string;
|
|
42
|
+
};
|
|
43
|
+
export declare function isRange(value: unknown): value is Range;
|
|
44
|
+
/** A URL's query string, or its parameters as an object (such as Next.js's `searchParams`). */
|
|
45
|
+
export type UrlParams = string | URLSearchParams | Record<string, string | string[] | undefined>;
|
|
46
|
+
/** The state a URL describes. Parameters that aren't the config's fields are ignored. */
|
|
47
|
+
export declare function stateFromUrl(search: UrlParams, config: SearchConfig): SearchState;
|
|
48
|
+
/** The query string for a state, followed by the URL's other parameters. */
|
|
49
|
+
export declare function urlFromState(state: SearchState, config: SearchConfig, current?: string | URLSearchParams): URLSearchParams;
|
|
50
|
+
/** A stable text form of a value, with object keys sorted. */
|
|
51
|
+
export declare function canonical(value: unknown): string;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export const emptyState = { q: "", refinements: {}, filters: {}, page: 0 };
|
|
2
|
+
/** The API's largest offset. */
|
|
3
|
+
export const maxOffset = 1000;
|
|
4
|
+
/** The request behind a state. A facet with selections is counted without them
|
|
5
|
+
* (`disjunctiveFacets`), so its other values stay listed and can be added. */
|
|
6
|
+
export function planSearch(config, state) {
|
|
7
|
+
const pageSize = config.pageSize ?? 20;
|
|
8
|
+
const facets = config.facets ?? [];
|
|
9
|
+
const filters = { ...state.filters };
|
|
10
|
+
for (const [field, values] of Object.entries(state.refinements))
|
|
11
|
+
if (values.length)
|
|
12
|
+
filters[field] = { in: values };
|
|
13
|
+
Object.assign(filters, config.filters);
|
|
14
|
+
// Only facets filtered by their selections alone: a fixed filter still narrows its own counts.
|
|
15
|
+
const disjunctive = facets.filter((field) => state.refinements[field]?.length && !(field in state.filters) && !(field in (config.filters ?? {})));
|
|
16
|
+
return {
|
|
17
|
+
q: state.q,
|
|
18
|
+
...(state.sort ? { sort: state.sort } : {}),
|
|
19
|
+
...(Object.keys(filters).length ? { filters } : {}),
|
|
20
|
+
...(facets.length ? { facets } : {}),
|
|
21
|
+
...(disjunctive.length ? { disjunctiveFacets: disjunctive } : {}),
|
|
22
|
+
limit: pageSize,
|
|
23
|
+
...(state.page > 0 ? { offset: Math.min(state.page * pageSize, maxOffset) } : {}),
|
|
24
|
+
...(config.fields ? { attributesToRetrieve: config.fields } : {}),
|
|
25
|
+
countMode: config.countMode ?? "exact",
|
|
26
|
+
...(facets.length ? { facetMode: config.facetMode ?? "exact" } : {}),
|
|
27
|
+
highlight: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Whether a page has hits after it that the API can return. */
|
|
31
|
+
export function hasMoreAfter(response, offset) {
|
|
32
|
+
const next = offset + response.hits.length;
|
|
33
|
+
return (response.hasMore ?? next < response.total) && next <= maxOffset;
|
|
34
|
+
}
|
|
35
|
+
const bound = /^(-?\d+(\.\d+)?|\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2})?)?)?$/;
|
|
36
|
+
export function isRange(value) {
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
38
|
+
return false;
|
|
39
|
+
const keys = Object.keys(value);
|
|
40
|
+
return keys.length > 0 && keys.every((key) => key === "gte" || key === "lte");
|
|
41
|
+
}
|
|
42
|
+
/** A filter value from the URL: `10..50` is a range, JSON is a value, anything else a string. */
|
|
43
|
+
function parseValue(text) {
|
|
44
|
+
const range = text.match(/^(.*)\.\.(.*)$/);
|
|
45
|
+
if (range && (range[1] || range[2]) && bound.test(range[1]) && bound.test(range[2])) {
|
|
46
|
+
const side = (part) => (/^-?\d+(\.\d+)?$/.test(part) ? Number(part) : part);
|
|
47
|
+
return { ...(range[1] ? { gte: side(range[1]) } : {}), ...(range[2] ? { lte: side(range[2]) } : {}) };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function formatValue(value) {
|
|
57
|
+
if (isRange(value))
|
|
58
|
+
return `${value.gte ?? ""}..${value.lte ?? ""}`;
|
|
59
|
+
// A string that would read back as something else is written as JSON.
|
|
60
|
+
if (typeof value === "string" && parseValue(value) === value)
|
|
61
|
+
return value;
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
function paramsFrom(search) {
|
|
65
|
+
if (typeof search === "string" || search instanceof URLSearchParams)
|
|
66
|
+
return new URLSearchParams(search);
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
for (const [key, value] of Object.entries(search))
|
|
69
|
+
for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value])
|
|
70
|
+
params.append(key, item);
|
|
71
|
+
return params;
|
|
72
|
+
}
|
|
73
|
+
/** The state a URL describes. Parameters that aren't the config's fields are ignored. */
|
|
74
|
+
export function stateFromUrl(search, config) {
|
|
75
|
+
const params = paramsFrom(search);
|
|
76
|
+
const refinements = {};
|
|
77
|
+
for (const field of config.facets ?? []) {
|
|
78
|
+
const values = params.getAll(field);
|
|
79
|
+
if (values.length)
|
|
80
|
+
refinements[field] = values;
|
|
81
|
+
}
|
|
82
|
+
const filters = {};
|
|
83
|
+
for (const field of config.filterFields ?? []) {
|
|
84
|
+
const value = params.get(field);
|
|
85
|
+
if (value !== null)
|
|
86
|
+
filters[field] = parseValue(value);
|
|
87
|
+
}
|
|
88
|
+
const page = Number(params.get("page"));
|
|
89
|
+
return {
|
|
90
|
+
q: params.get("q") ?? "",
|
|
91
|
+
...(params.get("sort") ? { sort: params.get("sort") } : {}),
|
|
92
|
+
refinements,
|
|
93
|
+
filters,
|
|
94
|
+
page: Number.isInteger(page) && page > 1 ? page - 1 : 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The query string for a state, followed by the URL's other parameters. */
|
|
98
|
+
export function urlFromState(state, config, current = "") {
|
|
99
|
+
const params = new URLSearchParams();
|
|
100
|
+
if (state.q)
|
|
101
|
+
params.set("q", state.q);
|
|
102
|
+
if (state.sort)
|
|
103
|
+
params.set("sort", state.sort);
|
|
104
|
+
for (const [field, values] of Object.entries(state.refinements))
|
|
105
|
+
if (config.facets?.includes(field))
|
|
106
|
+
for (const value of values)
|
|
107
|
+
params.append(field, value);
|
|
108
|
+
for (const [field, value] of Object.entries(state.filters))
|
|
109
|
+
if (config.filterFields?.includes(field) && value !== undefined)
|
|
110
|
+
params.set(field, formatValue(value));
|
|
111
|
+
if (state.page > 0)
|
|
112
|
+
params.set("page", String(state.page + 1));
|
|
113
|
+
const own = new Set(["q", "sort", "page", ...(config.facets ?? []), ...(config.filterFields ?? [])]);
|
|
114
|
+
for (const [key, value] of new URLSearchParams(current))
|
|
115
|
+
if (!own.has(key))
|
|
116
|
+
params.append(key, value);
|
|
117
|
+
return params;
|
|
118
|
+
}
|
|
119
|
+
/** A stable text form of a value, with object keys sorted. */
|
|
120
|
+
export function canonical(value) {
|
|
121
|
+
if (Array.isArray(value))
|
|
122
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
123
|
+
if (value && typeof value === "object")
|
|
124
|
+
return `{${Object.keys(value).sort().filter((key) => value[key] !== undefined)
|
|
125
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
126
|
+
return JSON.stringify(value) ?? "null";
|
|
127
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { DidYouMean, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
|
|
2
|
+
import { type Hit, type SearchConfig, type SearchState } from "./state.js";
|
|
3
|
+
/** Results computed on the server (see ssr.ts), so the first render needn't search again. */
|
|
4
|
+
export type ServerResults<T = unknown> = {
|
|
5
|
+
state: SearchState;
|
|
6
|
+
key: string;
|
|
7
|
+
response: SearchResponse<T>;
|
|
8
|
+
};
|
|
9
|
+
export type Results<T> = {
|
|
10
|
+
/** The request these results answer, as a key. */
|
|
11
|
+
key: string;
|
|
12
|
+
request: SearchRequest;
|
|
13
|
+
/** The first page's response. */
|
|
14
|
+
response: SearchResponse<T>;
|
|
15
|
+
/** The page's hits and any loaded after it. */
|
|
16
|
+
hits: Hit<T>[];
|
|
17
|
+
hasMore: boolean;
|
|
18
|
+
/** How long the searches took, in milliseconds, from request to response; left out when the
|
|
19
|
+
* results came from memory or the server. */
|
|
20
|
+
ms?: number;
|
|
21
|
+
};
|
|
22
|
+
export type Snapshot<T> = {
|
|
23
|
+
state: SearchState;
|
|
24
|
+
results: Results<T> | null;
|
|
25
|
+
/** A search for the current state is waiting or in flight; `results` are the previous ones. */
|
|
26
|
+
pending: boolean;
|
|
27
|
+
error: unknown;
|
|
28
|
+
loadingMore: boolean;
|
|
29
|
+
};
|
|
30
|
+
/** How a change should reach the browser history: typing replaces the entry, other changes add one,
|
|
31
|
+
* so Back undoes a filter rather than a keystroke. */
|
|
32
|
+
export type StateChange = {
|
|
33
|
+
push: boolean;
|
|
34
|
+
};
|
|
35
|
+
export declare const planKey: (request: SearchRequest) => string;
|
|
36
|
+
export declare class SearchStore<T = unknown> {
|
|
37
|
+
private client;
|
|
38
|
+
private config;
|
|
39
|
+
private snapshot;
|
|
40
|
+
private listeners;
|
|
41
|
+
private controller;
|
|
42
|
+
private timer;
|
|
43
|
+
private run;
|
|
44
|
+
/** Called after each change a visitor makes, not when the state is adopted from outside. */
|
|
45
|
+
onChange: ((state: SearchState, change: StateChange) => void) | null;
|
|
46
|
+
constructor(client: SearchBrowserClient, config: SearchConfig, state: SearchState, initial?: ServerResults<T>);
|
|
47
|
+
subscribe: (listener: () => void) => () => void;
|
|
48
|
+
getSnapshot: () => Snapshot<T>;
|
|
49
|
+
private set;
|
|
50
|
+
/** A visitor's change. Changes to the query wait for a pause in typing; the others search at once. */
|
|
51
|
+
update(change: (state: SearchState) => SearchState): void;
|
|
52
|
+
/** A state from outside, such as the URL after Back. */
|
|
53
|
+
adopt(state: SearchState): void;
|
|
54
|
+
/** A new client or config, from changed props. */
|
|
55
|
+
configure(client: SearchBrowserClient, config: SearchConfig): void;
|
|
56
|
+
get settings(): SearchConfig;
|
|
57
|
+
/** Searches unless the results already answer the current state; with a state, such as the
|
|
58
|
+
* URL's, adopts it first. */
|
|
59
|
+
start(state?: SearchState): void;
|
|
60
|
+
stop(): void;
|
|
61
|
+
private resultsFor;
|
|
62
|
+
private search;
|
|
63
|
+
/** A did-you-mean suggestion for a request that found nothing. */
|
|
64
|
+
suggest(request: SearchRequest, signal: AbortSignal): Promise<DidYouMean | null>;
|
|
65
|
+
/** Adds the next page's hits to the current ones. */
|
|
66
|
+
loadMore(): void;
|
|
67
|
+
}
|