@etoile-dev/react 0.2.2 → 1.0.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.
Files changed (74) hide show
  1. package/README.md +341 -205
  2. package/dist/Searchbar.d.ts +315 -0
  3. package/dist/Searchbar.js +207 -0
  4. package/dist/context.d.ts +57 -0
  5. package/dist/context.js +32 -0
  6. package/dist/hooks/useEtoileSearch.d.ts +122 -0
  7. package/dist/hooks/useEtoileSearch.js +138 -0
  8. package/dist/index.d.ts +44 -19
  9. package/dist/index.js +37 -12
  10. package/dist/primitives/Content.d.ts +34 -0
  11. package/dist/primitives/Content.js +108 -0
  12. package/dist/primitives/Empty.d.ts +25 -0
  13. package/dist/primitives/Empty.js +25 -0
  14. package/dist/primitives/Error.d.ts +29 -0
  15. package/dist/primitives/Error.js +26 -0
  16. package/dist/primitives/Group.d.ts +30 -0
  17. package/dist/primitives/Group.js +22 -0
  18. package/dist/primitives/Icon.d.ts +21 -0
  19. package/dist/primitives/Icon.js +14 -0
  20. package/dist/primitives/Input.d.ts +32 -0
  21. package/dist/primitives/Input.js +70 -0
  22. package/dist/primitives/Item.d.ts +61 -0
  23. package/dist/primitives/Item.js +76 -0
  24. package/dist/primitives/Kbd.d.ts +20 -0
  25. package/dist/primitives/Kbd.js +13 -0
  26. package/dist/primitives/List.d.ts +35 -0
  27. package/dist/primitives/List.js +37 -0
  28. package/dist/primitives/Loading.d.ts +25 -0
  29. package/dist/primitives/Loading.js +26 -0
  30. package/dist/primitives/Modal.d.ts +39 -0
  31. package/dist/primitives/Modal.js +37 -0
  32. package/dist/primitives/ModalInput.d.ts +61 -0
  33. package/dist/primitives/ModalInput.js +33 -0
  34. package/dist/primitives/Overlay.d.ts +21 -0
  35. package/dist/primitives/Overlay.js +41 -0
  36. package/dist/primitives/Portal.d.ts +28 -0
  37. package/dist/primitives/Portal.js +30 -0
  38. package/dist/primitives/Root.d.ts +116 -0
  39. package/dist/primitives/Root.js +413 -0
  40. package/dist/primitives/Separator.d.ts +19 -0
  41. package/dist/primitives/Separator.js +18 -0
  42. package/dist/primitives/Thumbnail.d.ts +31 -0
  43. package/dist/primitives/Thumbnail.js +59 -0
  44. package/dist/primitives/Trigger.d.ts +28 -0
  45. package/dist/primitives/Trigger.js +35 -0
  46. package/dist/store.d.ts +38 -0
  47. package/dist/store.js +63 -0
  48. package/dist/styles.css +480 -133
  49. package/dist/types.d.ts +3 -31
  50. package/dist/utils/composeRefs.d.ts +12 -0
  51. package/dist/utils/composeRefs.js +27 -0
  52. package/dist/utils/slot.d.ts +22 -0
  53. package/dist/utils/slot.js +58 -0
  54. package/package.json +8 -4
  55. package/dist/Search.d.ts +0 -37
  56. package/dist/Search.js +0 -31
  57. package/dist/components/SearchIcon.d.ts +0 -22
  58. package/dist/components/SearchIcon.js +0 -17
  59. package/dist/components/SearchInput.d.ts +0 -30
  60. package/dist/components/SearchInput.js +0 -59
  61. package/dist/components/SearchKbd.d.ts +0 -30
  62. package/dist/components/SearchKbd.js +0 -24
  63. package/dist/components/SearchResult.d.ts +0 -31
  64. package/dist/components/SearchResult.js +0 -40
  65. package/dist/components/SearchResultThumbnail.d.ts +0 -38
  66. package/dist/components/SearchResultThumbnail.js +0 -38
  67. package/dist/components/SearchResults.d.ts +0 -39
  68. package/dist/components/SearchResults.js +0 -53
  69. package/dist/components/SearchRoot.d.ts +0 -44
  70. package/dist/components/SearchRoot.js +0 -132
  71. package/dist/context/SearchContext.d.ts +0 -55
  72. package/dist/context/SearchContext.js +0 -36
  73. package/dist/hooks/useSearch.d.ts +0 -56
  74. package/dist/hooks/useSearch.js +0 -116
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center">
2
2
  <a href="https://etoile.dev">
