@etoile-dev/react 0.2.3 → 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 -206
  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 +9 -5
  55. package/dist/Search.d.ts +0 -39
  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,330 +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
- | `debounceMs` | `number` | | `100` |
227
- | `renderResult` | `(result: SearchResultData) => React.ReactNode` | | |
318
+ | Prop | Type | Default |
319
+ | ------------- | --------- | ------- |
320
+ | `placeholder` | `string` | |
321
+ | `asChild` | `boolean` | `false` |
228
322
 
229
323
  ---
230
324
 
231
- ### `<SearchRoot>`
325
+ ### `<Searchbar.ModalInput />`
232
326
 
233
- Context provider that manages search state and keyboard navigation.
327
+ Pre-composed modal input row.
234
328
 
235
- | Prop | Type | Required | Default |
236
- |---------------|-------------------|----------|---------|
237
- | `apiKey` | `string` | | |
238
- | `collections` | `string[]` | | |
239
- | `limit` | `number` | | `10` |
240
- | `debounceMs` | `number` | | `100` |
241
- | `autoFocus` | `boolean` | | `false` |
242
- | `children` | `React.ReactNode` | ✓ | |
329
+ | Prop | Type | Default |
330
+ | ------------- | ------------------- | ----------- |
331
+ | `placeholder` | `string` | `"Search…"` |
332
+ | `icon` | `ReactNode \| null` | `<Icon />` |
333
+ | `kbd` | `ReactNode \| null` | `"Esc"` |
243
334
 
244
335
  ---
245
336
 
246
- ### `<SearchInput>`
337
+ ### `<Searchbar.List />`
247
338
 
248
- Controlled input with ARIA combobox role.
339
+ Container with `role="listbox"`. Renders when open. In modal mode, it hides when
340
+ query is empty.
249
341
 
250
- | Prop | Type |
251
- |---------------|----------|
252
- | `placeholder` | `string` |
253
- | `className` | `string` |
342
+ | Prop | Type | Default |
343
+ | --------- | --------- | ------- |
344
+ | `asChild` | `boolean` | `false` |
254
345
 
255
- **Keyboard shortcuts:**
256
- - `ArrowUp` / `ArrowDown` — Navigate results
257
- - `Enter` — Select active result
258
- - `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`
259
359
 
260
360
  ---
261
361
 
262
- ### `<SearchResults>`
362
+ ### `<Searchbar.Group />`
363
+
364
+ | Prop | Type |
365
+ | ------- | -------- |
366
+ | `label` | `string` |
263
367
 
264
- Results container with ARIA listbox role.
368
+ ---
265
369
 
266
- | Prop | Type | Required |
267
- |-------------|-----------------------------------------------|----------|
268
- | `className` | `string` | |
269
- | `children` | `(result: SearchResultData) => React.ReactNode` | ✓ |
370
+ ### `<Searchbar.Separator />`
371
+
372
+ Visual separator (`role="separator"`).
270
373
 
271
374
  ---
272
375
 
273
- ### `<SearchResult>`
376
+ ### `<Searchbar.Empty />`
377
+
378
+ Renders when: list is open, query is non-empty, no items match, and not loading.
274
379
 
275
- Individual result with ARIA option role.
380
+ ---
276
381
 
277
- | Prop | Type | Required |
278
- |-------------|-------------------|----------|
279
- | `className` | `string` | |
280
- | `children` | `React.ReactNode` | ✓ |
382
+ ### `<Searchbar.Loading />`
281
383
 
282
- **Data attributes:**
283
- - `data-selected="true" | "false"` — Active state
284
- - `data-index="number"` — Result position
384
+ Renders when `isLoading={true}` is passed to `Searchbar.Root`.
285
385
 
286
386
  ---
287
387
 
288
- ### `<SearchResultThumbnail>`
388
+ ### `<Searchbar.Error />`
289
389
 
290
- Thumbnail image that auto-detects from `metadata.thumbnailUrl`.
390
+ Renders when `error` is set on `Searchbar.Root`. Accepts a render function:
291
391
 
