@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.
- package/README.md +341 -206
- package/dist/Searchbar.d.ts +315 -0
- package/dist/Searchbar.js +207 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +32 -0
- package/dist/hooks/useEtoileSearch.d.ts +122 -0
- package/dist/hooks/useEtoileSearch.js +138 -0
- package/dist/index.d.ts +44 -19
- package/dist/index.js +37 -12
- package/dist/primitives/Content.d.ts +34 -0
- package/dist/primitives/Content.js +108 -0
- package/dist/primitives/Empty.d.ts +25 -0
- package/dist/primitives/Empty.js +25 -0
- package/dist/primitives/Error.d.ts +29 -0
- package/dist/primitives/Error.js +26 -0
- package/dist/primitives/Group.d.ts +30 -0
- package/dist/primitives/Group.js +22 -0
- package/dist/primitives/Icon.d.ts +21 -0
- package/dist/primitives/Icon.js +14 -0
- package/dist/primitives/Input.d.ts +32 -0
- package/dist/primitives/Input.js +70 -0
- package/dist/primitives/Item.d.ts +61 -0
- package/dist/primitives/Item.js +76 -0
- package/dist/primitives/Kbd.d.ts +20 -0
- package/dist/primitives/Kbd.js +13 -0
- package/dist/primitives/List.d.ts +35 -0
- package/dist/primitives/List.js +37 -0
- package/dist/primitives/Loading.d.ts +25 -0
- package/dist/primitives/Loading.js +26 -0
- package/dist/primitives/Modal.d.ts +39 -0
- package/dist/primitives/Modal.js +37 -0
- package/dist/primitives/ModalInput.d.ts +61 -0
- package/dist/primitives/ModalInput.js +33 -0
- package/dist/primitives/Overlay.d.ts +21 -0
- package/dist/primitives/Overlay.js +41 -0
- package/dist/primitives/Portal.d.ts +28 -0
- package/dist/primitives/Portal.js +30 -0
- package/dist/primitives/Root.d.ts +116 -0
- package/dist/primitives/Root.js +413 -0
- package/dist/primitives/Separator.d.ts +19 -0
- package/dist/primitives/Separator.js +18 -0
- package/dist/primitives/Thumbnail.d.ts +31 -0
- package/dist/primitives/Thumbnail.js +59 -0
- package/dist/primitives/Trigger.d.ts +28 -0
- package/dist/primitives/Trigger.js +35 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.js +63 -0
- package/dist/styles.css +480 -133
- package/dist/types.d.ts +3 -31
- package/dist/utils/composeRefs.d.ts +12 -0
- package/dist/utils/composeRefs.js +27 -0
- package/dist/utils/slot.d.ts +22 -0
- package/dist/utils/slot.js +58 -0
- package/package.json +9 -5
- package/dist/Search.d.ts +0 -39
- package/dist/Search.js +0 -31
- package/dist/components/SearchIcon.d.ts +0 -22
- package/dist/components/SearchIcon.js +0 -17
- package/dist/components/SearchInput.d.ts +0 -30
- package/dist/components/SearchInput.js +0 -59
- package/dist/components/SearchKbd.d.ts +0 -30
- package/dist/components/SearchKbd.js +0 -24
- package/dist/components/SearchResult.d.ts +0 -31
- package/dist/components/SearchResult.js +0 -40
- package/dist/components/SearchResultThumbnail.d.ts +0 -38
- package/dist/components/SearchResultThumbnail.js +0 -38
- package/dist/components/SearchResults.d.ts +0 -39
- package/dist/components/SearchResults.js +0 -53
- package/dist/components/SearchRoot.d.ts +0 -44
- package/dist/components/SearchRoot.js +0 -132
- package/dist/context/SearchContext.d.ts +0 -55
- package/dist/context/SearchContext.js +0 -36
- package/dist/hooks/useSearch.d.ts +0 -56
- package/dist/hooks/useSearch.js +0 -116
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Searchbar — primary export of `@etoile-dev/react`.
|
|
3
|
+
*
|
|
4
|
+
* This module exposes three APIs:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Convenience wrapper**: `<Searchbar />` (Etoile-powered, live data).
|
|
7
|
+
* Pass `apiKey` + `collections` and it handles query state, fetch, loading,
|
|
8
|
+
* error, and default rendering.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Convenience modal**: `<SearchModal />` (Etoile-powered command palette).
|
|
11
|
+
* Pass `apiKey` + `collections` and it handles trigger, portal, overlay,
|
|
12
|
+
* content, and live results.
|
|
13
|
+
*
|
|
14
|
+
* 3. **Headless primitives**: `Searchbar.Root`, `Searchbar.Input`,
|
|
15
|
+
* `Searchbar.List`, `Searchbar.Item`, etc. are UI-only primitives with no
|
|
16
|
+
* built-in data fetching. Bring your own data layer.
|
|
17
|
+
*/
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import type { SearchFilter, SearchResult } from "@etoile-dev/client";
|
|
20
|
+
import type { SearchbarRootProps } from "./primitives/Root.js";
|
|
21
|
+
export type SearchbarProps = {
|
|
22
|
+
/** Your Etoile API key. Get one at https://etoile.dev */
|
|
23
|
+
apiKey: string;
|
|
24
|
+
/** Collections to search in (e.g., ["paintings", "artists"]) */
|
|
25
|
+
collections: string[];
|
|
26
|
+
/** Maximum results to return (default: 10) */
|
|
27
|
+
limit?: number;
|
|
28
|
+
/** Number of results to skip for pagination (default: 0) */
|
|
29
|
+
offset?: number;
|
|
30
|
+
/** Debounce delay in ms (default: 100) */
|
|
31
|
+
debounceMs?: number;
|
|
32
|
+
/** Placeholder for the search input (default: "Search…") */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Explicit metadata filters applied to results.
|
|
36
|
+
* Mutually exclusive with `autoFilters`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* filters={[{ key: "artist", operator: "eq", value: "Vincent van Gogh" }]}
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
filters?: SearchFilter[];
|
|
44
|
+
/**
|
|
45
|
+
* When `true`, the AI extracts filters from the query automatically.
|
|
46
|
+
* Mutually exclusive with `filters`.
|
|
47
|
+
*/
|
|
48
|
+
autoFilters?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Custom render function for each result item.
|
|
51
|
+
* Return a `Searchbar.Item` with a stable `value`.
|
|
52
|
+
*/
|
|
53
|
+
renderItem?: (result: SearchResult) => React.ReactNode;
|
|
54
|
+
/** Called when an item is selected. Receives the result's `external_id`. */
|
|
55
|
+
onSelect?: (value: string) => void;
|
|
56
|
+
baseUrl?: string;
|
|
57
|
+
} & Omit<SearchbarRootProps, "isLoading" | "error" | "search" | "onSearchChange" | "children" | "onSelect">;
|
|
58
|
+
export type SearchModalProps = {
|
|
59
|
+
/** Your Etoile API key. Get one at https://etoile.dev */
|
|
60
|
+
apiKey: string;
|
|
61
|
+
/** Collections to search in (e.g., ["paintings", "artists"]) */
|
|
62
|
+
collections: string[];
|
|
63
|
+
/** Maximum results to return (default: 10) */
|
|
64
|
+
limit?: number;
|
|
65
|
+
/** Number of results to skip for pagination (default: 0) */
|
|
66
|
+
offset?: number;
|
|
67
|
+
/** Debounce delay in ms (default: 100) */
|
|
68
|
+
debounceMs?: number;
|
|
69
|
+
/** Placeholder for the modal input (default: "Search…") */
|
|
70
|
+
placeholder?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Explicit metadata filters applied to results.
|
|
73
|
+
* Mutually exclusive with `autoFilters`.
|
|
74
|
+
*/
|
|
75
|
+
filters?: SearchFilter[];
|
|
76
|
+
/**
|
|
77
|
+
* When `true`, the AI extracts filters from the query automatically.
|
|
78
|
+
* Mutually exclusive with `filters`.
|
|
79
|
+
*/
|
|
80
|
+
autoFilters?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Global keyboard shortcut that opens the modal.
|
|
83
|
+
* Defaults to `"mod+k"` (⌘K on Mac / Ctrl+K elsewhere).
|
|
84
|
+
*/
|
|
85
|
+
hotkey?: string;
|
|
86
|
+
/** Accessible label for modal content (default: "Search") */
|
|
87
|
+
modalLabel?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Custom render function for each result item.
|
|
90
|
+
* Return a `Searchbar.Item` with a stable `value`.
|
|
91
|
+
*/
|
|
92
|
+
renderItem?: (result: SearchResult) => React.ReactNode;
|
|
93
|
+
/** Called when an item is selected. Receives the result's `external_id`. */
|
|
94
|
+
onSelect?: (value: string) => void;
|
|
95
|
+
baseUrl?: string;
|
|
96
|
+
} & Omit<SearchbarRootProps, "isLoading" | "error" | "search" | "onSearchChange" | "children" | "onSelect" | "hotkey">;
|
|
97
|
+
/**
|
|
98
|
+
* All-in-one command palette powered by Etoile.
|
|
99
|
+
*
|
|
100
|
+
* Handles portal, overlay, modal content, and live search results.
|
|
101
|
+
* Includes built-in open/close logic and defaults to `hotkey="mod+k"`.
|
|
102
|
+
* Import `@etoile-dev/react/styles.css` for the default theme.
|
|
103
|
+
*
|
|
104
|
+
* @example Basic usage
|
|
105
|
+
* ```tsx
|
|
106
|
+
* <SearchModal apiKey="your-api-key" collections={["paintings"]} />
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @example With filters
|
|
110
|
+
* ```tsx
|
|
111
|
+
* <SearchModal
|
|
112
|
+
* apiKey={process.env.ETOILE_API_KEY!}
|
|
113
|
+
* collections={["paintings"]}
|
|
114
|
+
* filters={[{ key: "artist", operator: "eq", value: "Vincent van Gogh" }]}
|
|
115
|
+
* />
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* @example With custom rendering
|
|
119
|
+
* ```tsx
|
|
120
|
+
* <SearchModal
|
|
121
|
+
* apiKey={process.env.ETOILE_API_KEY!}
|
|
122
|
+
* collections={["paintings", "artists"]}
|
|
123
|
+
* hotkey="mod+/"
|
|
124
|
+
* renderItem={(result) => (
|
|
125
|
+
* <Searchbar.Item value={result.external_id} label={result.title}>
|
|
126
|
+
* <Searchbar.Thumbnail />
|
|
127
|
+
* <div>
|
|
128
|
+
* <strong>{result.title}</strong>
|
|
129
|
+
* <span>{String(result.metadata?.artist ?? "")}</span>
|
|
130
|
+
* </div>
|
|
131
|
+
* </Searchbar.Item>
|
|
132
|
+
* )}
|
|
133
|
+
* />
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export declare const SearchModal: React.ForwardRefExoticComponent<{
|
|
137
|
+
/** Your Etoile API key. Get one at https://etoile.dev */
|
|
138
|
+
apiKey: string;
|
|
139
|
+
/** Collections to search in (e.g., ["paintings", "artists"]) */
|
|
140
|
+
collections: string[];
|
|
141
|
+
/** Maximum results to return (default: 10) */
|
|
142
|
+
limit?: number;
|
|
143
|
+
/** Number of results to skip for pagination (default: 0) */
|
|
144
|
+
offset?: number;
|
|
145
|
+
/** Debounce delay in ms (default: 100) */
|
|
146
|
+
debounceMs?: number;
|
|
147
|
+
/** Placeholder for the modal input (default: "Search…") */
|
|
148
|
+
placeholder?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Explicit metadata filters applied to results.
|
|
151
|
+
* Mutually exclusive with `autoFilters`.
|
|
152
|
+
*/
|
|
153
|
+
filters?: SearchFilter[];
|
|
154
|
+
/**
|
|
155
|
+
* When `true`, the AI extracts filters from the query automatically.
|
|
156
|
+
* Mutually exclusive with `filters`.
|
|
157
|
+
*/
|
|
158
|
+
autoFilters?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Global keyboard shortcut that opens the modal.
|
|
161
|
+
* Defaults to `"mod+k"` (⌘K on Mac / Ctrl+K elsewhere).
|
|
162
|
+
*/
|
|
163
|
+
hotkey?: string;
|
|
164
|
+
/** Accessible label for modal content (default: "Search") */
|
|
165
|
+
modalLabel?: string;
|
|
166
|
+
/**
|
|
167
|
+
* Custom render function for each result item.
|
|
168
|
+
* Return a `Searchbar.Item` with a stable `value`.
|
|
169
|
+
*/
|
|
170
|
+
renderItem?: (result: SearchResult) => React.ReactNode;
|
|
171
|
+
/** Called when an item is selected. Receives the result's `external_id`. */
|
|
172
|
+
onSelect?: (value: string) => void;
|
|
173
|
+
baseUrl?: string;
|
|
174
|
+
} & Omit<SearchbarRootProps, "isLoading" | "error" | "children" | "onSelect" | "search" | "onSearchChange" | "hotkey"> & React.RefAttributes<HTMLDivElement>>;
|
|
175
|
+
export declare const Searchbar: React.ForwardRefExoticComponent<{
|
|
176
|
+
/** Your Etoile API key. Get one at https://etoile.dev */
|
|
177
|
+
apiKey: string;
|
|
178
|
+
/** Collections to search in (e.g., ["paintings", "artists"]) */
|
|
179
|
+
collections: string[];
|
|
180
|
+
/** Maximum results to return (default: 10) */
|
|
181
|
+
limit?: number;
|
|
182
|
+
/** Number of results to skip for pagination (default: 0) */
|
|
183
|
+
offset?: number;
|
|
184
|
+
/** Debounce delay in ms (default: 100) */
|
|
185
|
+
debounceMs?: number;
|
|
186
|
+
/** Placeholder for the search input (default: "Search…") */
|
|
187
|
+
placeholder?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Explicit metadata filters applied to results.
|
|
190
|
+
* Mutually exclusive with `autoFilters`.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```tsx
|
|
194
|
+
* filters={[{ key: "artist", operator: "eq", value: "Vincent van Gogh" }]}
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
filters?: SearchFilter[];
|
|
198
|
+
/**
|
|
199
|
+
* When `true`, the AI extracts filters from the query automatically.
|
|
200
|
+
* Mutually exclusive with `filters`.
|
|
201
|
+
*/
|
|
202
|
+
autoFilters?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Custom render function for each result item.
|
|
205
|
+
* Return a `Searchbar.Item` with a stable `value`.
|
|
206
|
+
*/
|
|
207
|
+
renderItem?: (result: SearchResult) => React.ReactNode;
|
|
208
|
+
/** Called when an item is selected. Receives the result's `external_id`. */
|
|
209
|
+
onSelect?: (value: string) => void;
|
|
210
|
+
baseUrl?: string;
|
|
211
|
+
} & Omit<SearchbarRootProps, "isLoading" | "error" | "children" | "onSelect" | "search" | "onSearchChange"> & React.RefAttributes<HTMLDivElement>> & {
|
|
212
|
+
Root: React.ForwardRefExoticComponent<{
|
|
213
|
+
open?: boolean;
|
|
214
|
+
defaultOpen?: boolean;
|
|
215
|
+
onOpenChange?: (open: boolean) => void;
|
|
216
|
+
search?: string;
|
|
217
|
+
defaultSearch?: string;
|
|
218
|
+
onSearchChange?: (search: string) => void;
|
|
219
|
+
value?: string | null;
|
|
220
|
+
defaultValue?: string | null;
|
|
221
|
+
onValueChange?: (value: string | null) => void;
|
|
222
|
+
isLoading?: boolean;
|
|
223
|
+
error?: unknown;
|
|
224
|
+
hotkey?: string;
|
|
225
|
+
hotkeyBehavior?: "focus" | "toggle";
|
|
226
|
+
onSelect?: (value: string) => void;
|
|
227
|
+
children: React.ReactNode;
|
|
228
|
+
className?: string;
|
|
229
|
+
asChild?: boolean;
|
|
230
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> & React.RefAttributes<HTMLDivElement>>;
|
|
231
|
+
Input: React.ForwardRefExoticComponent<{
|
|
232
|
+
placeholder?: string;
|
|
233
|
+
className?: string;
|
|
234
|
+
asChild?: boolean;
|
|
235
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> & React.RefAttributes<HTMLInputElement>>;
|
|
236
|
+
List: React.ForwardRefExoticComponent<{
|
|
237
|
+
className?: string;
|
|
238
|
+
asChild?: boolean;
|
|
239
|
+
children: React.ReactNode;
|
|
240
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "role"> & React.RefAttributes<HTMLDivElement>>;
|
|
241
|
+
Item: React.ForwardRefExoticComponent<{
|
|
242
|
+
value: string;
|
|
243
|
+
label?: string;
|
|
244
|
+
disabled?: boolean;
|
|
245
|
+
onSelect?: (value: string) => void;
|
|
246
|
+
children: React.ReactNode;
|
|
247
|
+
className?: string;
|
|
248
|
+
asChild?: boolean;
|
|
249
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> & React.RefAttributes<HTMLDivElement>>;
|
|
250
|
+
Group: React.ForwardRefExoticComponent<{
|
|
251
|
+
label?: string;
|
|
252
|
+
className?: string;
|
|
253
|
+
asChild?: boolean;
|
|
254
|
+
children: React.ReactNode;
|
|
255
|
+
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>>;
|
|
256
|
+
Separator: React.ForwardRefExoticComponent<{
|
|
257
|
+
className?: string;
|
|
258
|
+
asChild?: boolean;
|
|
259
|
+
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>>;
|
|
260
|
+
Empty: React.ForwardRefExoticComponent<{
|
|
261
|
+
children?: React.ReactNode;
|
|
262
|
+
className?: string;
|
|
263
|
+
asChild?: boolean;
|
|
264
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "role"> & React.RefAttributes<HTMLDivElement>>;
|
|
265
|
+
Loading: React.ForwardRefExoticComponent<{
|
|
266
|
+
children?: React.ReactNode;
|
|
267
|
+
className?: string;
|
|
268
|
+
asChild?: boolean;
|
|
269
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "role"> & React.RefAttributes<HTMLDivElement>>;
|
|
270
|
+
Error: React.ForwardRefExoticComponent<{
|
|
271
|
+
children?: React.ReactNode | ((error: unknown) => React.ReactNode);
|
|
272
|
+
className?: string;
|
|
273
|
+
asChild?: boolean;
|
|
274
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "role"> & React.RefAttributes<HTMLDivElement>>;
|
|
275
|
+
Portal: {
|
|
276
|
+
({ container, children }: import("./primitives/Portal.js").SearchbarPortalProps): React.ReactPortal | null;
|
|
277
|
+
displayName: string;
|
|
278
|
+
};
|
|
279
|
+
Overlay: React.ForwardRefExoticComponent<{
|
|
280
|
+
className?: string;
|
|
281
|
+
asChild?: boolean;
|
|
282
|
+
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>>;
|
|
283
|
+
Content: React.ForwardRefExoticComponent<{
|
|
284
|
+
"aria-label"?: string;
|
|
285
|
+
className?: string;
|
|
286
|
+
asChild?: boolean;
|
|
287
|
+
children: React.ReactNode;
|
|
288
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "role"> & React.RefAttributes<HTMLDivElement>>;
|
|
289
|
+
Modal: React.ForwardRefExoticComponent<{
|
|
290
|
+
"aria-label"?: string;
|
|
291
|
+
} & Omit<SearchbarRootProps, "asChild"> & React.RefAttributes<HTMLDivElement>>;
|
|
292
|
+
ModalInput: React.ForwardRefExoticComponent<{
|
|
293
|
+
placeholder?: string;
|
|
294
|
+
icon?: React.ReactNode | null;
|
|
295
|
+
kbd?: React.ReactNode | null;
|
|
296
|
+
className?: string;
|
|
297
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & React.RefAttributes<HTMLDivElement>>;
|
|
298
|
+
Trigger: React.ForwardRefExoticComponent<{
|
|
299
|
+
children?: React.ReactNode;
|
|
300
|
+
className?: string;
|
|
301
|
+
asChild?: boolean;
|
|
302
|
+
} & React.ButtonHTMLAttributes<HTMLButtonElement> & React.RefAttributes<HTMLButtonElement>>;
|
|
303
|
+
Icon: {
|
|
304
|
+
({ size, className, ...props }: import("./primitives/Icon.js").SearchbarIconProps): import("react/jsx-runtime").JSX.Element;
|
|
305
|
+
displayName: string;
|
|
306
|
+
};
|
|
307
|
+
Kbd: {
|
|
308
|
+
({ children, className, ...props }: import("./primitives/Kbd.js").SearchbarKbdProps): import("react/jsx-runtime").JSX.Element;
|
|
309
|
+
displayName: string;
|
|
310
|
+
};
|
|
311
|
+
Thumbnail: {
|
|
312
|
+
({ src, alt, size, className, ...props }: import("./primitives/Thumbnail.js").SearchbarThumbnailProps): import("react/jsx-runtime").JSX.Element | null;
|
|
313
|
+
displayName: string;
|
|
314
|
+
};
|
|
315
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Searchbar — primary export of `@etoile-dev/react`.
|
|
4
|
+
*
|
|
5
|
+
* This module exposes three APIs:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Convenience wrapper**: `<Searchbar />` (Etoile-powered, live data).
|
|
8
|
+
* Pass `apiKey` + `collections` and it handles query state, fetch, loading,
|
|
9
|
+
* error, and default rendering.
|
|
10
|
+
*
|
|
11
|
+
* 2. **Convenience modal**: `<SearchModal />` (Etoile-powered command palette).
|
|
12
|
+
* Pass `apiKey` + `collections` and it handles trigger, portal, overlay,
|
|
13
|
+
* content, and live results.
|
|
14
|
+
*
|
|
15
|
+
* 3. **Headless primitives**: `Searchbar.Root`, `Searchbar.Input`,
|
|
16
|
+
* `Searchbar.List`, `Searchbar.Item`, etc. are UI-only primitives with no
|
|
17
|
+
* built-in data fetching. Bring your own data layer.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
import { Root } from "./primitives/Root.js";
|
|
21
|
+
import { Input } from "./primitives/Input.js";
|
|
22
|
+
import { List } from "./primitives/List.js";
|
|
23
|
+
import { Item } from "./primitives/Item.js";
|
|
24
|
+
import { Group } from "./primitives/Group.js";
|
|
25
|
+
import { Separator } from "./primitives/Separator.js";
|
|
26
|
+
import { Empty } from "./primitives/Empty.js";
|
|
27
|
+
import { Loading } from "./primitives/Loading.js";
|
|
28
|
+
import { Error as ErrorPrimitive } from "./primitives/Error.js";
|
|
29
|
+
import { Portal } from "./primitives/Portal.js";
|
|
30
|
+
import { Overlay } from "./primitives/Overlay.js";
|
|
31
|
+
import { Content } from "./primitives/Content.js";
|
|
32
|
+
import { Modal } from "./primitives/Modal.js";
|
|
33
|
+
import { ModalInput } from "./primitives/ModalInput.js";
|
|
34
|
+
import { Trigger } from "./primitives/Trigger.js";
|
|
35
|
+
import { Icon } from "./primitives/Icon.js";
|
|
36
|
+
import { Kbd } from "./primitives/Kbd.js";
|
|
37
|
+
import { Thumbnail } from "./primitives/Thumbnail.js";
|
|
38
|
+
import { useEtoileSearch } from "./hooks/useEtoileSearch.js";
|
|
39
|
+
import { SearchbarItemDataContext } from "./context.js";
|
|
40
|
+
/** Format hotkey string for display (e.g. "mod+k" → "⌘K" on Mac, "Ctrl+K" elsewhere). */
|
|
41
|
+
function formatHotkeyLabel(hotkey) {
|
|
42
|
+
const isMac = typeof navigator !== "undefined" && /mac|darwin/i.test(navigator.platform);
|
|
43
|
+
const parts = hotkey.toLowerCase().trim().split("+");
|
|
44
|
+
const key = parts[parts.length - 1];
|
|
45
|
+
const keyChar = key === "slash" ? "/" : key;
|
|
46
|
+
const mods = parts.slice(0, -1);
|
|
47
|
+
const modLabels = [];
|
|
48
|
+
if (mods.includes("mod"))
|
|
49
|
+
modLabels.push(isMac ? "⌘" : "Ctrl");
|
|
50
|
+
if (mods.includes("ctrl"))
|
|
51
|
+
modLabels.push("Ctrl");
|
|
52
|
+
if (mods.includes("alt"))
|
|
53
|
+
modLabels.push(isMac ? "⌥" : "Alt");
|
|
54
|
+
if (mods.includes("shift"))
|
|
55
|
+
modLabels.push(isMac ? "⇧" : "Shift");
|
|
56
|
+
const keyDisplay = keyChar === "/" || keyChar.length > 1 ? keyChar : keyChar.toUpperCase();
|
|
57
|
+
return modLabels.length > 0 ? `${modLabels.join("")}${keyDisplay}` : keyDisplay;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* All-in-one search component powered by Etoile.
|
|
61
|
+
*
|
|
62
|
+
* Handles data fetching, debounce, keyboard navigation, and ARIA wiring.
|
|
63
|
+
* No default hotkey; pass `hotkey="/"` (or `hotkey="mod+k"`) if you want a global shortcut.
|
|
64
|
+
* Import `@etoile-dev/react/styles.css` for the default theme.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```tsx
|
|
68
|
+
* import "@etoile-dev/react/styles.css";
|
|
69
|
+
* import { Searchbar } from "@etoile-dev/react";
|
|
70
|
+
*
|
|
71
|
+
* <Searchbar apiKey="your-api-key" collections={["paintings"]} />
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* @example With filters and custom rendering
|
|
75
|
+
* ```tsx
|
|
76
|
+
* <Searchbar
|
|
77
|
+
* apiKey={process.env.ETOILE_API_KEY!}
|
|
78
|
+
* collections={["paintings"]}
|
|
79
|
+
* filters={[{ key: "artist", operator: "eq", value: "Vincent van Gogh" }]}
|
|
80
|
+
* onSelect={(id) => router.push(`/painting/${id}`)}
|
|
81
|
+
* renderItem={(result) => (
|
|
82
|
+
* <Searchbar.Item value={result.external_id} label={result.title}>
|
|
83
|
+
* <Searchbar.Thumbnail />
|
|
84
|
+
* <div>
|
|
85
|
+
* <strong>{result.title}</strong>
|
|
86
|
+
* <span>{String(result.metadata?.year ?? "")}</span>
|
|
87
|
+
* </div>
|
|
88
|
+
* </Searchbar.Item>
|
|
89
|
+
* )}
|
|
90
|
+
* />
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @example Headless primitives (no Etoile dependency)
|
|
94
|
+
* ```tsx
|
|
95
|
+
* <Searchbar.Root onSelect={handleSelect}>
|
|
96
|
+
* <Searchbar.Input placeholder="Search paintings…" />
|
|
97
|
+
* <Searchbar.List>
|
|
98
|
+
* {paintings.map((p) => (
|
|
99
|
+
* <Searchbar.Item key={p.id} value={p.id} label={p.title}>
|
|
100
|
+
* {p.title}
|
|
101
|
+
* </Searchbar.Item>
|
|
102
|
+
* ))}
|
|
103
|
+
* <Searchbar.Empty>No results.</Searchbar.Empty>
|
|
104
|
+
* </Searchbar.List>
|
|
105
|
+
* </Searchbar.Root>
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
const SearchbarWrapper = React.forwardRef(({ apiKey, collections, limit, offset, debounceMs, placeholder = "Search…", filters, autoFilters, renderItem, onSelect, baseUrl, hotkey, className, ...rootProps }, ref) => {
|
|
109
|
+
const [query, setQuery] = React.useState("");
|
|
110
|
+
const { results, isLoading, error } = useEtoileSearch({
|
|
111
|
+
apiKey,
|
|
112
|
+
collections,
|
|
113
|
+
query,
|
|
114
|
+
limit,
|
|
115
|
+
offset,
|
|
116
|
+
debounceMs,
|
|
117
|
+
filters,
|
|
118
|
+
autoFilters,
|
|
119
|
+
baseUrl,
|
|
120
|
+
});
|
|
121
|
+
return (_jsxs(Root, { ...rootProps, ref: ref, hotkey: hotkey, hotkeyBehavior: hotkey ? "focus" : undefined, search: query, onSearchChange: setQuery, isLoading: isLoading, error: error ?? undefined, onSelect: onSelect, className: className
|
|
122
|
+
? `etoile-search ${className}`
|
|
123
|
+
: "etoile-search", children: [_jsxs("div", { "data-slot": "searchbar-input-row", children: [_jsx(Icon, {}), _jsx(Input, { placeholder: placeholder }), hotkey ? _jsx(Kbd, { children: formatHotkeyLabel(hotkey) }) : null] }), _jsxs(List, { children: [results.map((result) => renderItem ? (
|
|
124
|
+
// User's custom renderer — wrap in data context so Thumbnail works
|
|
125
|
+
_jsx(SearchbarItemDataContext.Provider, { value: result, children: renderItem(result) }, result.external_id)) : (_jsx(DefaultItem, { result: result }, result.external_id))), _jsxs(Empty, { children: ["No results found for ", _jsxs("span", { "data-slot": "searchbar-empty-query", children: ["\"", query, "\""] })] }), _jsx(Loading, {}), _jsx(ErrorPrimitive, {})] })] }));
|
|
126
|
+
});
|
|
127
|
+
SearchbarWrapper.displayName = "Searchbar";
|
|
128
|
+
/**
|
|
129
|
+
* All-in-one command palette powered by Etoile.
|
|
130
|
+
*
|
|
131
|
+
* Handles portal, overlay, modal content, and live search results.
|
|
132
|
+
* Includes built-in open/close logic and defaults to `hotkey="mod+k"`.
|
|
133
|
+
* Import `@etoile-dev/react/styles.css` for the default theme.
|
|
134
|
+
*
|
|
135
|
+
* @example Basic usage
|
|
136
|
+
* ```tsx
|
|
137
|
+
* <SearchModal apiKey="your-api-key" collections={["paintings"]} />
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example With filters
|
|
141
|
+
* ```tsx
|
|
142
|
+
* <SearchModal
|
|
143
|
+
* apiKey={process.env.ETOILE_API_KEY!}
|
|
144
|
+
* collections={["paintings"]}
|
|
145
|
+
* filters={[{ key: "artist", operator: "eq", value: "Vincent van Gogh" }]}
|
|
146
|
+
* />
|
|
147
|
+
* ```
|
|
148
|
+
*
|
|
149
|
+
* @example With custom rendering
|
|
150
|
+
* ```tsx
|
|
151
|
+
* <SearchModal
|
|
152
|
+
* apiKey={process.env.ETOILE_API_KEY!}
|
|
153
|
+
* collections={["paintings", "artists"]}
|
|
154
|
+
* hotkey="mod+/"
|
|
155
|
+
* renderItem={(result) => (
|
|
156
|
+
* <Searchbar.Item value={result.external_id} label={result.title}>
|
|
157
|
+
* <Searchbar.Thumbnail />
|
|
158
|
+
* <div>
|
|
159
|
+
* <strong>{result.title}</strong>
|
|
160
|
+
* <span>{String(result.metadata?.artist ?? "")}</span>
|
|
161
|
+
* </div>
|
|
162
|
+
* </Searchbar.Item>
|
|
163
|
+
* )}
|
|
164
|
+
* />
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export const SearchModal = React.forwardRef(({ apiKey, collections, limit, offset, debounceMs, placeholder = "Search…", filters, autoFilters, hotkey = "mod+k", modalLabel = "Search", renderItem, onSelect, baseUrl, className, ...rootProps }, ref) => {
|
|
168
|
+
const [query, setQuery] = React.useState("");
|
|
169
|
+
const { results, isLoading, error } = useEtoileSearch({
|
|
170
|
+
apiKey,
|
|
171
|
+
collections,
|
|
172
|
+
query,
|
|
173
|
+
limit,
|
|
174
|
+
offset,
|
|
175
|
+
debounceMs,
|
|
176
|
+
filters,
|
|
177
|
+
autoFilters,
|
|
178
|
+
baseUrl,
|
|
179
|
+
});
|
|
180
|
+
return (_jsxs(Modal, { ...rootProps, ref: ref, hotkey: hotkey, search: query, onSearchChange: setQuery, isLoading: isLoading, error: error ?? undefined, onSelect: onSelect, className: className
|
|
181
|
+
? `etoile-search ${className}`
|
|
182
|
+
: "etoile-search", "aria-label": modalLabel, children: [_jsx(ModalInput, { placeholder: placeholder }), _jsxs(List, { children: [results.map((result) => renderItem ? (_jsx(SearchbarItemDataContext.Provider, { value: result, children: renderItem(result) }, result.external_id)) : (_jsx(DefaultItem, { result: result }, result.external_id))), _jsxs(Empty, { children: ["No results found for ", _jsxs("span", { "data-slot": "searchbar-empty-query", children: ["\"", query, "\""] })] }), _jsx(Loading, {}), _jsx(ErrorPrimitive, {})] })] }));
|
|
183
|
+
});
|
|
184
|
+
SearchModal.displayName = "SearchModal";
|
|
185
|
+
// ─── Default item renderer ────────────────────────────────────────────────────
|
|
186
|
+
const DefaultItem = ({ result }) => (_jsx(SearchbarItemDataContext.Provider, { value: result, children: _jsxs(Item, { value: result.external_id, label: result.title, children: [_jsx(Thumbnail, {}), _jsxs("div", { "data-slot": "searchbar-result-content", children: [_jsx("span", { "data-slot": "searchbar-result-title", children: result.title }), _jsx("span", { "data-slot": "searchbar-result-subtitle", children: result.collection })] })] }) }));
|
|
187
|
+
// ─── Namespace assembly ───────────────────────────────────────────────────────
|
|
188
|
+
export const Searchbar = Object.assign(SearchbarWrapper, {
|
|
189
|
+
Root,
|
|
190
|
+
Input,
|
|
191
|
+
List,
|
|
192
|
+
Item,
|
|
193
|
+
Group,
|
|
194
|
+
Separator,
|
|
195
|
+
Empty,
|
|
196
|
+
Loading,
|
|
197
|
+
Error: ErrorPrimitive,
|
|
198
|
+
Portal,
|
|
199
|
+
Overlay,
|
|
200
|
+
Content,
|
|
201
|
+
Modal,
|
|
202
|
+
ModalInput,
|
|
203
|
+
Trigger,
|
|
204
|
+
Icon,
|
|
205
|
+
Kbd,
|
|
206
|
+
Thumbnail,
|
|
207
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { SearchbarStore, SearchbarState } from "./store.js";
|
|
3
|
+
export type SearchbarContextValue = {
|
|
4
|
+
store: SearchbarStore;
|
|
5
|
+
/** Stable listbox DOM id — wired to aria-controls on Input */
|
|
6
|
+
listId: string;
|
|
7
|
+
/**
|
|
8
|
+
* Unique id for the Root instance. Used as `data-searchbar-root` on every
|
|
9
|
+
* DOM node that belongs to this searchbar (including portaled content) so
|
|
10
|
+
* the click-outside handler can correctly ignore clicks inside portals.
|
|
11
|
+
*/
|
|
12
|
+
rootId: string;
|
|
13
|
+
/** Optional Root className forwarded to portaled Content/Overlay. */
|
|
14
|
+
rootClassName?: string;
|
|
15
|
+
/** Whether search is controlled (Input must not mutate store) */
|
|
16
|
+
isSearchControlled?: boolean;
|
|
17
|
+
/** Called when query changes (for controlled mode, Input calls this) */
|
|
18
|
+
onSearchChange?: (query: string) => void;
|
|
19
|
+
/** Ref for the Trigger — Content restores focus here on close */
|
|
20
|
+
triggerRef?: React.RefObject<HTMLElement | null>;
|
|
21
|
+
/** Derive a stable DOM id for a given item value */
|
|
22
|
+
getItemId: (value: string) => string;
|
|
23
|
+
/** Trigger selection of an item by value — handles callbacks + state update */
|
|
24
|
+
onSelect: (value: string) => void;
|
|
25
|
+
/** Set open state — respects controlled mode (Overlay/Escape use this) */
|
|
26
|
+
setOpen: (open: boolean) => void;
|
|
27
|
+
/** Keyboard handler — Input calls this so portal-rendered Inputs get navigation */
|
|
28
|
+
handleKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void;
|
|
29
|
+
/** Register an item's DOM node — called by Item on mount/unmount */
|
|
30
|
+
registerItem: (meta: {
|
|
31
|
+
value: string;
|
|
32
|
+
label: string;
|
|
33
|
+
disabled: boolean;
|
|
34
|
+
node: HTMLElement | null;
|
|
35
|
+
onSelect?: (value: string) => void;
|
|
36
|
+
}) => void;
|
|
37
|
+
/** Unregister an item on unmount */
|
|
38
|
+
unregisterItem: (value: string) => void;
|
|
39
|
+
};
|
|
40
|
+
export declare const SearchbarProvider: React.Provider<SearchbarContextValue | null>;
|
|
41
|
+
export declare function useSearchbarContext(): SearchbarContextValue;
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to a slice of the searchbar store.
|
|
44
|
+
* Re-renders only when the selected slice changes (reference equality).
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const query = useSearchbarStore(ctx.store, (s) => s.query);
|
|
48
|
+
*/
|
|
49
|
+
export declare function useSearchbarStore<T>(store: SearchbarStore, selector: (state: SearchbarState) => T): T;
|
|
50
|
+
/** Convenience: read a slice within a component that already has context */
|
|
51
|
+
export declare function useSearchbarState<T>(selector: (state: SearchbarState) => T): T;
|
|
52
|
+
/** When true, List hides when query is empty (command palette mode) */
|
|
53
|
+
export declare const SearchbarHideListWhenQueryEmptyContext: React.Context<boolean>;
|
|
54
|
+
/** Context for the item currently being rendered (set by Searchbar.Item) */
|
|
55
|
+
export declare const SearchbarItemContext: React.Context<string | null>;
|
|
56
|
+
/** Context providing the raw SearchResult when rendered via the Etoile wrapper */
|
|
57
|
+
export declare const SearchbarItemDataContext: React.Context<Record<string, any> | null>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
const SearchbarContext = React.createContext(null);
|
|
3
|
+
export const SearchbarProvider = SearchbarContext.Provider;
|
|
4
|
+
export function useSearchbarContext() {
|
|
5
|
+
const ctx = React.useContext(SearchbarContext);
|
|
6
|
+
if (!ctx) {
|
|
7
|
+
throw new Error("Searchbar primitives must be used within Searchbar.Root.");
|
|
8
|
+
}
|
|
9
|
+
return ctx;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to a slice of the searchbar store.
|
|
13
|
+
* Re-renders only when the selected slice changes (reference equality).
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const query = useSearchbarStore(ctx.store, (s) => s.query);
|
|
17
|
+
*/
|
|
18
|
+
export function useSearchbarStore(store, selector) {
|
|
19
|
+
return React.useSyncExternalStore(store.subscribe, () => selector(store.getState()), () => selector(store.getState()));
|
|
20
|
+
}
|
|
21
|
+
/** Convenience: read a slice within a component that already has context */
|
|
22
|
+
export function useSearchbarState(selector) {
|
|
23
|
+
const { store } = useSearchbarContext();
|
|
24
|
+
return useSearchbarStore(store, selector);
|
|
25
|
+
}
|
|
26
|
+
/** When true, List hides when query is empty (command palette mode) */
|
|
27
|
+
export const SearchbarHideListWhenQueryEmptyContext = React.createContext(false);
|
|
28
|
+
/** Context for the item currently being rendered (set by Searchbar.Item) */
|
|
29
|
+
export const SearchbarItemContext = React.createContext(null);
|
|
30
|
+
/** Context providing the raw SearchResult when rendered via the Etoile wrapper */
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
export const SearchbarItemDataContext = React.createContext(null);
|