@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/README.md ADDED
@@ -0,0 +1,520 @@
1
+ # @headband/react
2
+
3
+ React hooks and components for [Headband](https://headband.dev) search. Drop-in building blocks for building fast, faceted search UIs -- designed as an InstantSearch-compatible alternative that works with Headband's unified search API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @headband/react
9
+ ```
10
+
11
+ **Peer dependencies:** React 18+ and React DOM 18+.
12
+
13
+ ## Quick Start
14
+
15
+ ```tsx
16
+ import {
17
+ headband,
18
+ HeadbandProvider,
19
+ SearchBox,
20
+ Hits,
21
+ RefinementList,
22
+ Pagination,
23
+ Stats,
24
+ } from "@headband/react";
25
+
26
+ const client = headband("https://your-headband-instance.com", "your-api-key");
27
+
28
+ function App() {
29
+ return (
30
+ <HeadbandProvider client={client} index="movies">
31
+ <SearchBox placeholder="Search movies..." />
32
+ <Stats />
33
+ <div style={{ display: "flex", gap: "2rem" }}>
34
+ <RefinementList attribute="genres" header="Genres" />
35
+ <div>
36
+ <Hits hitComponent={MovieHit} />
37
+ <Pagination />
38
+ </div>
39
+ </div>
40
+ </HeadbandProvider>
41
+ );
42
+ }
43
+
44
+ function MovieHit({ hit }: { hit: any }) {
45
+ return (
46
+ <div>
47
+ <h3>{hit.title}</h3>
48
+ <p>{hit.overview}</p>
49
+ </div>
50
+ );
51
+ }
52
+ ```
53
+
54
+ ## Architecture
55
+
56
+ All state lives in `<HeadbandProvider>`, which holds the search query, refinements, pagination, and sort state. Child components read and write state through React hooks. Search requests are debounced (200ms) and stale responses are automatically discarded.
57
+
58
+ ```
59
+ HeadbandProvider -- owns all search state, executes searches
60
+ |-- useSearchBox() -- query input binding
61
+ |-- useHits() -- current result hits
62
+ |-- useRefinementList() -- facet filtering
63
+ |-- usePagination() -- page navigation
64
+ |-- useStats() -- result count & timing
65
+ |-- useSortBy() -- sort order
66
+ |-- useRange() -- numeric range filters
67
+ |-- useHighlight() -- highlighted text extraction
68
+ |-- useSearch() -- full state + actions (escape hatch)
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Client
74
+
75
+ ### `headband(host, apiKey)`
76
+
77
+ Creates a Headband API client.
78
+
79
+ ```ts
80
+ import { headband } from "@headband/react";
81
+
82
+ const client = headband("https://search.example.com", "your-api-key");
83
+ ```
84
+
85
+ | Param | Type | Description |
86
+ | -------- | -------- | --------------------------------- |
87
+ | `host` | `string` | Headband instance URL |
88
+ | `apiKey` | `string` | API key for authentication |
89
+
90
+ Returns a `HeadbandClient` with `search()` and `multiSearch()` methods.
91
+
92
+ ---
93
+
94
+ ## Provider
95
+
96
+ ### `<HeadbandProvider>`
97
+
98
+ Wraps your search UI and manages all search state.
99
+
100
+ ```tsx
101
+ <HeadbandProvider
102
+ client={client}
103
+ index="products"
104
+ initialQuery="shoes"
105
+ >
106
+ {/* search components */}
107
+ </HeadbandProvider>
108
+ ```
109
+
110
+ | Prop | Type | Default | Description |
111
+ | -------------- | ----------------- | ------- | ---------------------------------------- |
112
+ | `client` | `HeadbandClient` | -- | Client returned by `headband()` |
113
+ | `index` | `string` | -- | Index to search |
114
+ | `initialQuery` | `string` | `""` | Starting query value |
115
+ | `children` | `ReactNode` | -- | Child components |
116
+
117
+ ---
118
+
119
+ ## Hooks
120
+
121
+ ### `useSearchBox()`
122
+
123
+ Binds to the search input. Returns the current query plus setters.
124
+
125
+ ```tsx
126
+ const { query, setQuery, clear } = useSearchBox();
127
+ ```
128
+
129
+ | Return | Type | Description |
130
+ | ---------- | -------------------------- | ---------------------------------------------- |
131
+ | `query` | `string` | Current query string |
132
+ | `setQuery` | `(query: string) => void` | Update the query (resets pagination) |
133
+ | `clear` | `() => void` | Clear the query to an empty string |
134
+
135
+ ---
136
+
137
+ ### `useHits<T>()`
138
+
139
+ Returns the current search result hits with loading and error state.
140
+
141
+ ```tsx
142
+ const { hits, isLoading, error } = useHits<Movie>();
143
+ ```
144
+
145
+ | Return | Type | Description |
146
+ | ----------- | ------------- | ---------------------------------------------- |
147
+ | `hits` | `Hit<T>[]` | Array of hit objects |
148
+ | `isLoading` | `boolean` | Whether a search is in progress |
149
+ | `error` | `Error\|null` | Error from the last search, if any |
150
+
151
+ Each `Hit<T>` includes the original document fields, an optional `_formatted` object with highlighted values, and a typed `__data: T` accessor.
152
+
153
+ ---
154
+
155
+ ### `useRefinementList(props)`
156
+
157
+ Provides facet filtering for a given attribute. Automatically registers the attribute so the provider includes it in search requests.
158
+
159
+ ```tsx
160
+ const { items, refine, canToggle, isLoading } = useRefinementList({
161
+ attribute: "brand",
162
+ limit: 10,
163
+ sortBy: "count",
164
+ });
165
+ ```
166
+
167
+ | Prop | Type | Default | Description |
168
+ | ----------- | -------------------- | --------- | ------------------------------------ |
169
+ | `attribute` | `string` | -- | Facet attribute (e.g. `"genres"`) |
170
+ | `limit` | `number` | `20` | Max facet values to show |
171
+ | `sortBy` | `"count" \| "alpha"` | `"count"` | Sort order for facet values |
172
+
173
+ | Return | Type | Description |
174
+ | ----------- | ------------------------ | ------------------------------------- |
175
+ | `items` | `RefinementListItem[]` | Facet values with count & state |
176
+ | `refine` | `(value: string) => void`| Toggle a facet value on/off |
177
+ | `canToggle` | `boolean` | Whether any values are available |
178
+ | `isLoading` | `boolean` | Loading state |
179
+
180
+ Each `RefinementListItem` has `{ value: string; count: number; isRefined: boolean }`.
181
+
182
+ ---
183
+
184
+ ### `usePagination()`
185
+
186
+ Pagination state derived from the current search results. Pages are 0-indexed.
187
+
188
+ ```tsx
189
+ const { currentPage, totalPages, totalHits, setPage, canPrevious, canNext, pages } =
190
+ usePagination();
191
+ ```
192
+
193
+ | Return | Type | Description |
194
+ | ------------- | ------------------------- | ------------------------------------ |
195
+ | `currentPage` | `number` | Current page index (0-based) |
196
+ | `totalPages` | `number` | Total number of pages |
197
+ | `totalHits` | `number` | Total result count |
198
+ | `setPage` | `(page: number) => void` | Navigate to a page (clamped) |
199
+ | `canPrevious` | `boolean` | Whether a previous page exists |
200
+ | `canNext` | `boolean` | Whether a next page exists |
201
+ | `pages` | `number[]` | Window of page numbers for rendering |
202
+
203
+ ---
204
+
205
+ ### `useStats()`
206
+
207
+ Returns summary statistics about the current search.
208
+
209
+ ```tsx
210
+ const { totalHits, processingTimeMs, query, isLoading } = useStats();
211
+ ```
212
+
213
+ | Return | Type | Description |
214
+ | ----------------- | --------- | -------------------------------- |
215
+ | `totalHits` | `number` | Total results for the query |
216
+ | `processingTimeMs`| `number` | Server-side processing time (ms) |
217
+ | `query` | `string` | The query that produced results |
218
+ | `isLoading` | `boolean` | Loading state |
219
+
220
+ ---
221
+
222
+ ### `useSortBy(props)`
223
+
224
+ Manages sort-by dropdown state.
225
+
226
+ ```tsx
227
+ const { currentSort, setSort, items } = useSortBy({
228
+ items: [
229
+ { value: "price:asc", label: "Price (low to high)" },
230
+ { value: "price:desc", label: "Price (high to low)" },
231
+ { value: "rating:desc", label: "Top rated" },
232
+ ],
233
+ });
234
+ ```
235
+
236
+ | Prop | Type | Description |
237
+ | ------- | -------------- | ----------------------------------------- |
238
+ | `items` | `SortByItem[]` | Available sort options |
239
+
240
+ Each `SortByItem` has `{ value: string; label: string }`.
241
+
242
+ | Return | Type | Description |
243
+ | ------------- | --------------------------------- | ------------------------------ |
244
+ | `currentSort` | `string \| null` | Active sort value (null = relevance) |
245
+ | `setSort` | `(value: string\|null) => void` | Set the sort order |
246
+ | `items` | `(SortByItem & { isSelected })[]` | Items with `isSelected` flag |
247
+
248
+ ---
249
+
250
+ ### `useRange(props)`
251
+
252
+ Numeric range filter for a given attribute.
253
+
254
+ ```tsx
255
+ const { min, max, stats, setRange, clear, isLoading } = useRange({
256
+ attribute: "price",
257
+ });
258
+ ```
259
+
260
+ | Prop | Type | Description |
261
+ | ----------- | -------- | ------------------------------------- |
262
+ | `attribute` | `string` | Numeric attribute (e.g. `"price"`) |
263
+
264
+ | Return | Type | Description |
265
+ | ----------- | ------------------------------------------ | -------------------------------- |
266
+ | `min` | `number \| undefined` | Current min bound |
267
+ | `max` | `number \| undefined` | Current max bound |
268
+ | `stats` | `{ min: number; max: number } \| undefined`| Overall min/max from engine |
269
+ | `setRange` | `(range: { min?: number; max?: number }) => void` | Set range filter |
270
+ | `clear` | `() => void` | Remove the range filter |
271
+ | `isLoading` | `boolean` | Loading state |
272
+
273
+ ---
274
+
275
+ ### `useHighlight(props)`
276
+
277
+ Extracts the highlighted value for a specific attribute from a hit. Sanitizes the HTML to only allow `<em>` tags for safety.
278
+
279
+ ```tsx
280
+ const { value, highlighted } = useHighlight({
281
+ hit: someHit,
282
+ attribute: "title",
283
+ });
284
+ ```
285
+
286
+ | Prop | Type | Description |
287
+ | ----------- | ------------------------ | ---------------------------------- |
288
+ | `hit` | `Record<string, unknown>`| The hit object from `useHits()` |
289
+ | `attribute` | `string` | Attribute to highlight |
290
+
291
+ | Return | Type | Description |
292
+ | ------------- | --------- | ---------------------------------------------- |
293
+ | `value` | `string` | Highlighted HTML string (safe for innerHTML) |
294
+ | `highlighted` | `boolean` | Whether the value contains highlight marks |
295
+
296
+ Supports nested attributes via dot notation (e.g. `"author.name"`).
297
+
298
+ ---
299
+
300
+ ### `useSearch()`
301
+
302
+ Escape-hatch hook that exposes the full search state and all actions. Prefer the specialized hooks above for most use cases.
303
+
304
+ ```tsx
305
+ const {
306
+ query, results, isLoading, error, refinements, ranges, sortBy, page,
307
+ hitsPerPage, facets, setQuery, setPage, setSortBy, setHitsPerPage,
308
+ toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet,
309
+ refresh,
310
+ } = useSearch();
311
+ ```
312
+
313
+ ---
314
+
315
+ ## Components
316
+
317
+ All components accept a `styled` prop (default `true`) that toggles built-in Tailwind CSS classes. Set `styled={false}` to bring your own styles.
318
+
319
+ ### `<SearchBox>`
320
+
321
+ Search input with built-in clear button and search icon.
322
+
323
+ ```tsx
324
+ <SearchBox
325
+ placeholder="Search products..."
326
+ autoFocus
327
+ onQueryChange={(q) => console.log(q)}
328
+ />
329
+ ```
330
+
331
+ | Prop | Type | Default | Description |
332
+ | --------------- | --------------------------- | -------------- | ---------------------------- |
333
+ | `placeholder` | `string` | `"Search..."` | Input placeholder |
334
+ | `className` | `string` | -- | Additional CSS classes |
335
+ | `autoFocus` | `boolean` | `false` | Auto-focus on mount |
336
+ | `onQueryChange` | `(query: string) => void` | -- | Called on every query change |
337
+ | `styled` | `boolean` | `true` | Enable built-in styles |
338
+
339
+ ---
340
+
341
+ ### `<Hits>`
342
+
343
+ Renders search result hits in a grid layout.
344
+
345
+ ```tsx
346
+ <Hits hitComponent={ProductCard} emptyComponent={<p>No results.</p>} />
347
+ ```
348
+
349
+ | Prop | Type | Default | Description |
350
+ | ---------------- | ------------------------------- | -------------- | ------------------------------ |
351
+ | `hitComponent` | `ComponentType<{ hit: Hit }>` | JSON card | Custom component per hit |
352
+ | `className` | `string` | -- | CSS classes for the container |
353
+ | `emptyComponent` | `ReactNode` | "No results" | Shown when there are no hits |
354
+ | `styled` | `boolean` | `true` | Enable built-in styles |
355
+
356
+ ---
357
+
358
+ ### `<RefinementList>`
359
+
360
+ Checkbox list of facet values with counts.
361
+
362
+ ```tsx
363
+ <RefinementList attribute="brand" header="Brand" limit={10} sortBy="count" />
364
+ ```
365
+
366
+ | Prop | Type | Default | Description |
367
+ | ----------- | -------------------- | --------- | -------------------------------- |
368
+ | `attribute` | `string` | -- | Facet attribute |
369
+ | `limit` | `number` | `20` | Max values to show |
370
+ | `sortBy` | `"count" \| "alpha"` | `"count"` | Value sort order |
371
+ | `header` | `string` | -- | Optional heading above the list |
372
+ | `className` | `string` | -- | Additional CSS classes |
373
+ | `styled` | `boolean` | `true` | Enable built-in styles |
374
+
375
+ ---
376
+
377
+ ### `<Pagination>`
378
+
379
+ Page navigation with Previous/Next buttons and numbered pages.
380
+
381
+ ```tsx
382
+ <Pagination />
383
+ ```
384
+
385
+ | Prop | Type | Default | Description |
386
+ | ----------- | --------- | ------- | ------------------------ |
387
+ | `className` | `string` | -- | Additional CSS classes |
388
+ | `styled` | `boolean` | `true` | Enable built-in styles |
389
+
390
+ ---
391
+
392
+ ### `<Stats>`
393
+
394
+ Displays result count and processing time (e.g. "1,234 results in 3ms").
395
+
396
+ ```tsx
397
+ <Stats />
398
+ <Stats render={({ totalHits, processingTimeMs }) =>
399
+ `Found ${totalHits} items (${processingTimeMs}ms)`
400
+ } />
401
+ ```
402
+
403
+ | Prop | Type | Default | Description |
404
+ | ----------- | ------------------------------------------------------- | ------- | ------------------------- |
405
+ | `render` | `(stats: { totalHits; processingTimeMs; query }) => string` | -- | Custom render function |
406
+ | `className` | `string` | -- | Additional CSS classes |
407
+ | `styled` | `boolean` | `true` | Enable built-in styles |
408
+
409
+ ---
410
+
411
+ ### `<SortBy>`
412
+
413
+ Sort-by select dropdown.
414
+
415
+ ```tsx
416
+ <SortBy
417
+ items={[
418
+ { value: "price:asc", label: "Price (low to high)" },
419
+ { value: "price:desc", label: "Price (high to low)" },
420
+ ]}
421
+ />
422
+ ```
423
+
424
+ | Prop | Type | Default | Description |
425
+ | ----------- | -------------- | ----------- | -------------------------- |
426
+ | `items` | `SortByItem[]` | -- | Sort options |
427
+ | `label` | `string` | `"Sort by"` | Accessibility label |
428
+ | `className` | `string` | -- | Additional CSS classes |
429
+ | `styled` | `boolean` | `true` | Enable built-in styles |
430
+
431
+ ---
432
+
433
+ ### `<Highlight>`
434
+
435
+ Renders highlighted text for a hit attribute. Safe for use with `dangerouslySetInnerHTML` -- all HTML except `<em>` tags is stripped and escaped.
436
+
437
+ ```tsx
438
+ <Highlight hit={hit} attribute="title" />
439
+ ```
440
+
441
+ | Prop | Type | Default | Description |
442
+ | ----------- | ------------------------- | -------- | ----------------------------- |
443
+ | `hit` | `Record<string, unknown>` | -- | Hit object from `useHits()` |
444
+ | `attribute` | `string` | -- | Attribute to highlight |
445
+ | `tagName` | `keyof HTMLElementTagNameMap` | `"span"` | Wrapper element tag |
446
+ | `className` | `string` | -- | Additional CSS classes |
447
+ | `styled` | `boolean` | `true` | Enable built-in styles |
448
+
449
+ ---
450
+
451
+ ### `<RangeInput>`
452
+
453
+ Min/max number inputs for numeric range filtering.
454
+
455
+ ```tsx
456
+ <RangeInput attribute="price" header="Price" />
457
+ ```
458
+
459
+ | Prop | Type | Default | Description |
460
+ | ---------------- | --------- | ------- | ----------------------------- |
461
+ | `attribute` | `string` | -- | Numeric attribute to filter |
462
+ | `header` | `string` | -- | Optional heading |
463
+ | `minPlaceholder` | `string` | `"Min"` | Min input placeholder |
464
+ | `maxPlaceholder` | `string` | `"Max"` | Max input placeholder |
465
+ | `className` | `string` | -- | Additional CSS classes |
466
+ | `styled` | `boolean` | `true` | Enable built-in styles |
467
+
468
+ ---
469
+
470
+ ### `<PoweredBy>`
471
+
472
+ "Powered by Headband" badge with link.
473
+
474
+ ```tsx
475
+ <PoweredBy />
476
+ ```
477
+
478
+ | Prop | Type | Default | Description |
479
+ | ----------- | --------- | ------------------------ | ------------------------ |
480
+ | `href` | `string` | `"https://headband.dev"` | Link URL |
481
+ | `className` | `string` | -- | Additional CSS classes |
482
+ | `styled` | `boolean` | `true` | Enable built-in styles |
483
+
484
+ ---
485
+
486
+ ## Comparison to Algolia InstantSearch
487
+
488
+ | Feature | @headband/react | react-instantsearch |
489
+ | --------------------- | ---------------------------- | ---------------------------- |
490
+ | Provider | `<HeadbandProvider>` | `<InstantSearch>` |
491
+ | Search input | `<SearchBox>` / `useSearchBox` | `<SearchBox>` / `useSearchBox` |
492
+ | Results | `<Hits>` / `useHits` | `<Hits>` / `useHits` |
493
+ | Facets | `<RefinementList>` | `<RefinementList>` |
494
+ | Pagination | `<Pagination>` | `<Pagination>` |
495
+ | Stats | `<Stats>` | `<Stats>` |
496
+ | Sort | `<SortBy>` | `<SortBy>` |
497
+ | Range | `<RangeInput>` / `useRange` | `<RangeInput>` / `useRange` |
498
+ | Highlighting | `<Highlight>` | `<Highlight>` |
499
+ | Bundle size | ~33 KB (ESM, unminified) | ~120 KB+ |
500
+ | Backend lock-in | None (Headband is engine-agnostic) | Algolia only |
501
+ | Built-in styles | Tailwind (opt-out with `styled={false}`) | CSS classes |
502
+
503
+ ## TypeScript
504
+
505
+ All hooks, components, and types are fully typed and exported. Import types directly:
506
+
507
+ ```ts
508
+ import type {
509
+ HeadbandClient,
510
+ SearchParams,
511
+ SearchResponse,
512
+ Hit,
513
+ RefinementListItem,
514
+ SortByItem,
515
+ } from "@headband/react";
516
+ ```
517
+
518
+ ## License
519
+
520
+ MIT