292
- | Prop | Type | Required | Default |
293
- |-------------|----------|----------|-------------------------------|
294
- | `src` | `string` | | `metadata.thumbnailUrl` |
295
- | `alt` | `string` | | `result.title` |
296
- | `size` | `number` | | `40` |
297
- | `className` | `string` | | |
392
+ ```tsx
393
+ <Searchbar.Error>{(err) => `Search failed: ${String(err)}`}</Searchbar.Error>
394
+ ```
298
395
 
299
396
  ---
300
397
 
301
- ### `<SearchIcon>`
398
+ ### `<Searchbar.Portal />`
399
+
400
+ Portals children to `document.body` (or a custom `container`).
302
401
 
303
- Built-in search magnifying glass SVG icon.
402
+ ---
304
403
 
305
- | Prop | Type | Required | Default |
306
- |-------------|----------|----------|---------|
307
- | `size` | `number` | | `18` |
308
- | `className` | `string` | | |
404
+ ### `<Searchbar.Overlay />`
405
+
406
+ Backdrop for modal/palette mode. Renders when open.
309
407
 
310
408
  ---
311
409
 
312
- ### `<SearchKbd>`
410
+ ### `<Searchbar.Content />`
313
411
 
314
- Keyboard shortcut badge.
412
+ Dialog panel for modal/palette mode. Focuses first focusable child on open.
413
+
414
+ ---
415
+
416
+ ### `<Searchbar.Trigger />`
315
417
 
316
- | Prop | Type | Required | Default |
317
- |-------------|-------------------|----------|---------|
318
- | `children` | `React.ReactNode` | | `⌘K` |
319
- | `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.
320
435
 
321
436
  ```tsx
322
- <SearchKbd /> // Shows "⌘K"
323
- <SearchKbd>/</SearchKbd> // Shows "/"
437
+ <Searchbar.Kbd /> // "⌘K"
438
+ <Searchbar.Kbd>/</Searchbar.Kbd>
324
439
  ```
325
440
 
326
441
  ---
327
442
 
328
- ### `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()`
329
457
 
330
- Headless hook for complete control.
458
+ Access store helpers from any component inside `Searchbar.Root`.
331
459
 
332
- **Options:**
460
+ ```tsx
461
+ import { useSearchbarContext, useSearchbarStore } from "@etoile-dev/react";
333
462
 
334
- | Field | Type | Required | Default |
335
- |---------------|------------|----------|---------|
336
- | `apiKey` | `string` | ✓ | |
337
- | `collections` | `string[]` | ✓ | |
338
- | `limit` | `number` | | `10` |
339
- | `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
+ ```
340
469
 
341
- **Returns:**
470
+ ---
471
+
472
+ ### `useEtoileSearch(options)`
342
473
 
343
- | Field | Type |
344
- |--------------------|----------------------------|
345
- | `query` | `string` |
346
- | `setQuery` | `(q: string) => void` |
347
- | `results` | `SearchResultData[]` |
348
- | `isLoading` | `boolean` |
349
- | `selectedIndex` | `number` |
350
- | `setSelectedIndex` | `(i: number) => void` |
351
- | `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`.
352
488
 
353
489
  ---
354
490
 
355
491
  ## Types
356
492
 
357
493
  ```ts
358
- type SearchResultData = {
359
- external_id: string;
360
- title: string;
361
- collection: string;
362
- score: number;
363
- content?: string;
364
- metadata: Record<string, unknown>;
365
- };
494
+ import type {
495
+ SearchResult,
496
+ SearchFilter,
497
+ FilterOperator,
498
+ } from "@etoile-dev/react";
366
499
  ```
367
500
 
501
+ `SearchResultData` remains exported as a deprecated alias.
502
+
368
503
  ---
369
504
 
370
505
  ## Why @etoile-dev/react?
371
506
 
372
- - **Radix / shadcn-style primitives** — Composable and unstyled
373
- - **Accessibility built-in** — ARIA combobox, keyboard navigation, focus management, click-outside dismiss
374
- - **Behavior, not appearance** — You own the design
375
- - **TypeScript-first** — Full type safety
376
- - **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
377
512
 
378
513
  ---
379
514