3
- <img src="https://etoile.dev/assets/logo-black.svg" alt="Étoile" height="32" />
3
+ <img src="https://etoile.dev/assets/logo-black.svg" alt="Etoile" height="32" />
4
4
  </a>
5
5
  </p>
6
6
 
@@ -11,7 +11,7 @@
11
11
  <h1 align="center">@etoile-dev/react</h1>
12
12
 
13
13
  <p align="center">
14
- <strong>Headless React primitives for search.</strong>
14
+ <strong>Headless React primitives for search — with Etoile-powered components and hooks.</strong>
15
15
  <br />
16
16
  Composable. Accessible. Zero styling.
17
17
  </p>
@@ -24,19 +24,23 @@
24
24
 
25
25
  ## About
26
26
 
27
- **@etoile-dev/react** provides headless, composable React components for search powered by [Étoile](https://etoile.dev).
27
+ **@etoile-dev/react** is the React SDK for **Etoile**, and also a set of unstyled primitives you can wire to any data source.
28
28
 
29
- Built on top of [@etoile-dev/client](https://www.npmjs.com/package/@etoile-dev/client), these primitives give you full control over styling while handling state, keyboard navigation, and accessibility.
29
+ You get three layers:
30
+
31
+ - **Ready-to-use Etoile components** — `<Searchbar />` and `<SearchModal />`
32
+ - **Etoile data hooks** — `useEtoileSearch` (and `useSearch` alias)
33
+ - **Headless primitives** — `Searchbar.Root`, `Searchbar.Input`, `Searchbar.List`, `Searchbar.Item`, …
30
34
 
31
35
  ---
32
36
 
33
37
  ## Philosophy
34
38
 
35
39
  - **Headless-first** — You control the appearance
36
- - **Composable** — Build your own search UX
37
- - **Accessible** — Full ARIA support and keyboard navigation
38
- - **No magic** — Behavior is predictable and documented
39
- - **No opinions** — Bring your own styles (or use our optional theme)
40
+ - **Composable** — Build any search UX from small primitives
41
+ - **Accessible** — Full ARIA combobox / listbox pattern, keyboard navigation
42
+ - **No magic** — Predictable behavior, clear contracts
43
+ - **Zero style opinions** — Bring your own CSS (or use our optional theme)
40
44
 
41
45
  ---
42
46
 
@@ -50,329 +54,461 @@ npm i @etoile-dev/react
50
54
 
51
55
  ## Quickstart
52
56
 
57
+ The simplest possible usage — just an API key and a collection:
58
+
53
59
  ```tsx
54
- import { Search } from "@etoile-dev/react";
60
+ import "@etoile-dev/react/styles.css";
61
+ import { Searchbar } from "@etoile-dev/react";
55
62
 
56
63
  export default function App() {
57
- return <Search apiKey="your-api-key" collections={["paintings"]} />;
64
+ return <Searchbar apiKey="your-api-key" collections={["paintings"]} />;
58
65
  }
59
66
  ```
60
67
 
68
+ Search multiple collections and handle selection:
69
+
70
+ ```tsx
71
+ <Searchbar
72
+ apiKey={process.env.ETOILE_API_KEY!}
73
+ collections={["paintings", "artists"]}
74
+ limit={10}
75
+ onSelect={(id) => router.push(`/work/${id}`)}
76
+ />
77
+ ```
78
+
61
79
  ---
62
80
 
63
- ## Composable Primitives
81
+ ## Headless primitives
64
82
 
65
- For full control, use the headless primitives:
83
+ Use `Searchbar.Root` and friends for full control with no Etoile dependency:
66
84
 
67
85
  ```tsx
68
- import {
69
- SearchRoot,
70
- SearchInput,
71
- SearchResults,
72
- SearchResult,
73
- } from "@etoile-dev/react";
86
+ import { Searchbar } from "@etoile-dev/react";
74
87
 
75
- export default function CustomSearch() {
88
+ export default function LocalSearch() {
76
89
  return (
77
- <SearchRoot
78
- apiKey={process.env.ETOILE_API_KEY}
79
- collections={["paintings"]}
80
- limit={20}
81
- >
82
- <SearchInput placeholder="Search paintings..." className="search-input" />
83
-
84
- <SearchResults className="results-list">
85
- {(result) => (
86
- <SearchResult className="result-item">
87
- <h3>{result.title}</h3>
88
- <p>{result.metadata.artist}</p>
89
- <small>Score: {result.score.toFixed(2)}</small>
90
- </SearchResult>
91
- )}
92
- </SearchResults>
93
- </SearchRoot>
90
+ <Searchbar.Root onSelect={(id) => router.push(`/paintings/${id}`)}>
91
+ <Searchbar.Input placeholder="Search paintings…" />
92
+ <Searchbar.List>
93
+ {paintings.map((p) => (
94
+ <Searchbar.Item key={p.id} value={p.id} label={p.title}>
95
+ {p.title}
96
+ </Searchbar.Item>
97
+ ))}
98
+ <Searchbar.Empty>No results.</Searchbar.Empty>
99
+ <Searchbar.Loading />
100
+ </Searchbar.List>
101
+ </Searchbar.Root>
94
102
  );
95
103
  }
96
104
  ```
97
105
 
98
- ---
106
+ Primitives are unstyled by default. To opt into the built-in theme while using
107
+ primitives, add `className="etoile-search"` on `Searchbar.Root` and import
108
+ `@etoile-dev/react/styles.css`.
99
109
 
100
- ## Styling with data attributes
110
+ ---
101
111
 
102
- Each result automatically gets `data-selected` and `data-index` attributes:
112
+ ## Custom rendering
103
113
 
104
- ```css
105
- .result-item {
106
- padding: 1rem;
107
- cursor: pointer;
108
- }
109
-
110
- .result-item[data-selected="true"] {
111
- background: #f0f9ff;
112
- border-left: 3px solid #0ea5e9;
113
- }
114
+ ```tsx
115
+ <Searchbar
116
+ apiKey={process.env.ETOILE_API_KEY!}
117
+ collections={["paintings", "artists"]}
118
+ onSelect={(id) => router.push(`/work/${id}`)}
119
+ renderItem={(result) => (
120
+ <Searchbar.Item value={result.external_id} label={result.title}>
121
+ <Searchbar.Thumbnail />
122
+ <div>
123
+ <strong>{result.title}</strong>
124
+ <span>{String(result.metadata?.artist ?? "")}</span>
125
+ </div>
126
+ </Searchbar.Item>
127
+ )}
128
+ />
114
129
  ```
115
130
 
116
131
  ---
117
132
 
118
- ## Default Theme
133
+ ## Command palette / modal mode
119
134
 
120
- Import the optional theme for a polished, ready-to-use experience:
135
+ Use the `<SearchModal />` convenience component for an Etoile-powered palette:
121
136
 
122
137
  ```tsx
123
138
  import "@etoile-dev/react/styles.css";
124
- import { Search } from "@etoile-dev/react";
139
+ import { SearchModal } from "@etoile-dev/react";
125
140
 
126
- <Search apiKey="your-api-key" collections={["paintings"]} />
141
+ <SearchModal apiKey="your-api-key" collections={["paintings"]} />;
127
142
  ```
128
143
 
129
- That's it! The `etoile-search` class is applied automatically.
144
+ Or compose the primitives yourself for full control:
145
+
146
+ ```tsx
147
+ <Searchbar.Root className="etoile-search">
148
+ <Searchbar.Trigger>
149
+ <Searchbar.Icon /> Search
150
+ </Searchbar.Trigger>
151
+
152
+ <Searchbar.Portal>
153
+ <Searchbar.Overlay className="overlay" />
154
+ <Searchbar.Content aria-label="Search paintings" className="palette">
155
+ <Searchbar.Input autoFocus placeholder="Search…" />
156
+ <Searchbar.List>
157
+ {results.map((r) => (
158
+ <Searchbar.Item key={r.id} value={r.id}>
159
+ {r.title}
160
+ </Searchbar.Item>
161
+ ))}
162
+ <Searchbar.Empty>No results.</Searchbar.Empty>
163
+ </Searchbar.List>
164
+ </Searchbar.Content>
165
+ </Searchbar.Portal>
166
+ </Searchbar.Root>
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Styling
172
+
173
+ ### Data attributes
174
+
175
+ All primitives emit `data-*` attributes — no class coupling required:
130
176
 
131
- ### Dark Mode
177
+ ```css
178
+ [role="option"][data-selected="true"] {
179
+ background: #f0f9ff;
180
+ }
181
+
182
+ [role="option"][data-disabled="true"] {
183
+ opacity: 0.4;
184
+ cursor: not-allowed;
185
+ }
186
+
187
+ [data-state="open"] {
188
+ border-color: #3b82f6;
189
+ }
190
+ ```
132
191
 
133
- Add `dark` to the className:
192
+ ### Dark mode
134
193
 
135
194
  ```tsx
136
- <Search apiKey="your-api-key" collections={["paintings"]} className="dark" />
195
+ <Searchbar className="dark" apiKey="your-api-key" collections={["paintings"]} />
196
+ ```
137
197
 
138
- // Or with SearchRoot
139
- <SearchRoot apiKey="your-api-key" collections={["paintings"]} className="dark">
140
- ...
141
- </SearchRoot>
198
+ Or wrap a parent element:
199
+
200
+ ```tsx
201
+ <div className="dark">
202
+ <Searchbar apiKey="your-api-key" collections={["paintings"]} />
203
+ </div>
142
204
  ```
143
205
 
144
- ### CSS Variables
206
+ ### CSS variables
145
207
 
146
- Every value is customizable. Here are the key variables:
208
+ Every value is customizable:
147
209
 
148
210
  ```css
149
211
  .etoile-search {
150
- /* Colors */
151
212
  --etoile-bg: #ffffff;
152
213
  --etoile-border: #e4e4e7;
153
214
  --etoile-text: #09090b;
154
215
  --etoile-text-muted: #71717a;
155
216
  --etoile-selected: #f4f4f5;
156
- --etoile-ring: #18181b;
157
-
158
- /* Sizing */
159
217
  --etoile-radius: 12px;
160
218
  --etoile-input-height: 44px;
161
- --etoile-thumbnail-size: 40px;
162
- --etoile-results-max-height: 300px;
219
+ }
220
+ ```
163
221
 
164
- /* Spacing */
165
- --etoile-input-padding-x: 16px;
166
- --etoile-result-gap: 16px;
167
- --etoile-results-offset: 8px;
222
+ See `styles.css` for all 40+ variables.
168
223
 
169
- /* Typography */
170
- --etoile-font-size-input: 15px;
171
- --etoile-font-size-title: 14px;
224
+ ---
172
225
 
173
- /* Animation */
174
- --etoile-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
175
- }
176
- ```
226
+ ## API
227
+
228
+ ### `<Searchbar />`
229
+
230
+ Ready-to-use search component powered by Etoile.
231
+
232
+ | Prop | Type | Default |
233
+ | ------------- | ------------------------------------- | ----------------- |
234
+ | `apiKey` | `string` | **required** |
235
+ | `collections` | `string[]` | **required** |
236
+ | `limit` | `number` | `10` |
237
+ | `offset` | `number` | `0` (API default) |
238
+ | `debounceMs` | `number` | `100` |
239
+ | `placeholder` | `string` | `"Search…"` |
240
+ | `filters` | `SearchFilter[]` | |
241
+ | `autoFilters` | `boolean` | |
242
+ | `renderItem` | `(result: SearchResult) => ReactNode` | |
243
+ | `onSelect` | `(value: string) => void` | |
244
+ | `hotkey` | `string` | |
245
+ | `className` | `string` | `"etoile-search"` |
177
246
 
178
- See `styles.css` for the complete list of 40+ variables.
247
+ Also supports non-state DOM/behavior props from `Searchbar.Root`.
179
248
 
180
249
  ---
181
250
 
182
- ## Headless hook
251
+ ### `<SearchModal />`
252
+
253
+ Ready-to-use command palette powered by Etoile.
254
+
255
+ | Prop | Type | Default |
256
+ | ------------- | ------------------------------------- | ----------------- |
257
+ | `apiKey` | `string` | **required** |
258
+ | `collections` | `string[]` | **required** |
259
+ | `limit` | `number` | `10` |
260
+ | `offset` | `number` | `0` (API default) |
261
+ | `debounceMs` | `number` | `100` |
262
+ | `placeholder` | `string` | `"Search…"` |
263
+ | `filters` | `SearchFilter[]` | |
264
+ | `autoFilters` | `boolean` | |
265
+ | `hotkey` | `string` | `"mod+k"` |
266
+ | `modalLabel` | `string` | `"Search"` |
267
+ | `renderItem` | `(result: SearchResult) => ReactNode` | |
268
+ | `onSelect` | `(value: string) => void` | |
269
+ | `className` | `string` | `"etoile-search"` |
183
270
 
184
- For complete control, use the `useSearch` hook:
271
+ ---
185
272
 
186
- ```tsx
187
- import { useSearch } from "@etoile-dev/react";
273
+ ### `<Searchbar.Root />`
274
+
275
+ Context provider and state machine for headless usage.
276
+
277
+ | Prop | Type | Default |
278
+ | ---------------- | --------------------------------- | ---------- |
279
+ | `open` | `boolean` | |
280
+ | `defaultOpen` | `boolean` | `false` |
281
+ | `onOpenChange` | `(open: boolean) => void` | |
282
+ | `search` | `string` | |
283
+ | `defaultSearch` | `string` | `""` |
284
+ | `onSearchChange` | `(search: string) => void` | |
285
+ | `value` | `string \| null` | |
286
+ | `defaultValue` | `string \| null` | `null` |
287
+ | `onValueChange` | `(value: string \| null) => void` | |
288
+ | `isLoading` | `boolean` | `false` |
289
+ | `error` | `unknown` | |
290
+ | `hotkey` | `string` | |
291
+ | `hotkeyBehavior` | `"focus" \| "toggle"` | `"toggle"` |
292
+ | `onSelect` | `(value: string) => void` | |
293
+ | `className` | `string` | |
294
+ | `asChild` | `boolean` | `false` |
188
295
 
189
- function MyCustomSearch() {
190
- const { query, setQuery, results, isLoading } = useSearch({
191
- apiKey: "your-api-key",
192
- collections: ["paintings"],
193
- });
296
+ **Keyboard shortcuts:**
194
297
 
195
- return (
196
- <div>
197
- <input
198
- value={query}
199
- onChange={(e) => setQuery(e.target.value)}
200
- placeholder="Search paintings..."
201
- />
202
- {isLoading && <p>Loading...</p>}
203
- <ul>
204
- {results.map((result) => (
205
- <li key={result.external_id}>{result.title}</li>
206
- ))}
207
- </ul>
208
- </div>
209
- );
210
- }
211
- ```
298
+ - `↑` / `↓` — Navigate items
299
+ - `Enter` — Select active item
300
+ - `Escape` — Close list
212
301
 
213
302
  ---
214
303
 
215
- ## API
304
+ ### `<Searchbar.Modal />`
305
+
306
+ Headless modal primitive (`Root + Portal + Overlay + Content`).
216
307
 
217
- ### `<Search>`
308
+ | Prop | Type | Default |
309
+ | ------------ | -------- | ---------- |
310
+ | `aria-label` | `string` | `"Search"` |
311
+
312
+ Also accepts `Searchbar.Root` props.
313
+
314
+ ---
218
315
 
219
- Convenience component that composes all primitives.
316
+ ### `<Searchbar.Input />`
220
317
 
221
- | Prop | Type | Required | Default |
222
- |----------------|-----------------------------------------------|----------|---------|
223
- | `apiKey` | `string` | ✓ | |
224
- | `collections` | `string[]` | | |
225
- | `limit` | `number` | | `10` |
226
- | `renderResult` | `(result: SearchResultData) => React.ReactNode` | | |
318
+ | Prop | Type | Default |
319
+ | ------------- | --------- | ------- |
320
+ | `placeholder` | `string` | |
321
+ | `asChild` | `boolean` | `false` |
227
322
 
228
323
  ---
229
324
 
230
- ### `<SearchRoot>`
325
+ ### `<Searchbar.ModalInput />`
231
326
 
232
- Context provider that manages search state and keyboard navigation.
327
+ Pre-composed modal input row.
233
328
 
234
- | Prop | Type | Required | Default |
235
- |---------------|-------------------|----------|---------|
236
- | `apiKey` | `string` | | |
237
- | `collections` | `string[]` | | |
238
- | `limit` | `number` | | `10` |
239
- | `debounceMs` | `number` | | `100` |
240
- | `autoFocus` | `boolean` | | `false` |
241
- | `children` | `React.ReactNode` | ✓ | |
329
+ | Prop | Type | Default |
330
+ | ------------- | ------------------- | ----------- |
331
+ | `placeholder` | `string` | `"Search…"` |
332
+ | `icon` | `ReactNode \| null` | `<Icon />` |
333
+ | `kbd` | `ReactNode \| null` | `"Esc"` |
242
334
 
243
335
  ---
244
336
 
245
- ### `<SearchInput>`
337
+ ### `<Searchbar.List />`
246
338
 
247
- Controlled input with ARIA combobox role.
339
+ Container with `role="listbox"`. Renders when open. In modal mode, it hides when
340
+ query is empty.
248
341
 
249
- | Prop | Type |
250
- |---------------|----------|
251
- | `placeholder` | `string` |
252
- | `className` | `string` |
342
+ | Prop | Type | Default |
343
+ | --------- | --------- | ------- |
344
+ | `asChild` | `boolean` | `false` |
253
345
 
254
- **Keyboard shortcuts:**
255
- - `ArrowUp` / `ArrowDown` — Navigate results
256
- - `Enter` — Select active result
257
- - `Escape` — Close results (press again to clear)
346
+ ---
347
+
348
+ ### `<Searchbar.Item />`
349
+
350
+ | Prop | Type | Default |
351
+ | ---------- | ------------------------- | -------- |
352
+ | `value` | `string` | required |
353
+ | `label` | `string` | `value` |
354
+ | `disabled` | `boolean` | `false` |
355
+ | `onSelect` | `(value: string) => void` | |
356
+ | `asChild` | `boolean` | `false` |
357
+
358
+ **Data attributes:** `data-selected`, `data-disabled`, `data-value`
258
359
 
259
360
  ---
260
361
 
261
- ### `<SearchResults>`
362
+ ### `<Searchbar.Group />`
363
+
364
+ | Prop | Type |
365
+ | ------- | -------- |
366
+ | `label` | `string` |
262
367
 
263
- Results container with ARIA listbox role.
368
+ ---
264
369
 
265
- | Prop | Type | Required |
266
- |-------------|-----------------------------------------------|----------|
267
- | `className` | `string` | |
268
- | `children` | `(result: SearchResultData) => React.ReactNode` | ✓ |
370
+ ### `<Searchbar.Separator />`
371
+
372
+ Visual separator (`role="separator"`).
269
373
 
270
374
  ---
271
375
 
272
- ### `<SearchResult>`
376
+ ### `<Searchbar.Empty />`
377
+
378
+ Renders when: list is open, query is non-empty, no items match, and not loading.
273
379
 
274
- Individual result with ARIA option role.
380
+ ---
275
381
 
276
- | Prop | Type | Required |
277
- |-------------|-------------------|----------|
278
- | `className` | `string` | |
279
- | `children` | `React.ReactNode` | ✓ |
382
+ ### `<Searchbar.Loading />`
280
383
 
281
- **Data attributes:**
282
- - `data-selected="true" | "false"` — Active state
283
- - `data-index="number"` — Result position
384
+ Renders when `isLoading={true}` is passed to `Searchbar.Root`.
284
385
 
285
386
  ---
286
387
 
287
- ### `<SearchResultThumbnail>`
388
+ ### `<Searchbar.Error />`
288
389
 
289
- Thumbnail image that auto-detects from `metadata.thumbnailUrl`.
390
+ Renders when `error` is set on `Searchbar.Root`. Accepts a render function:
290
391
 
291
- | Prop | Type | Required | Default |
292
- |-------------|----------|----------|-------------------------------|
293
- | `src` | `string` | | `metadata.thumbnailUrl` |
294
- | `alt` | `string` | | `result.title` |
295
- | `size` | `number` | | `40` |
296
- | `className` | `string` | | |
392
+ ```tsx
393
+ <Searchbar.Error>{(err) => `Search failed: ${String(err)}`}</Searchbar.Error>
394
+ ```
297
395
 
298
396
  ---
299
397
 
300
- ### `<SearchIcon>`
398
+ ### `<Searchbar.Portal />`
399
+
400
+ Portals children to `document.body` (or a custom `container`).
301
401
 
302
- Built-in search magnifying glass SVG icon.
402
+ ---
303
403
 
304
- | Prop | Type | Required | Default |
305
- |-------------|----------|----------|---------|
306
- | `size` | `number` | | `18` |
307
- | `className` | `string` | | |
404
+ ### `<Searchbar.Overlay />`
405
+
406
+ Backdrop for modal/palette mode. Renders when open.
308
407
 
309
408
  ---
310
409
 
311
- ### `<SearchKbd>`
410
+ ### `<Searchbar.Content />`
312
411
 
313
- Keyboard shortcut badge.
412
+ Dialog panel for modal/palette mode. Focuses first focusable child on open.
413
+
414
+ ---
415
+
416
+ ### `<Searchbar.Trigger />`
314
417
 
315
- | Prop | Type | Required | Default |
316
- |-------------|-------------------|----------|---------|
317
- | `children` | `React.ReactNode` | | `⌘K` |
318
- | `className` | `string` | | `etoile-kbd` |
418
+ Button that toggles open state.
419
+
420
+ ---
421
+
422
+ ### `<Searchbar.Icon />`
423
+
424
+ Search magnifying glass SVG icon.
425
+
426
+ | Prop | Type | Default |
427
+ | ------ | -------- | ------- |
428
+ | `size` | `number` | `18` |
429
+
430
+ ---
431
+
432
+ ### `<Searchbar.Kbd />`
433
+
434
+ Keyboard shortcut badge.
319
435
 
320
436
  ```tsx
321
- <SearchKbd /> // Shows "⌘K"
322
- <SearchKbd>/</SearchKbd> // Shows "/"
437
+ <Searchbar.Kbd /> // "⌘K"
438
+ <Searchbar.Kbd>/</Searchbar.Kbd>
323
439
  ```
324
440
 
325
441
  ---
326
442
 
327
- ### `useSearch(options)`
443
+ ### `<Searchbar.Thumbnail />`
444
+
445
+ Thumbnail image. Auto-reads `metadata.thumbnailUrl` from item context when
446
+ used inside the Etoile `<Searchbar />` wrapper.
447
+
448
+ | Prop | Type | Default |
449
+ | ------ | -------- | ------------ |
450
+ | `src` | `string` | from context |
451
+ | `alt` | `string` | item title |
452
+ | `size` | `number` | `40` |
453
+
454
+ ---
455
+
456
+ ### `useSearchbarContext()`
328
457
 
329
- Headless hook for complete control.
458
+ Access store helpers from any component inside `Searchbar.Root`.
330
459
 
331
- **Options:**
460
+ ```tsx
461
+ import { useSearchbarContext, useSearchbarStore } from "@etoile-dev/react";
332
462
 
333
- | Field | Type | Required | Default |
334
- |---------------|------------|----------|---------|
335
- | `apiKey` | `string` | ✓ | |
336
- | `collections` | `string[]` | ✓ | |
337
- | `limit` | `number` | | `10` |
338
- | `debounceMs` | `number` | | `100` |
463
+ function QueryDisplay() {
464
+ const { store } = useSearchbarContext();
465
+ const query = useSearchbarStore(store, (s) => s.query);
466
+ return <span>{query}</span>;
467
+ }
468
+ ```
339
469
 
340
- **Returns:**
470
+ ---
471
+
472
+ ### `useEtoileSearch(options)`
341
473
 
342
- | Field | Type |
343
- |--------------------|----------------------------|
344
- | `query` | `string` |
345
- | `setQuery` | `(q: string) => void` |
346
- | `results` | `SearchResultData[]` |
347
- | `isLoading` | `boolean` |
348
- | `selectedIndex` | `number` |
349
- | `setSelectedIndex` | `(i: number) => void` |
350
- | `clear` | `() => void` |
474
+ Headless data hook for live Etoile search.
475
+
476
+ ```tsx
477
+ import { useEtoileSearch } from "@etoile-dev/react";
478
+
479
+ const [query, setQuery] = useState("");
480
+ const { results, isLoading } = useEtoileSearch({
481
+ apiKey: process.env.ETOILE_API_KEY!,
482
+ collections: ["paintings"],
483
+ query,
484
+ });
485
+ ```
486
+
487
+ Returns `results`, `isLoading`, `error`, `appliedFilters`, and `refinedQuery`.
351
488
 
352
489
  ---
353
490
 
354
491
  ## Types
355
492
 
356
493
  ```ts
357
- type SearchResultData = {
358
- external_id: string;
359
- title: string;
360
- collection: string;
361
- score: number;
362
- content?: string;
363
- metadata: Record<string, unknown>;
364
- };
494
+ import type {
495
+ SearchResult,
496
+ SearchFilter,
497
+ FilterOperator,
498
+ } from "@etoile-dev/react";
365
499
  ```
366
500
 
501
+ `SearchResultData` remains exported as a deprecated alias.
502
+
367
503
  ---
368
504
 
369
505
  ## Why @etoile-dev/react?
370
506
 
371
- - **Radix / shadcn-style primitives** — Composable and unstyled
372
- - **Accessibility built-in** — ARIA combobox, keyboard navigation, focus management, click-outside dismiss
373
- - **Behavior, not appearance** — You own the design
374
- - **TypeScript-first** — Full type safety
375
- - **Zero dependencies** — Only React and @etoile-dev/client
507
+ - **Fast by default** — only the components that depend on the query re-render, not the whole tree
508
+ - **Stable selection** — items are identified by value, not by index
509
+ - **Controlled or uncontrolled** — every stateful prop supports both patterns
510
+ - **Composable** — render any element as any primitive with `asChild`
511
+ - **No opinions** — primitives emit `data-*` attributes and inject no theme classes
376
512
 
377
513
  ---
378
514