@headband/react 0.1.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.mjs ADDED
@@ -0,0 +1,1092 @@
1
+ "use client";
2
+
3
+ // src/client.ts
4
+ var HeadbandError = class extends Error {
5
+ constructor(message, status, code) {
6
+ super(message);
7
+ this.name = "HeadbandError";
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ };
12
+ function headband(host, apiKey) {
13
+ const baseUrl = host.replace(/\/+$/, "");
14
+ async function request(path, body) {
15
+ let response;
16
+ try {
17
+ response = await fetch(`${baseUrl}${path}`, {
18
+ method: "POST",
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ Authorization: `Bearer ${apiKey}`
22
+ },
23
+ body: JSON.stringify(body)
24
+ });
25
+ } catch (err) {
26
+ throw new HeadbandError(
27
+ err instanceof Error ? err.message : "Network request failed",
28
+ 0
29
+ );
30
+ }
31
+ if (!response.ok) {
32
+ let errorBody = {};
33
+ try {
34
+ errorBody = await response.json();
35
+ } catch {
36
+ }
37
+ throw new HeadbandError(
38
+ errorBody.error ?? `Request failed with status ${response.status}`,
39
+ response.status,
40
+ errorBody.code
41
+ );
42
+ }
43
+ return response.json();
44
+ }
45
+ return {
46
+ search(index, query, params) {
47
+ return request("/v1/search", {
48
+ index,
49
+ q: query,
50
+ ...params
51
+ });
52
+ },
53
+ multiSearch(queries) {
54
+ return request("/v1/multi-search", { queries });
55
+ }
56
+ };
57
+ }
58
+
59
+ // src/provider.tsx
60
+ import {
61
+ createContext,
62
+ useContext,
63
+ useCallback,
64
+ useEffect,
65
+ useRef,
66
+ useMemo,
67
+ useState
68
+ } from "react";
69
+ import { jsx } from "react/jsx-runtime";
70
+ var HeadbandContext = createContext(null);
71
+ function useHeadbandContext() {
72
+ const ctx = useContext(HeadbandContext);
73
+ if (!ctx) {
74
+ throw new Error(
75
+ "useHeadbandContext must be used within a <HeadbandProvider>."
76
+ );
77
+ }
78
+ return ctx;
79
+ }
80
+ function buildFilterString(refinements, ranges) {
81
+ const parts = [];
82
+ refinements.forEach((values, attribute) => {
83
+ if (values.size === 0) return;
84
+ if (values.size === 1) {
85
+ const [v] = values;
86
+ parts.push(`${attribute} = "${v}"`);
87
+ } else {
88
+ const list = Array.from(values).map((v) => `"${v}"`).join(", ");
89
+ parts.push(`${attribute} IN [${list}]`);
90
+ }
91
+ });
92
+ ranges.forEach((range, attribute) => {
93
+ if (range.min !== void 0) {
94
+ parts.push(`${attribute} >= ${range.min}`);
95
+ }
96
+ if (range.max !== void 0) {
97
+ parts.push(`${attribute} <= ${range.max}`);
98
+ }
99
+ });
100
+ return parts.length > 0 ? parts.join(" AND ") : void 0;
101
+ }
102
+ function HeadbandProvider({
103
+ client,
104
+ index,
105
+ initialQuery = "",
106
+ children
107
+ }) {
108
+ const [query, setQueryRaw] = useState(initialQuery);
109
+ const [results, setResults] = useState(null);
110
+ const [isLoading, setIsLoading] = useState(false);
111
+ const [error, setError] = useState(null);
112
+ const [refinements, setRefinements] = useState(
113
+ () => /* @__PURE__ */ new Map()
114
+ );
115
+ const [ranges, setRanges] = useState(() => /* @__PURE__ */ new Map());
116
+ const [sortBy, setSortBy] = useState(null);
117
+ const [page, setPage] = useState(0);
118
+ const [hitsPerPage, setHitsPerPage] = useState(20);
119
+ const [facets, setFacets] = useState(() => /* @__PURE__ */ new Set());
120
+ const timerRef = useRef(null);
121
+ const requestIdRef = useRef(0);
122
+ const executeSearch = useCallback(() => {
123
+ const id = ++requestIdRef.current;
124
+ setIsLoading(true);
125
+ setError(null);
126
+ const filter = buildFilterString(refinements, ranges);
127
+ const facetList = facets.size > 0 ? Array.from(facets) : void 0;
128
+ const sortList = sortBy ? [sortBy] : void 0;
129
+ const params = {
130
+ limit: hitsPerPage,
131
+ offset: page * hitsPerPage,
132
+ ...filter ? { filter } : {},
133
+ ...facetList ? { facets: facetList } : {},
134
+ ...sortList ? { sort: sortList } : {},
135
+ attributesToHighlight: ["*"]
136
+ };
137
+ client.search(index, query, params).then((res) => {
138
+ if (id === requestIdRef.current) {
139
+ setResults(res);
140
+ setIsLoading(false);
141
+ }
142
+ }).catch((err) => {
143
+ if (id === requestIdRef.current) {
144
+ setError(err instanceof Error ? err : new Error(String(err)));
145
+ setIsLoading(false);
146
+ }
147
+ });
148
+ }, [client, index, query, refinements, ranges, sortBy, page, hitsPerPage, facets]);
149
+ useEffect(() => {
150
+ if (timerRef.current) {
151
+ clearTimeout(timerRef.current);
152
+ }
153
+ timerRef.current = setTimeout(executeSearch, 200);
154
+ return () => {
155
+ if (timerRef.current) clearTimeout(timerRef.current);
156
+ };
157
+ }, [executeSearch]);
158
+ const setQuery = useCallback((q) => {
159
+ setQueryRaw(q);
160
+ setPage(0);
161
+ }, []);
162
+ const toggleRefinement = useCallback(
163
+ (attribute, value2) => {
164
+ setRefinements((prev) => {
165
+ const next = new Map(prev);
166
+ const current = new Set(next.get(attribute) ?? []);
167
+ if (current.has(value2)) {
168
+ current.delete(value2);
169
+ } else {
170
+ current.add(value2);
171
+ }
172
+ next.set(attribute, current);
173
+ return next;
174
+ });
175
+ setPage(0);
176
+ },
177
+ []
178
+ );
179
+ const setRange = useCallback(
180
+ (attribute, range) => {
181
+ setRanges((prev) => {
182
+ const next = new Map(prev);
183
+ next.set(attribute, range);
184
+ return next;
185
+ });
186
+ setPage(0);
187
+ },
188
+ []
189
+ );
190
+ const clearRange = useCallback((attribute) => {
191
+ setRanges((prev) => {
192
+ const next = new Map(prev);
193
+ next.delete(attribute);
194
+ return next;
195
+ });
196
+ setPage(0);
197
+ }, []);
198
+ const registerFacet = useCallback((attribute) => {
199
+ setFacets((prev) => {
200
+ if (prev.has(attribute)) return prev;
201
+ const next = new Set(prev);
202
+ next.add(attribute);
203
+ return next;
204
+ });
205
+ }, []);
206
+ const unregisterFacet = useCallback((attribute) => {
207
+ setFacets((prev) => {
208
+ if (!prev.has(attribute)) return prev;
209
+ const next = new Set(prev);
210
+ next.delete(attribute);
211
+ return next;
212
+ });
213
+ }, []);
214
+ const refresh = useCallback(() => {
215
+ executeSearch();
216
+ }, [executeSearch]);
217
+ const state = useMemo(
218
+ () => ({
219
+ query,
220
+ results,
221
+ isLoading,
222
+ error,
223
+ refinements,
224
+ ranges,
225
+ sortBy,
226
+ page,
227
+ hitsPerPage,
228
+ facets
229
+ }),
230
+ [query, results, isLoading, error, refinements, ranges, sortBy, page, hitsPerPage, facets]
231
+ );
232
+ const actions = useMemo(
233
+ () => ({
234
+ setQuery,
235
+ setPage,
236
+ setSortBy,
237
+ setHitsPerPage,
238
+ toggleRefinement,
239
+ setRange,
240
+ clearRange,
241
+ registerFacet,
242
+ unregisterFacet,
243
+ refresh
244
+ }),
245
+ [setQuery, toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet, refresh]
246
+ );
247
+ const value = useMemo(
248
+ () => ({ client, index, state, actions }),
249
+ [client, index, state, actions]
250
+ );
251
+ return /* @__PURE__ */ jsx(HeadbandContext.Provider, { value, children });
252
+ }
253
+
254
+ // src/hooks/useSearch.ts
255
+ function useSearch() {
256
+ const { state, actions } = useHeadbandContext();
257
+ return { ...state, ...actions };
258
+ }
259
+
260
+ // src/hooks/useSearchBox.ts
261
+ import { useCallback as useCallback2 } from "react";
262
+ function useSearchBox() {
263
+ const { state, actions } = useHeadbandContext();
264
+ const clear = useCallback2(() => {
265
+ actions.setQuery("");
266
+ }, [actions]);
267
+ return {
268
+ query: state.query,
269
+ setQuery: actions.setQuery,
270
+ clear
271
+ };
272
+ }
273
+
274
+ // src/hooks/useHits.ts
275
+ import { useMemo as useMemo2 } from "react";
276
+ function useHits() {
277
+ const { state } = useHeadbandContext();
278
+ const hits = useMemo2(() => {
279
+ if (!state.results) return [];
280
+ return state.results.hits.map((raw) => {
281
+ const hit = { ...raw };
282
+ hit.__data = raw;
283
+ return hit;
284
+ });
285
+ }, [state.results]);
286
+ return {
287
+ hits,
288
+ isLoading: state.isLoading,
289
+ error: state.error
290
+ };
291
+ }
292
+
293
+ // src/hooks/useRefinementList.ts
294
+ import { useEffect as useEffect2, useMemo as useMemo3, useCallback as useCallback3 } from "react";
295
+ function useRefinementList({
296
+ attribute,
297
+ limit = 20,
298
+ sortBy: sortOrder = "count"
299
+ }) {
300
+ const { state, actions } = useHeadbandContext();
301
+ useEffect2(() => {
302
+ actions.registerFacet(attribute);
303
+ return () => {
304
+ actions.unregisterFacet(attribute);
305
+ };
306
+ }, [attribute, actions]);
307
+ const refined = state.refinements.get(attribute) ?? /* @__PURE__ */ new Set();
308
+ const items = useMemo3(() => {
309
+ const distribution = state.results?.facetDistribution?.[attribute] ?? {};
310
+ const allValues = /* @__PURE__ */ new Set([
311
+ ...Object.keys(distribution),
312
+ ...refined
313
+ ]);
314
+ let result = Array.from(allValues).map(
315
+ (value) => ({
316
+ value,
317
+ count: distribution[value] ?? 0,
318
+ isRefined: refined.has(value)
319
+ })
320
+ );
321
+ if (sortOrder === "alpha") {
322
+ result.sort((a, b) => a.value.localeCompare(b.value));
323
+ } else {
324
+ result.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
325
+ }
326
+ if (result.length > limit) {
327
+ result = result.slice(0, limit);
328
+ }
329
+ return result;
330
+ }, [state.results, attribute, refined, sortOrder, limit]);
331
+ const refine = useCallback3(
332
+ (value) => {
333
+ actions.toggleRefinement(attribute, value);
334
+ },
335
+ [attribute, actions]
336
+ );
337
+ return {
338
+ items,
339
+ refine,
340
+ canToggle: items.length > 0,
341
+ isLoading: state.isLoading
342
+ };
343
+ }
344
+
345
+ // src/hooks/usePagination.ts
346
+ import { useMemo as useMemo4, useCallback as useCallback4 } from "react";
347
+ function usePagination() {
348
+ const { state, actions } = useHeadbandContext();
349
+ const totalHits = useMemo4(() => {
350
+ if (!state.results) return 0;
351
+ return state.results.estimatedTotalHits ?? state.results.totalHits ?? 0;
352
+ }, [state.results]);
353
+ const totalPages = useMemo4(() => {
354
+ if (totalHits === 0) return 0;
355
+ return Math.ceil(totalHits / state.hitsPerPage);
356
+ }, [totalHits, state.hitsPerPage]);
357
+ const canPrevious = state.page > 0;
358
+ const canNext = state.page < totalPages - 1;
359
+ const setPage = useCallback4(
360
+ (page) => {
361
+ const clamped = Math.max(0, Math.min(page, totalPages - 1));
362
+ actions.setPage(clamped);
363
+ },
364
+ [actions, totalPages]
365
+ );
366
+ const pages = useMemo4(() => {
367
+ if (totalPages === 0) return [];
368
+ const windowSize = 5;
369
+ const half = Math.floor(windowSize / 2);
370
+ let start = Math.max(0, state.page - half);
371
+ const end = Math.min(totalPages, start + windowSize);
372
+ if (end - start < windowSize) {
373
+ start = Math.max(0, end - windowSize);
374
+ }
375
+ const result = [];
376
+ for (let i = start; i < end; i++) {
377
+ result.push(i);
378
+ }
379
+ return result;
380
+ }, [state.page, totalPages]);
381
+ return {
382
+ currentPage: state.page,
383
+ totalPages,
384
+ totalHits,
385
+ setPage,
386
+ canPrevious,
387
+ canNext,
388
+ pages
389
+ };
390
+ }
391
+
392
+ // src/hooks/useStats.ts
393
+ import { useMemo as useMemo5 } from "react";
394
+ function useStats() {
395
+ const { state } = useHeadbandContext();
396
+ return useMemo5(
397
+ () => ({
398
+ totalHits: state.results ? state.results.estimatedTotalHits ?? state.results.totalHits ?? 0 : 0,
399
+ processingTimeMs: state.results?.processingTimeMs ?? 0,
400
+ query: state.query,
401
+ isLoading: state.isLoading
402
+ }),
403
+ [state.results, state.query, state.isLoading]
404
+ );
405
+ }
406
+
407
+ // src/hooks/useSortBy.ts
408
+ import { useCallback as useCallback5, useMemo as useMemo6 } from "react";
409
+ function useSortBy({ items }) {
410
+ const { state, actions } = useHeadbandContext();
411
+ const setSort = useCallback5(
412
+ (value) => {
413
+ actions.setSortBy(value);
414
+ },
415
+ [actions]
416
+ );
417
+ const enrichedItems = useMemo6(
418
+ () => items.map((item) => ({
419
+ ...item,
420
+ isSelected: state.sortBy === item.value
421
+ })),
422
+ [items, state.sortBy]
423
+ );
424
+ return {
425
+ currentSort: state.sortBy,
426
+ setSort,
427
+ items: enrichedItems
428
+ };
429
+ }
430
+
431
+ // src/hooks/useRange.ts
432
+ import { useCallback as useCallback6, useMemo as useMemo7 } from "react";
433
+ function useRange({ attribute }) {
434
+ const { state, actions } = useHeadbandContext();
435
+ const current = state.ranges.get(attribute);
436
+ const stats = useMemo7(() => {
437
+ return state.results?.facetStats?.[attribute];
438
+ }, [state.results, attribute]);
439
+ const setRange = useCallback6(
440
+ (range) => {
441
+ actions.setRange(attribute, range);
442
+ },
443
+ [attribute, actions]
444
+ );
445
+ const clear = useCallback6(() => {
446
+ actions.clearRange(attribute);
447
+ }, [attribute, actions]);
448
+ return {
449
+ min: current?.min,
450
+ max: current?.max,
451
+ stats,
452
+ setRange,
453
+ clear,
454
+ isLoading: state.isLoading
455
+ };
456
+ }
457
+
458
+ // src/hooks/useHighlight.ts
459
+ import { useMemo as useMemo8 } from "react";
460
+ function sanitizeHighlight(raw) {
461
+ const OPEN = "\0OPEN\0";
462
+ const CLOSE = "\0CLOSE\0";
463
+ let sanitized = raw.replace(/<em>/gi, OPEN).replace(/<\/em>/gi, CLOSE);
464
+ sanitized = sanitized.replace(/<[^>]*>/g, "");
465
+ sanitized = sanitized.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
466
+ sanitized = sanitized.replace(new RegExp(escapeRegex(OPEN), "g"), "<em>").replace(new RegExp(escapeRegex(CLOSE), "g"), "</em>");
467
+ return sanitized;
468
+ }
469
+ function escapeRegex(s) {
470
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
471
+ }
472
+ function getNestedValue(obj, path) {
473
+ const parts = path.split(".");
474
+ let current = obj;
475
+ for (const part of parts) {
476
+ if (current === null || current === void 0) return void 0;
477
+ current = current[part];
478
+ }
479
+ return current;
480
+ }
481
+ function useHighlight({
482
+ hit,
483
+ attribute
484
+ }) {
485
+ return useMemo8(() => {
486
+ const formatted = hit._formatted;
487
+ const highlightedRaw = formatted ? getNestedValue(formatted, attribute) : void 0;
488
+ if (typeof highlightedRaw === "string") {
489
+ const sanitized = sanitizeHighlight(highlightedRaw);
490
+ const hasHighlight = sanitized.includes("<em>");
491
+ return { value: sanitized, highlighted: hasHighlight };
492
+ }
493
+ const rawValue = getNestedValue(hit, attribute);
494
+ const stringValue = rawValue !== null && rawValue !== void 0 ? String(rawValue) : "";
495
+ return { value: stringValue, highlighted: false };
496
+ }, [hit, attribute]);
497
+ }
498
+
499
+ // src/components/SearchBox.tsx
500
+ import {
501
+ useCallback as useCallback7,
502
+ useRef as useRef2
503
+ } from "react";
504
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
505
+ function SearchBox({
506
+ placeholder = "Search...",
507
+ className,
508
+ autoFocus = false,
509
+ onQueryChange,
510
+ styled = true
511
+ }) {
512
+ const { query, setQuery, clear } = useSearchBox();
513
+ const inputRef = useRef2(null);
514
+ const handleChange = useCallback7(
515
+ (e) => {
516
+ const value = e.target.value;
517
+ setQuery(value);
518
+ onQueryChange?.(value);
519
+ },
520
+ [setQuery, onQueryChange]
521
+ );
522
+ const handleClear = useCallback7(() => {
523
+ clear();
524
+ onQueryChange?.("");
525
+ inputRef.current?.focus();
526
+ }, [clear, onQueryChange]);
527
+ const handleSubmit = useCallback7((e) => {
528
+ e.preventDefault();
529
+ }, []);
530
+ const handleKeyDown = useCallback7(
531
+ (e) => {
532
+ if (e.key === "Escape") {
533
+ handleClear();
534
+ }
535
+ },
536
+ [handleClear]
537
+ );
538
+ const baseClass = styled ? "relative flex items-center w-full" : "";
539
+ const inputClass = styled ? "w-full rounded-lg border border-gray-200 bg-white py-2.5 pl-10 pr-10 text-sm outline-none transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500 dark:focus:border-indigo-400" : "";
540
+ return /* @__PURE__ */ jsxs(
541
+ "form",
542
+ {
543
+ onSubmit: handleSubmit,
544
+ role: "search",
545
+ className: `${baseClass} ${className ?? ""}`.trim(),
546
+ children: [
547
+ /* @__PURE__ */ jsx2(
548
+ "svg",
549
+ {
550
+ className: styled ? "pointer-events-none absolute left-3 h-4 w-4 text-gray-400 dark:text-gray-500" : "pointer-events-none absolute left-3 h-4 w-4",
551
+ xmlns: "http://www.w3.org/2000/svg",
552
+ fill: "none",
553
+ viewBox: "0 0 24 24",
554
+ strokeWidth: 2,
555
+ stroke: "currentColor",
556
+ "aria-hidden": "true",
557
+ children: /* @__PURE__ */ jsx2(
558
+ "path",
559
+ {
560
+ strokeLinecap: "round",
561
+ strokeLinejoin: "round",
562
+ d: "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
563
+ }
564
+ )
565
+ }
566
+ ),
567
+ /* @__PURE__ */ jsx2(
568
+ "input",
569
+ {
570
+ ref: inputRef,
571
+ type: "search",
572
+ value: query,
573
+ onChange: handleChange,
574
+ onKeyDown: handleKeyDown,
575
+ placeholder,
576
+ autoFocus,
577
+ autoComplete: "off",
578
+ autoCorrect: "off",
579
+ spellCheck: false,
580
+ "aria-label": "Search",
581
+ className: inputClass
582
+ }
583
+ ),
584
+ query.length > 0 && /* @__PURE__ */ jsx2(
585
+ "button",
586
+ {
587
+ type: "button",
588
+ onClick: handleClear,
589
+ "aria-label": "Clear search",
590
+ className: styled ? "absolute right-3 flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" : "absolute right-3 flex h-5 w-5 items-center justify-center",
591
+ children: /* @__PURE__ */ jsx2(
592
+ "svg",
593
+ {
594
+ xmlns: "http://www.w3.org/2000/svg",
595
+ fill: "none",
596
+ viewBox: "0 0 24 24",
597
+ strokeWidth: 2,
598
+ stroke: "currentColor",
599
+ className: "h-3.5 w-3.5",
600
+ "aria-hidden": "true",
601
+ children: /* @__PURE__ */ jsx2(
602
+ "path",
603
+ {
604
+ strokeLinecap: "round",
605
+ strokeLinejoin: "round",
606
+ d: "M6 18 18 6M6 6l12 12"
607
+ }
608
+ )
609
+ }
610
+ )
611
+ }
612
+ )
613
+ ]
614
+ }
615
+ );
616
+ }
617
+
618
+ // src/components/Hits.tsx
619
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
620
+ function DefaultHit({ hit }) {
621
+ const { _formatted, __data, ...rest } = hit;
622
+ return /* @__PURE__ */ jsx3("div", { className: "rounded-lg border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-900", children: /* @__PURE__ */ jsx3("pre", { className: "overflow-x-auto whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300", children: JSON.stringify(rest, null, 2) }) });
623
+ }
624
+ function DefaultEmpty() {
625
+ return /* @__PURE__ */ jsx3("div", { className: "py-12 text-center text-sm text-gray-500 dark:text-gray-400", children: "No results found." });
626
+ }
627
+ function Hits({
628
+ hitComponent,
629
+ className,
630
+ emptyComponent,
631
+ styled = true
632
+ }) {
633
+ const { hits, isLoading, error } = useHits();
634
+ const HitComponent = hitComponent ?? DefaultHit;
635
+ if (error) {
636
+ return /* @__PURE__ */ jsxs2(
637
+ "div",
638
+ {
639
+ role: "alert",
640
+ className: styled ? "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300" : void 0,
641
+ children: [
642
+ "Search error: ",
643
+ error.message
644
+ ]
645
+ }
646
+ );
647
+ }
648
+ if (!isLoading && hits.length === 0) {
649
+ return /* @__PURE__ */ jsx3(Fragment, { children: emptyComponent ?? /* @__PURE__ */ jsx3(DefaultEmpty, {}) });
650
+ }
651
+ const listClass = styled ? `grid gap-3 ${className ?? ""}`.trim() : className ?? "";
652
+ return /* @__PURE__ */ jsxs2("div", { className: listClass, role: "list", "aria-label": "Search results", children: [
653
+ isLoading && hits.length === 0 && /* @__PURE__ */ jsx3(
654
+ "div",
655
+ {
656
+ className: styled ? "py-8 text-center text-sm text-gray-400 dark:text-gray-500" : void 0,
657
+ "aria-live": "polite",
658
+ children: "Searching..."
659
+ }
660
+ ),
661
+ hits.map((hit, i) => /* @__PURE__ */ jsx3("div", { role: "listitem", children: /* @__PURE__ */ jsx3(HitComponent, { hit }) }, hit.id ?? i))
662
+ ] });
663
+ }
664
+
665
+ // src/components/RefinementList.tsx
666
+ import { useCallback as useCallback8 } from "react";
667
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
668
+ function RefinementList({
669
+ attribute,
670
+ limit,
671
+ sortBy,
672
+ className,
673
+ styled = true,
674
+ header
675
+ }) {
676
+ const { items, refine, isLoading } = useRefinementList({
677
+ attribute,
678
+ limit,
679
+ sortBy
680
+ });
681
+ const handleChange = useCallback8(
682
+ (value) => (_e) => {
683
+ refine(value);
684
+ },
685
+ [refine]
686
+ );
687
+ const containerClass = styled ? `space-y-1 ${className ?? ""}`.trim() : className ?? "";
688
+ return /* @__PURE__ */ jsxs3("fieldset", { className: containerClass, "aria-label": `Filter by ${attribute}`, children: [
689
+ header && /* @__PURE__ */ jsx4(
690
+ "legend",
691
+ {
692
+ className: styled ? "mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" : void 0,
693
+ children: header
694
+ }
695
+ ),
696
+ isLoading && items.length === 0 && /* @__PURE__ */ jsx4(
697
+ "div",
698
+ {
699
+ className: styled ? "py-2 text-xs text-gray-400 dark:text-gray-500" : void 0,
700
+ "aria-live": "polite",
701
+ children: "Loading..."
702
+ }
703
+ ),
704
+ items.map((item) => /* @__PURE__ */ jsxs3(
705
+ "label",
706
+ {
707
+ className: styled ? "flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800" : "flex items-center gap-2",
708
+ children: [
709
+ /* @__PURE__ */ jsx4(
710
+ "input",
711
+ {
712
+ type: "checkbox",
713
+ checked: item.isRefined,
714
+ onChange: handleChange(item.value),
715
+ className: styled ? "h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 dark:border-gray-600" : void 0,
716
+ "aria-label": `${item.value} (${item.count})`
717
+ }
718
+ ),
719
+ /* @__PURE__ */ jsx4(
720
+ "span",
721
+ {
722
+ className: styled ? "flex-1 truncate text-gray-700 dark:text-gray-300" : "flex-1",
723
+ children: item.value
724
+ }
725
+ ),
726
+ /* @__PURE__ */ jsx4(
727
+ "span",
728
+ {
729
+ className: styled ? "text-xs tabular-nums text-gray-400 dark:text-gray-500" : void 0,
730
+ children: item.count
731
+ }
732
+ )
733
+ ]
734
+ },
735
+ item.value
736
+ ))
737
+ ] });
738
+ }
739
+
740
+ // src/components/Pagination.tsx
741
+ import { useCallback as useCallback9 } from "react";
742
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
743
+ function Pagination({ className, styled = true }) {
744
+ const { currentPage, totalPages, setPage, canPrevious, canNext, pages } = usePagination();
745
+ const handlePrevious = useCallback9(() => {
746
+ setPage(currentPage - 1);
747
+ }, [setPage, currentPage]);
748
+ const handleNext = useCallback9(() => {
749
+ setPage(currentPage + 1);
750
+ }, [setPage, currentPage]);
751
+ if (totalPages <= 1) return null;
752
+ const navClass = styled ? `flex items-center gap-1 ${className ?? ""}`.trim() : className ?? "";
753
+ const buttonBase = styled ? "inline-flex h-8 min-w-[2rem] items-center justify-center rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500" : "";
754
+ const activeClass = styled ? "bg-indigo-600 text-white" : "";
755
+ const inactiveClass = styled ? "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800" : "";
756
+ const disabledClass = styled ? "text-gray-300 cursor-not-allowed dark:text-gray-600" : "";
757
+ return /* @__PURE__ */ jsxs4("nav", { "aria-label": "Search results pagination", className: navClass, children: [
758
+ /* @__PURE__ */ jsx5(
759
+ "button",
760
+ {
761
+ type: "button",
762
+ onClick: handlePrevious,
763
+ disabled: !canPrevious,
764
+ "aria-label": "Previous page",
765
+ className: `${buttonBase} px-2 ${canPrevious ? inactiveClass : disabledClass}`.trim(),
766
+ children: /* @__PURE__ */ jsx5(
767
+ "svg",
768
+ {
769
+ xmlns: "http://www.w3.org/2000/svg",
770
+ fill: "none",
771
+ viewBox: "0 0 24 24",
772
+ strokeWidth: 2,
773
+ stroke: "currentColor",
774
+ className: "h-4 w-4",
775
+ "aria-hidden": "true",
776
+ children: /* @__PURE__ */ jsx5(
777
+ "path",
778
+ {
779
+ strokeLinecap: "round",
780
+ strokeLinejoin: "round",
781
+ d: "M15.75 19.5 8.25 12l7.5-7.5"
782
+ }
783
+ )
784
+ }
785
+ )
786
+ }
787
+ ),
788
+ pages.map((page) => /* @__PURE__ */ jsx5(
789
+ "button",
790
+ {
791
+ type: "button",
792
+ onClick: () => setPage(page),
793
+ "aria-label": `Page ${page + 1}`,
794
+ "aria-current": page === currentPage ? "page" : void 0,
795
+ className: `${buttonBase} ${page === currentPage ? activeClass : inactiveClass}`.trim(),
796
+ children: page + 1
797
+ },
798
+ page
799
+ )),
800
+ /* @__PURE__ */ jsx5(
801
+ "button",
802
+ {
803
+ type: "button",
804
+ onClick: handleNext,
805
+ disabled: !canNext,
806
+ "aria-label": "Next page",
807
+ className: `${buttonBase} px-2 ${canNext ? inactiveClass : disabledClass}`.trim(),
808
+ children: /* @__PURE__ */ jsx5(
809
+ "svg",
810
+ {
811
+ xmlns: "http://www.w3.org/2000/svg",
812
+ fill: "none",
813
+ viewBox: "0 0 24 24",
814
+ strokeWidth: 2,
815
+ stroke: "currentColor",
816
+ className: "h-4 w-4",
817
+ "aria-hidden": "true",
818
+ children: /* @__PURE__ */ jsx5(
819
+ "path",
820
+ {
821
+ strokeLinecap: "round",
822
+ strokeLinejoin: "round",
823
+ d: "m8.25 4.5 7.5 7.5-7.5 7.5"
824
+ }
825
+ )
826
+ }
827
+ )
828
+ }
829
+ )
830
+ ] });
831
+ }
832
+
833
+ // src/components/Stats.tsx
834
+ import { jsx as jsx6 } from "react/jsx-runtime";
835
+ function Stats({ className, styled = true, render }) {
836
+ const { totalHits, processingTimeMs, query, isLoading } = useStats();
837
+ if (isLoading && totalHits === 0) return null;
838
+ const text = render ? render({ totalHits, processingTimeMs, query }) : `${totalHits.toLocaleString()} result${totalHits !== 1 ? "s" : ""} in ${processingTimeMs}ms`;
839
+ return /* @__PURE__ */ jsx6(
840
+ "p",
841
+ {
842
+ className: styled ? `text-sm text-gray-500 dark:text-gray-400 ${className ?? ""}`.trim() : className ?? "",
843
+ "aria-live": "polite",
844
+ role: "status",
845
+ children: text
846
+ }
847
+ );
848
+ }
849
+
850
+ // src/components/SortBy.tsx
851
+ import { useCallback as useCallback10 } from "react";
852
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
853
+ function SortBy({
854
+ items: itemsProp,
855
+ className,
856
+ styled = true,
857
+ label = "Sort by"
858
+ }) {
859
+ const { currentSort, setSort, items } = useSortBy({ items: itemsProp });
860
+ const handleChange = useCallback10(
861
+ (e) => {
862
+ const value = e.target.value;
863
+ setSort(value === "" ? null : value);
864
+ },
865
+ [setSort]
866
+ );
867
+ const selectClass = styled ? "rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition-colors focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white" : "";
868
+ return /* @__PURE__ */ jsxs5("div", { className, children: [
869
+ /* @__PURE__ */ jsx7("label", { className: "sr-only", htmlFor: "headband-sort-by", children: label }),
870
+ /* @__PURE__ */ jsxs5(
871
+ "select",
872
+ {
873
+ id: "headband-sort-by",
874
+ value: currentSort ?? "",
875
+ onChange: handleChange,
876
+ className: selectClass,
877
+ "aria-label": label,
878
+ children: [
879
+ /* @__PURE__ */ jsx7("option", { value: "", children: "Relevance" }),
880
+ items.map((item) => /* @__PURE__ */ jsx7("option", { value: item.value, children: item.label }, item.value))
881
+ ]
882
+ }
883
+ )
884
+ ] });
885
+ }
886
+
887
+ // src/components/Highlight.tsx
888
+ import { jsx as jsx8 } from "react/jsx-runtime";
889
+ function Highlight({
890
+ hit,
891
+ attribute,
892
+ className,
893
+ styled = true,
894
+ tagName: Tag = "span"
895
+ }) {
896
+ const { value, highlighted } = useHighlight({ hit, attribute });
897
+ const highlightClass = styled ? `[&_em]:bg-indigo-100 [&_em]:not-italic [&_em]:font-medium [&_em]:text-indigo-900 dark:[&_em]:bg-indigo-900/40 dark:[&_em]:text-indigo-200 ${className ?? ""}`.trim() : className ?? "";
898
+ if (highlighted) {
899
+ return /* @__PURE__ */ jsx8(
900
+ Tag,
901
+ {
902
+ className: highlightClass,
903
+ dangerouslySetInnerHTML: { __html: value }
904
+ }
905
+ );
906
+ }
907
+ return /* @__PURE__ */ jsx8(Tag, { className, children: value });
908
+ }
909
+
910
+ // src/components/RangeInput.tsx
911
+ import {
912
+ useState as useState2,
913
+ useCallback as useCallback11,
914
+ useEffect as useEffect3
915
+ } from "react";
916
+ import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
917
+ function RangeInput({
918
+ attribute,
919
+ className,
920
+ styled = true,
921
+ header,
922
+ minPlaceholder = "Min",
923
+ maxPlaceholder = "Max"
924
+ }) {
925
+ const { min, max, stats, setRange, clear } = useRange({ attribute });
926
+ const [localMin, setLocalMin] = useState2(min !== void 0 ? String(min) : "");
927
+ const [localMax, setLocalMax] = useState2(max !== void 0 ? String(max) : "");
928
+ useEffect3(() => {
929
+ setLocalMin(min !== void 0 ? String(min) : "");
930
+ }, [min]);
931
+ useEffect3(() => {
932
+ setLocalMax(max !== void 0 ? String(max) : "");
933
+ }, [max]);
934
+ const handleSubmit = useCallback11(
935
+ (e) => {
936
+ e.preventDefault();
937
+ const parsedMin = localMin !== "" ? Number(localMin) : void 0;
938
+ const parsedMax = localMax !== "" ? Number(localMax) : void 0;
939
+ if (parsedMin === void 0 && parsedMax === void 0) {
940
+ clear();
941
+ } else {
942
+ setRange({
943
+ min: parsedMin !== void 0 && !Number.isNaN(parsedMin) ? parsedMin : void 0,
944
+ max: parsedMax !== void 0 && !Number.isNaN(parsedMax) ? parsedMax : void 0
945
+ });
946
+ }
947
+ },
948
+ [localMin, localMax, setRange, clear]
949
+ );
950
+ const handleMinChange = useCallback11(
951
+ (e) => setLocalMin(e.target.value),
952
+ []
953
+ );
954
+ const handleMaxChange = useCallback11(
955
+ (e) => setLocalMax(e.target.value),
956
+ []
957
+ );
958
+ const containerClass = styled ? `space-y-2 ${className ?? ""}`.trim() : className ?? "";
959
+ const inputClass = styled ? "w-full rounded border border-gray-200 bg-white px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500" : "";
960
+ const buttonClass = styled ? "rounded bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500" : "";
961
+ return /* @__PURE__ */ jsxs6(
962
+ "form",
963
+ {
964
+ onSubmit: handleSubmit,
965
+ className: containerClass,
966
+ "aria-label": `Filter by ${attribute} range`,
967
+ children: [
968
+ header && /* @__PURE__ */ jsx9(
969
+ "div",
970
+ {
971
+ className: styled ? "text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" : void 0,
972
+ children: header
973
+ }
974
+ ),
975
+ stats && /* @__PURE__ */ jsxs6(
976
+ "div",
977
+ {
978
+ className: styled ? "text-xs text-gray-400 dark:text-gray-500" : void 0,
979
+ children: [
980
+ "Range: ",
981
+ stats.min,
982
+ " \u2013 ",
983
+ stats.max
984
+ ]
985
+ }
986
+ ),
987
+ /* @__PURE__ */ jsxs6("div", { className: styled ? "flex items-center gap-2" : "flex gap-2", children: [
988
+ /* @__PURE__ */ jsx9(
989
+ "input",
990
+ {
991
+ type: "number",
992
+ value: localMin,
993
+ onChange: handleMinChange,
994
+ placeholder: minPlaceholder,
995
+ "aria-label": `Minimum ${attribute}`,
996
+ className: inputClass
997
+ }
998
+ ),
999
+ /* @__PURE__ */ jsx9(
1000
+ "span",
1001
+ {
1002
+ className: styled ? "text-sm text-gray-400 dark:text-gray-500" : void 0,
1003
+ "aria-hidden": "true",
1004
+ children: "\u2013"
1005
+ }
1006
+ ),
1007
+ /* @__PURE__ */ jsx9(
1008
+ "input",
1009
+ {
1010
+ type: "number",
1011
+ value: localMax,
1012
+ onChange: handleMaxChange,
1013
+ placeholder: maxPlaceholder,
1014
+ "aria-label": `Maximum ${attribute}`,
1015
+ className: inputClass
1016
+ }
1017
+ ),
1018
+ /* @__PURE__ */ jsx9("button", { type: "submit", className: buttonClass, children: "Go" })
1019
+ ] })
1020
+ ]
1021
+ }
1022
+ );
1023
+ }
1024
+
1025
+ // src/components/PoweredBy.tsx
1026
+ import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
1027
+ function PoweredBy({
1028
+ className,
1029
+ styled = true,
1030
+ href = "https://headband.dev"
1031
+ }) {
1032
+ const linkClass = styled ? `inline-flex items-center gap-1.5 text-xs text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 ${className ?? ""}`.trim() : className ?? "";
1033
+ return /* @__PURE__ */ jsxs7(
1034
+ "a",
1035
+ {
1036
+ href,
1037
+ target: "_blank",
1038
+ rel: "noopener noreferrer",
1039
+ className: linkClass,
1040
+ "aria-label": "Powered by Headband - Search as a Service",
1041
+ children: [
1042
+ /* @__PURE__ */ jsx10(
1043
+ "svg",
1044
+ {
1045
+ width: "14",
1046
+ height: "14",
1047
+ viewBox: "0 0 24 24",
1048
+ fill: "none",
1049
+ xmlns: "http://www.w3.org/2000/svg",
1050
+ "aria-hidden": "true",
1051
+ children: /* @__PURE__ */ jsx10(
1052
+ "path",
1053
+ {
1054
+ d: "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z",
1055
+ stroke: "currentColor",
1056
+ strokeWidth: "2",
1057
+ strokeLinecap: "round",
1058
+ strokeLinejoin: "round"
1059
+ }
1060
+ )
1061
+ }
1062
+ ),
1063
+ "Powered by ",
1064
+ /* @__PURE__ */ jsx10("strong", { className: "font-semibold", children: "Headband" })
1065
+ ]
1066
+ }
1067
+ );
1068
+ }
1069
+ export {
1070
+ HeadbandError,
1071
+ HeadbandProvider,
1072
+ Highlight,
1073
+ Hits,
1074
+ Pagination,
1075
+ PoweredBy,
1076
+ RangeInput,
1077
+ RefinementList,
1078
+ SearchBox,
1079
+ SortBy,
1080
+ Stats,
1081
+ headband,
1082
+ useHeadbandContext,
1083
+ useHighlight,
1084
+ useHits,
1085
+ usePagination,
1086
+ useRange,
1087
+ useRefinementList,
1088
+ useSearch,
1089
+ useSearchBox,
1090
+ useSortBy,
1091
+ useStats
1092
+ };