@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,136 @@
|
|
|
1
|
+
import { cachedSearch, peek } from "./cache.js";
|
|
2
|
+
import { canonical, hasMoreAfter, planSearch } from "./state.js";
|
|
3
|
+
const typingPause = 150;
|
|
4
|
+
export const planKey = (request) => canonical(request);
|
|
5
|
+
export class SearchStore {
|
|
6
|
+
client;
|
|
7
|
+
config;
|
|
8
|
+
snapshot;
|
|
9
|
+
listeners = new Set();
|
|
10
|
+
controller = null;
|
|
11
|
+
timer;
|
|
12
|
+
run = 0;
|
|
13
|
+
/** Called after each change a visitor makes, not when the state is adopted from outside. */
|
|
14
|
+
onChange = null;
|
|
15
|
+
constructor(client, config, state, initial) {
|
|
16
|
+
this.client = client;
|
|
17
|
+
this.config = config;
|
|
18
|
+
const request = planSearch(config, state);
|
|
19
|
+
this.snapshot = { state, results: null, pending: true, error: null, loadingMore: false };
|
|
20
|
+
if (initial && initial.key === planKey(request))
|
|
21
|
+
this.snapshot = { ...this.snapshot, results: this.resultsFor(request, initial.response), pending: false };
|
|
22
|
+
}
|
|
23
|
+
subscribe = (listener) => {
|
|
24
|
+
this.listeners.add(listener);
|
|
25
|
+
return () => { this.listeners.delete(listener); };
|
|
26
|
+
};
|
|
27
|
+
getSnapshot = () => this.snapshot;
|
|
28
|
+
set(patch) {
|
|
29
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
30
|
+
for (const listener of this.listeners)
|
|
31
|
+
listener();
|
|
32
|
+
}
|
|
33
|
+
/** A visitor's change. Changes to the query wait for a pause in typing; the others search at once. */
|
|
34
|
+
update(change) {
|
|
35
|
+
const previous = this.snapshot.state;
|
|
36
|
+
const state = change(previous);
|
|
37
|
+
if (canonical(state) === canonical(previous))
|
|
38
|
+
return;
|
|
39
|
+
const typing = state.q !== previous.q && canonical({ ...state, q: "", page: 0 }) === canonical({ ...previous, q: "", page: 0 });
|
|
40
|
+
this.set({ state });
|
|
41
|
+
this.onChange?.(state, { push: !typing });
|
|
42
|
+
this.search(typing);
|
|
43
|
+
}
|
|
44
|
+
/** A state from outside, such as the URL after Back. */
|
|
45
|
+
adopt(state) {
|
|
46
|
+
if (canonical(state) === canonical(this.snapshot.state))
|
|
47
|
+
return;
|
|
48
|
+
this.set({ state });
|
|
49
|
+
this.search(false);
|
|
50
|
+
}
|
|
51
|
+
/** A new client or config, from changed props. */
|
|
52
|
+
configure(client, config) {
|
|
53
|
+
if (client === this.client && canonical(config) === canonical(this.config))
|
|
54
|
+
return;
|
|
55
|
+
this.client = client;
|
|
56
|
+
this.config = config;
|
|
57
|
+
this.search(false);
|
|
58
|
+
}
|
|
59
|
+
get settings() { return this.config; }
|
|
60
|
+
/** Searches unless the results already answer the current state; with a state, such as the
|
|
61
|
+
* URL's, adopts it first. */
|
|
62
|
+
start(state) {
|
|
63
|
+
if (state && canonical(state) !== canonical(this.snapshot.state))
|
|
64
|
+
this.set({ state });
|
|
65
|
+
const key = planKey(planSearch(this.config, this.snapshot.state));
|
|
66
|
+
if (this.snapshot.results?.key !== key || this.snapshot.error)
|
|
67
|
+
this.search(false);
|
|
68
|
+
}
|
|
69
|
+
stop() {
|
|
70
|
+
clearTimeout(this.timer);
|
|
71
|
+
this.controller?.abort();
|
|
72
|
+
this.controller = null;
|
|
73
|
+
this.run++;
|
|
74
|
+
}
|
|
75
|
+
resultsFor(request, response) {
|
|
76
|
+
return { key: planKey(request), request, response, hits: response.hits, hasMore: hasMoreAfter(response, request.offset ?? 0) };
|
|
77
|
+
}
|
|
78
|
+
search(debounce) {
|
|
79
|
+
this.stop();
|
|
80
|
+
const run = this.run;
|
|
81
|
+
const client = this.client;
|
|
82
|
+
const request = planSearch(this.config, this.snapshot.state);
|
|
83
|
+
// Results already in memory show at once, without a loading state.
|
|
84
|
+
const cached = peek(client, request);
|
|
85
|
+
if (cached) {
|
|
86
|
+
this.set({ results: this.resultsFor(request, cached), pending: false, error: null, loadingMore: false });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.set({ pending: true, loadingMore: false });
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
this.controller = controller;
|
|
92
|
+
const go = () => {
|
|
93
|
+
const started = performance.now();
|
|
94
|
+
cachedSearch(client, request, controller.signal).then((response) => {
|
|
95
|
+
if (run !== this.run)
|
|
96
|
+
return;
|
|
97
|
+
const ms = Math.round(performance.now() - started);
|
|
98
|
+
this.set({ results: { ...this.resultsFor(request, response), ms }, pending: false, error: null });
|
|
99
|
+
}, (error) => {
|
|
100
|
+
if (run !== this.run)
|
|
101
|
+
return;
|
|
102
|
+
this.set({ pending: false, error });
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
if (debounce)
|
|
106
|
+
this.timer = setTimeout(go, typingPause);
|
|
107
|
+
else
|
|
108
|
+
go();
|
|
109
|
+
}
|
|
110
|
+
/** A did-you-mean suggestion for a request that found nothing. */
|
|
111
|
+
suggest(request, signal) {
|
|
112
|
+
return this.client.suggest(request, { signal });
|
|
113
|
+
}
|
|
114
|
+
/** Adds the next page's hits to the current ones. */
|
|
115
|
+
loadMore() {
|
|
116
|
+
const { results, loadingMore, pending } = this.snapshot;
|
|
117
|
+
if (!results?.hasMore || loadingMore || pending)
|
|
118
|
+
return;
|
|
119
|
+
const { facets: _, disjunctiveFacets: __, facetMode: ___, ...request } = results.request;
|
|
120
|
+
const offset = (request.offset ?? 0) + results.hits.length;
|
|
121
|
+
const run = this.run;
|
|
122
|
+
this.set({ loadingMore: true });
|
|
123
|
+
cachedSearch(this.client, { ...request, offset }).then((page) => {
|
|
124
|
+
const current = this.snapshot.results;
|
|
125
|
+
if (run !== this.run || current?.key !== results.key)
|
|
126
|
+
return;
|
|
127
|
+
this.set({
|
|
128
|
+
results: { ...current, hits: [...current.hits, ...page.hits], hasMore: hasMoreAfter(page, offset) },
|
|
129
|
+
loadingMore: false,
|
|
130
|
+
});
|
|
131
|
+
}, (error) => {
|
|
132
|
+
if (run === this.run)
|
|
133
|
+
this.set({ loadingMore: false, error });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/* @findlane/browser/react/styles.css: a default look for the components. Every rule is inside
|
|
2
|
+
:where(), so your own CSS wins without !important. Theme it with these variables, set anywhere
|
|
3
|
+
above the components:
|
|
4
|
+
--findlane-accent, --findlane-background, --findlane-border, --findlane-muted, --findlane-radius.
|
|
5
|
+
Without them, colors follow the page's color-scheme. */
|
|
6
|
+
|
|
7
|
+
:where([data-findlane-searchbox]) {
|
|
8
|
+
position: relative;
|
|
9
|
+
}
|
|
10
|
+
:where([data-findlane-searchbox], [data-findlane-search-input]) :where(form) {
|
|
11
|
+
margin: 0;
|
|
12
|
+
}
|
|
13
|
+
:where([data-findlane-searchbox], [data-findlane-search-input]) :where(input) {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
width: 100%;
|
|
16
|
+
font: inherit;
|
|
17
|
+
padding: 0.6em 0.9em;
|
|
18
|
+
color: inherit;
|
|
19
|
+
background: var(--findlane-background, Canvas);
|
|
20
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
21
|
+
border-radius: var(--findlane-radius, 10px);
|
|
22
|
+
}
|
|
23
|
+
:where([data-findlane-searchbox], [data-findlane-search-input]) :where(input:focus-visible) {
|
|
24
|
+
outline: 2px solid var(--findlane-accent, #2f5bff);
|
|
25
|
+
outline-offset: 1px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
:where([data-findlane-panel]) {
|
|
29
|
+
position: absolute;
|
|
30
|
+
top: calc(100% + 6px);
|
|
31
|
+
left: 0;
|
|
32
|
+
right: 0;
|
|
33
|
+
z-index: 50;
|
|
34
|
+
max-height: min(70vh, 480px);
|
|
35
|
+
overflow: auto;
|
|
36
|
+
padding: 4px 0;
|
|
37
|
+
color: CanvasText;
|
|
38
|
+
background: var(--findlane-background, Canvas);
|
|
39
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 14%, transparent));
|
|
40
|
+
border-radius: var(--findlane-radius, 10px);
|
|
41
|
+
box-shadow: 0 12px 32px rgb(0 0 0 / 0.14);
|
|
42
|
+
}
|
|
43
|
+
:where([data-findlane-panel] [role="option"]) {
|
|
44
|
+
display: flex;
|
|
45
|
+
gap: 12px;
|
|
46
|
+
align-items: center;
|
|
47
|
+
padding: 8px 12px;
|
|
48
|
+
color: inherit;
|
|
49
|
+
text-decoration: none;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
:where([data-findlane-panel] [role="option"][aria-selected="true"]) {
|
|
53
|
+
background: color-mix(in srgb, var(--findlane-accent, #2f5bff) 10%, transparent);
|
|
54
|
+
}
|
|
55
|
+
:where([data-findlane-image]) {
|
|
56
|
+
width: 40px;
|
|
57
|
+
height: 40px;
|
|
58
|
+
flex: none;
|
|
59
|
+
object-fit: contain;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
}
|
|
62
|
+
:where([data-findlane-panel] [data-findlane-title]) {
|
|
63
|
+
min-width: 0;
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
text-overflow: ellipsis;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
}
|
|
68
|
+
:where([data-findlane-panel], [data-findlane-hits]) :where(mark) {
|
|
69
|
+
color: var(--findlane-accent, #2f5bff);
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
background: none;
|
|
72
|
+
}
|
|
73
|
+
:where([data-findlane-note], [data-findlane-empty]) {
|
|
74
|
+
margin: 0;
|
|
75
|
+
padding: 12px;
|
|
76
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
77
|
+
text-align: center;
|
|
78
|
+
}
|
|
79
|
+
:where([data-findlane-all]) {
|
|
80
|
+
display: block;
|
|
81
|
+
padding: 10px 12px;
|
|
82
|
+
color: var(--findlane-accent, #2f5bff);
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
text-align: center;
|
|
85
|
+
text-decoration: none;
|
|
86
|
+
border-top: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 10%, transparent));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
:where([data-findlane-dialog-trigger]) {
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
gap: 12px;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: space-between;
|
|
94
|
+
min-width: 180px;
|
|
95
|
+
padding: 0.5em 0.6em 0.5em 0.9em;
|
|
96
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
97
|
+
font: inherit;
|
|
98
|
+
background: var(--findlane-background, Canvas);
|
|
99
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
100
|
+
border-radius: var(--findlane-radius, 10px);
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
}
|
|
103
|
+
:where([data-findlane-dialog-trigger]) :where(kbd) {
|
|
104
|
+
padding: 0.1em 0.4em;
|
|
105
|
+
font: inherit;
|
|
106
|
+
font-size: 0.8em;
|
|
107
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
108
|
+
border-radius: 5px;
|
|
109
|
+
}
|
|
110
|
+
:where([data-findlane-dialog]) {
|
|
111
|
+
width: min(640px, calc(100vw - 32px));
|
|
112
|
+
max-height: min(70vh, 640px);
|
|
113
|
+
margin: 12vh auto auto;
|
|
114
|
+
padding: 0;
|
|
115
|
+
color: CanvasText;
|
|
116
|
+
background: var(--findlane-background, Canvas);
|
|
117
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 14%, transparent));
|
|
118
|
+
border-radius: calc(var(--findlane-radius, 10px) * 1.4);
|
|
119
|
+
box-shadow: 0 24px 64px rgb(0 0 0 / 0.25);
|
|
120
|
+
}
|
|
121
|
+
:where([data-findlane-dialog])::backdrop {
|
|
122
|
+
background: rgb(0 0 0 / 0.35);
|
|
123
|
+
}
|
|
124
|
+
/* The padding is inside, so a click on the dialog itself is a click on the backdrop. */
|
|
125
|
+
:where([data-findlane-dialog] > [data-findlane-searchbox]) {
|
|
126
|
+
padding: 12px;
|
|
127
|
+
}
|
|
128
|
+
/* In the dialog, suggestions sit under the input instead of floating over the page. */
|
|
129
|
+
:where([data-findlane-dialog] [data-findlane-panel]) {
|
|
130
|
+
position: static;
|
|
131
|
+
margin-top: 8px;
|
|
132
|
+
max-height: none;
|
|
133
|
+
border: 0;
|
|
134
|
+
box-shadow: none;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
:where([data-findlane-hits]) {
|
|
138
|
+
display: grid;
|
|
139
|
+
gap: 12px;
|
|
140
|
+
margin: 0;
|
|
141
|
+
padding: 0;
|
|
142
|
+
list-style: none;
|
|
143
|
+
}
|
|
144
|
+
:where([data-findlane-hits][data-pending]) {
|
|
145
|
+
opacity: 0.6;
|
|
146
|
+
transition: opacity 0.15s 0.1s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
:where([data-findlane-refinement-list]) {
|
|
150
|
+
min-width: 0;
|
|
151
|
+
margin: 0 0 18px;
|
|
152
|
+
padding: 0;
|
|
153
|
+
border: 0;
|
|
154
|
+
}
|
|
155
|
+
:where([data-findlane-refinement-list]) :where(legend) {
|
|
156
|
+
margin-bottom: 6px;
|
|
157
|
+
padding: 0;
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
}
|
|
160
|
+
:where([data-findlane-refinement-list]) :where(label) {
|
|
161
|
+
display: flex;
|
|
162
|
+
gap: 8px;
|
|
163
|
+
align-items: center;
|
|
164
|
+
padding: 3px 0;
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
}
|
|
167
|
+
:where([data-findlane-refinement-list]) :where(label[data-empty]) {
|
|
168
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
169
|
+
}
|
|
170
|
+
:where([data-findlane-refinement-list]) :where(input) {
|
|
171
|
+
margin: 0;
|
|
172
|
+
accent-color: var(--findlane-accent, #2f5bff);
|
|
173
|
+
}
|
|
174
|
+
:where([data-findlane-value]) {
|
|
175
|
+
flex: 1;
|
|
176
|
+
min-width: 0;
|
|
177
|
+
overflow-wrap: anywhere;
|
|
178
|
+
}
|
|
179
|
+
:where([data-findlane-count]) {
|
|
180
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
181
|
+
font-size: 0.85em;
|
|
182
|
+
font-variant-numeric: tabular-nums;
|
|
183
|
+
}
|
|
184
|
+
:where([data-findlane-refinement-list]) :where(button) {
|
|
185
|
+
padding: 4px 0;
|
|
186
|
+
color: var(--findlane-accent, #2f5bff);
|
|
187
|
+
font: inherit;
|
|
188
|
+
background: none;
|
|
189
|
+
border: 0;
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
:where([data-findlane-sort]) {
|
|
194
|
+
padding: 0.4em 0.6em;
|
|
195
|
+
color: inherit;
|
|
196
|
+
font: inherit;
|
|
197
|
+
background: var(--findlane-background, Canvas);
|
|
198
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
199
|
+
border-radius: calc(var(--findlane-radius, 10px) * 0.8);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
:where([data-findlane-current-refinements]) {
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-wrap: wrap;
|
|
205
|
+
gap: 6px;
|
|
206
|
+
}
|
|
207
|
+
:where([data-findlane-current-refinements]) :where(button) {
|
|
208
|
+
padding: 0.3em 0.75em;
|
|
209
|
+
color: inherit;
|
|
210
|
+
font: inherit;
|
|
211
|
+
font-size: 0.9em;
|
|
212
|
+
background: color-mix(in srgb, var(--findlane-accent, #2f5bff) 10%, transparent);
|
|
213
|
+
border: 0;
|
|
214
|
+
border-radius: 999px;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
:where([data-findlane-current-refinements]) :where([data-findlane-clear]) {
|
|
218
|
+
color: var(--findlane-accent, #2f5bff);
|
|
219
|
+
background: none;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
:where([data-findlane-load-more]) {
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: column;
|
|
225
|
+
gap: 6px;
|
|
226
|
+
align-items: center;
|
|
227
|
+
margin-top: 24px;
|
|
228
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
229
|
+
font-size: 0.9em;
|
|
230
|
+
}
|
|
231
|
+
:where([data-findlane-load-more]) :where(button) {
|
|
232
|
+
padding: 0.6em 1.4em;
|
|
233
|
+
color: CanvasText;
|
|
234
|
+
font: inherit;
|
|
235
|
+
font-weight: 600;
|
|
236
|
+
background: var(--findlane-background, Canvas);
|
|
237
|
+
border: 1px solid var(--findlane-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
238
|
+
border-radius: var(--findlane-radius, 10px);
|
|
239
|
+
cursor: pointer;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
:where([data-findlane-did-you-mean]) {
|
|
243
|
+
margin: 0;
|
|
244
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
245
|
+
}
|
|
246
|
+
:where([data-findlane-did-you-mean]) :where(button) {
|
|
247
|
+
padding: 0;
|
|
248
|
+
font: inherit;
|
|
249
|
+
font-weight: 600;
|
|
250
|
+
color: var(--findlane-accent, #2f5bff);
|
|
251
|
+
background: none;
|
|
252
|
+
border: 0;
|
|
253
|
+
text-decoration: underline;
|
|
254
|
+
cursor: pointer;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
:where([data-findlane-stats]) {
|
|
258
|
+
margin: 0;
|
|
259
|
+
color: var(--findlane-muted, color-mix(in srgb, currentColor 60%, transparent));
|
|
260
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type HTMLAttributes, type InputHTMLAttributes } from "react";
|
|
2
|
+
import type { Hit } from "./state.js";
|
|
3
|
+
import type { ClientOptions } from "./client.js";
|
|
4
|
+
export type AutocompleteOptions<T> = ClientOptions & {
|
|
5
|
+
/** Suggestions to show: 1–50 (default 6). */
|
|
6
|
+
limit?: number;
|
|
7
|
+
/** The record fields to return (`attributesToRetrieve`). */
|
|
8
|
+
fields?: string[];
|
|
9
|
+
filters?: Record<string, unknown>;
|
|
10
|
+
sort?: string;
|
|
11
|
+
/** Characters to type before searching (default 1). */
|
|
12
|
+
minLength?: number;
|
|
13
|
+
debounceMs?: number;
|
|
14
|
+
defaultQuery?: string;
|
|
15
|
+
/** A base for the elements' IDs; one is generated otherwise. */
|
|
16
|
+
id?: string;
|
|
17
|
+
/** A hit's page. Enter on a suggestion goes there unless `onSelect` is given. */
|
|
18
|
+
getHref?: (hit: Hit<T>) => string | undefined;
|
|
19
|
+
/** A hit's page as a template, for server components, which can't pass `getHref`:
|
|
20
|
+
* "/products/{handle}" puts in the record's `handle`, URL-encoded, and `{objectID}` the hit's ID.
|
|
21
|
+
* A template that is one field, such as "{url}", takes that field's URL as it is. */
|
|
22
|
+
hrefTemplate?: string;
|
|
23
|
+
/** Called when a suggestion is chosen, instead of following its link; for client-side routers. */
|
|
24
|
+
onSelect?: (hit: Hit<T>) => void;
|
|
25
|
+
/** Called on Enter without a chosen suggestion. Without it, Enter submits the input's form. */
|
|
26
|
+
onSubmit?: (query: string) => void;
|
|
27
|
+
};
|
|
28
|
+
/** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
|
|
29
|
+
export declare function fillTemplate<T>(template: string, hit: Hit<T>): string | undefined;
|
|
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. */
|
|
32
|
+
export declare function useAutocomplete<T = Record<string, unknown>>(options?: AutocompleteOptions<T>): {
|
|
33
|
+
query: string;
|
|
34
|
+
setQuery: (value: string) => void;
|
|
35
|
+
hits: {
|
|
36
|
+
id: string;
|
|
37
|
+
score: number | null;
|
|
38
|
+
record: T;
|
|
39
|
+
highlights?: Record<string, string | string[]>;
|
|
40
|
+
}[];
|
|
41
|
+
total: number;
|
|
42
|
+
response: import("../index.js").SearchResponse<T> | null;
|
|
43
|
+
error: unknown;
|
|
44
|
+
isPending: boolean;
|
|
45
|
+
isOpen: boolean;
|
|
46
|
+
activeIndex: number;
|
|
47
|
+
setActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
48
|
+
close: () => void;
|
|
49
|
+
select: (hit: Hit<T>) => void;
|
|
50
|
+
/** A hit's page, from `getHref` or `hrefTemplate`. */
|
|
51
|
+
hrefFor: (hit: Hit<T>) => string | undefined;
|
|
52
|
+
getInputProps: (props?: InputHTMLAttributes<HTMLInputElement>) => InputHTMLAttributes<HTMLInputElement>;
|
|
53
|
+
getListboxProps: (props?: HTMLAttributes<HTMLElement>) => HTMLAttributes<HTMLElement>;
|
|
54
|
+
getOptionProps: (index: number, props?: HTMLAttributes<HTMLElement>) => HTMLAttributes<HTMLElement>;
|
|
55
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useId, useState, } from "react";
|
|
3
|
+
import { valuesAt } from "./highlight.js";
|
|
4
|
+
import { useSearch } from "./use-search.js";
|
|
5
|
+
/** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
|
|
6
|
+
export function fillTemplate(template, hit) {
|
|
7
|
+
const value = (field) => (field === "objectID" ? hit.id : valuesAt(hit.record, field)[0] ?? "");
|
|
8
|
+
const whole = template.match(/^\{([^}]+)\}$/);
|
|
9
|
+
const href = whole ? value(whole[1]) : template.replace(/\{([^}]+)\}/g, (_, field) => encodeURIComponent(value(field)));
|
|
10
|
+
return /^[a-z][a-z\d+.-]*:/i.test(href.trim()) && !/^https?:/i.test(href.trim()) ? undefined : href || undefined;
|
|
11
|
+
}
|
|
12
|
+
/** 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
|
+
export function useAutocomplete(options = {}) {
|
|
15
|
+
const { limit = 6, fields, filters, sort, minLength = 1, debounceMs, hrefTemplate, onSelect, onSubmit } = options;
|
|
16
|
+
const getHref = options.getHref ?? (hrefTemplate ? (hit) => fillTemplate(hrefTemplate, hit) : undefined);
|
|
17
|
+
const generated = useId();
|
|
18
|
+
const id = options.id ?? `findlane${generated.replace(/:/g, "")}`;
|
|
19
|
+
const [query, setQueryState] = useState(options.defaultQuery ?? "");
|
|
20
|
+
const [focused, setFocused] = useState(false);
|
|
21
|
+
const [dismissed, setDismissed] = useState(false);
|
|
22
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
23
|
+
const request = query.trim().length >= minLength
|
|
24
|
+
? {
|
|
25
|
+
q: query,
|
|
26
|
+
limit,
|
|
27
|
+
highlight: true,
|
|
28
|
+
...(fields ? { attributesToRetrieve: fields } : {}),
|
|
29
|
+
...(filters ? { filters } : {}),
|
|
30
|
+
...(sort ? { sort } : {}),
|
|
31
|
+
}
|
|
32
|
+
: null;
|
|
33
|
+
const search = useSearch(request, {
|
|
34
|
+
publicId: options.publicId, baseUrl: options.baseUrl, client: options.client, debounceMs,
|
|
35
|
+
});
|
|
36
|
+
const hits = search.data?.hits ?? [];
|
|
37
|
+
// Open once there's something to show: suggestions, "no results", or an error.
|
|
38
|
+
const isOpen = focused && !dismissed && request !== null && (search.data !== null || search.error !== null);
|
|
39
|
+
// New suggestions start unselected.
|
|
40
|
+
useEffect(() => setActiveIndex(-1), [search.data]);
|
|
41
|
+
const listboxId = `${id}-listbox`;
|
|
42
|
+
const optionId = (index) => `${id}-option-${index}`;
|
|
43
|
+
const setQuery = (value) => {
|
|
44
|
+
setQueryState(value);
|
|
45
|
+
setDismissed(false);
|
|
46
|
+
setActiveIndex(-1);
|
|
47
|
+
};
|
|
48
|
+
const close = () => {
|
|
49
|
+
setDismissed(true);
|
|
50
|
+
setActiveIndex(-1);
|
|
51
|
+
};
|
|
52
|
+
const select = (hit) => {
|
|
53
|
+
close();
|
|
54
|
+
if (onSelect)
|
|
55
|
+
return onSelect(hit);
|
|
56
|
+
const href = getHref?.(hit);
|
|
57
|
+
if (href)
|
|
58
|
+
window.location.assign(href);
|
|
59
|
+
};
|
|
60
|
+
function onKeyDown(event) {
|
|
61
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
if (!isOpen)
|
|
64
|
+
return setDismissed(false);
|
|
65
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
66
|
+
// -1 is the input itself, between the last suggestion and the first.
|
|
67
|
+
setActiveIndex((current) => ((current + 1 + step + hits.length + 1) % (hits.length + 1)) - 1);
|
|
68
|
+
}
|
|
69
|
+
else if (event.key === "Escape") {
|
|
70
|
+
if (isOpen) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
close();
|
|
73
|
+
}
|
|
74
|
+
else if (query) {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
setQuery("");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
|
80
|
+
const hit = isOpen && activeIndex >= 0 ? hits[activeIndex] : undefined;
|
|
81
|
+
if (hit) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
select(hit);
|
|
84
|
+
}
|
|
85
|
+
else if (onSubmit) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
close();
|
|
88
|
+
onSubmit(query);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
query,
|
|
94
|
+
setQuery,
|
|
95
|
+
hits,
|
|
96
|
+
total: search.data?.total ?? 0,
|
|
97
|
+
response: search.data,
|
|
98
|
+
error: search.error,
|
|
99
|
+
isPending: search.isPending,
|
|
100
|
+
isOpen,
|
|
101
|
+
activeIndex,
|
|
102
|
+
setActiveIndex,
|
|
103
|
+
close,
|
|
104
|
+
select,
|
|
105
|
+
/** A hit's page, from `getHref` or `hrefTemplate`. */
|
|
106
|
+
hrefFor: (hit) => getHref?.(hit),
|
|
107
|
+
getInputProps: (props = {}) => ({
|
|
108
|
+
autoComplete: "off",
|
|
109
|
+
autoCorrect: "off",
|
|
110
|
+
spellCheck: false,
|
|
111
|
+
maxLength: 200,
|
|
112
|
+
...props,
|
|
113
|
+
role: "combobox",
|
|
114
|
+
"aria-autocomplete": "list",
|
|
115
|
+
"aria-expanded": isOpen,
|
|
116
|
+
"aria-controls": listboxId,
|
|
117
|
+
"aria-activedescendant": isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined,
|
|
118
|
+
value: query,
|
|
119
|
+
onChange: (event) => {
|
|
120
|
+
props.onChange?.(event);
|
|
121
|
+
setQuery(event.target.value);
|
|
122
|
+
},
|
|
123
|
+
onFocus: (event) => {
|
|
124
|
+
props.onFocus?.(event);
|
|
125
|
+
setFocused(true);
|
|
126
|
+
setDismissed(false);
|
|
127
|
+
},
|
|
128
|
+
onBlur: (event) => {
|
|
129
|
+
props.onBlur?.(event);
|
|
130
|
+
setFocused(false);
|
|
131
|
+
},
|
|
132
|
+
onKeyDown: (event) => {
|
|
133
|
+
props.onKeyDown?.(event);
|
|
134
|
+
if (!event.defaultPrevented)
|
|
135
|
+
onKeyDown(event);
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
getListboxProps: (props = {}) => ({
|
|
139
|
+
...props,
|
|
140
|
+
id: listboxId,
|
|
141
|
+
role: "listbox",
|
|
142
|
+
// Keeps focus in the input, so choosing a suggestion doesn't close the list first.
|
|
143
|
+
onMouseDown: (event) => {
|
|
144
|
+
props.onMouseDown?.(event);
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
getOptionProps: (index, props = {}) => ({
|
|
149
|
+
...props,
|
|
150
|
+
id: optionId(index),
|
|
151
|
+
role: "option",
|
|
152
|
+
"aria-selected": index === activeIndex,
|
|
153
|
+
onMouseMove: (event) => {
|
|
154
|
+
props.onMouseMove?.(event);
|
|
155
|
+
if (index !== activeIndex)
|
|
156
|
+
setActiveIndex(index);
|
|
157
|
+
},
|
|
158
|
+
onClick: (event) => {
|
|
159
|
+
props.onClick?.(event);
|
|
160
|
+
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)
|
|
163
|
+
return;
|
|
164
|
+
// A link without onSelect is followed as usual.
|
|
165
|
+
if (onSelect || !event.currentTarget.href) {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
select(hit);
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
close();
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SearchRequest, SearchResponse } from "../index.js";
|
|
2
|
+
import { type ClientOptions } from "./client.js";
|
|
3
|
+
export type UseSearchOptions<T> = ClientOptions & {
|
|
4
|
+
/** How long to wait for a pause in typing, in milliseconds (default 150). */
|
|
5
|
+
debounceMs?: number;
|
|
6
|
+
/** Results for the first request, such as ones from the server. The hook doesn't search again
|
|
7
|
+
* until the request changes. */
|
|
8
|
+
initialData?: SearchResponse<T>;
|
|
9
|
+
};
|
|
10
|
+
export type UseSearchResult<T> = {
|
|
11
|
+
/** The latest results. While a newer search runs they stay, so the page doesn't flash. */
|
|
12
|
+
data: SearchResponse<T> | null;
|
|
13
|
+
error: unknown;
|
|
14
|
+
/** A search is waiting for a pause in typing or is in flight. */
|
|
15
|
+
isPending: boolean;
|
|
16
|
+
};
|
|
17
|
+
/** Keeps one search current. A new request cancels the one in flight, and a slow response never
|
|
18
|
+
* replaces a newer one. Pass `null` to not search (and clear the results). */
|
|
19
|
+
export declare function useSearch<T = Record<string, unknown>>(request: SearchRequest | null, { debounceMs, initialData, ...clientOptions }?: UseSearchOptions<T>): UseSearchResult<T>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { cachedSearch, peek, searchKey } from "./cache.js";
|
|
4
|
+
import { useClient } from "./client.js";
|
|
5
|
+
/** Keeps one search current. A new request cancels the one in flight, and a slow response never
|
|
6
|
+
* replaces a newer one. Pass `null` to not search (and clear the results). */
|
|
7
|
+
export function useSearch(request, { debounceMs = 150, initialData, ...clientOptions } = {}) {
|
|
8
|
+
const client = useClient(clientOptions);
|
|
9
|
+
const key = request ? searchKey(request) : null;
|
|
10
|
+
const [result, setResult] = useState(() => ({
|
|
11
|
+
data: initialData ?? null,
|
|
12
|
+
error: null,
|
|
13
|
+
isPending: request !== null && !initialData,
|
|
14
|
+
}));
|
|
15
|
+
const initialKey = useRef(initialData ? key : null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!request) {
|
|
18
|
+
setResult((current) => (current.data || current.error || current.isPending ? { data: null, error: null, isPending: false } : current));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (key === initialKey.current)
|
|
22
|
+
return;
|
|
23
|
+
initialKey.current = null;
|
|
24
|
+
const cached = peek(client, request);
|
|
25
|
+
if (cached) {
|
|
26
|
+
setResult({ data: cached, error: null, isPending: false });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
setResult((current) => (current.isPending ? current : { ...current, isPending: true }));
|
|
30
|
+
let active = true;
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
cachedSearch(client, request, controller.signal).then((data) => { if (active)
|
|
34
|
+
setResult({ data, error: null, isPending: false }); }, (error) => { if (active)
|
|
35
|
+
setResult((current) => ({ ...current, error, isPending: false })); });
|
|
36
|
+
}, debounceMs);
|
|
37
|
+
return () => {
|
|
38
|
+
active = false;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
controller.abort();
|
|
41
|
+
};
|
|
42
|
+
// The key stands for the request, so a new object with the same search doesn't search again.
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [client, key, debounceMs]);
|
|
45
|
+
return result;
|
|
46
|
+
}
|