@etoile-dev/react 0.2.3 → 1.0.1

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 +344 -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 +136 -0
  7. package/dist/hooks/useEtoileSearch.js +187 -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
@@ -0,0 +1,136 @@
1
+ import type { SearchFilter, SearchResult } from "@etoile-dev/client";
2
+ export type UseEtoileSearchOptions = {
3
+ /** Your Etoile API key */
4
+ apiKey: string;
5
+ /** Collections to search */
6
+ collections: string[];
7
+ /** Current search query (controlled externally, e.g. from Searchbar.Root) */
8
+ query: string;
9
+ /** Maximum results to return (default: 10) */
10
+ limit?: number;
11
+ /** Number of results to skip for pagination (default: 0) */
12
+ offset?: number;
13
+ /** Debounce delay in ms before firing the request (default: 100) */
14
+ debounceMs?: number;
15
+ /**
16
+ * Whether failed requests should retry automatically (default: true).
17
+ * Uses `errorRetryCount` and `errorRetryInterval`.
18
+ */
19
+ shouldRetryOnError?: boolean;
20
+ /** Maximum number of retries after a failed request (default: 2). */
21
+ errorRetryCount?: number;
22
+ /** Delay between retries in ms (default: 1000). */
23
+ errorRetryInterval?: number;
24
+ /**
25
+ * Explicit metadata filters applied to results.
26
+ * Mutually exclusive with `autoFilters`.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * filters={[
31
+ * { key: "artist", operator: "eq", value: "Vincent van Gogh" },
32
+ * { key: "year", operator: "lte", value: 1900 },
33
+ * ]}
34
+ * ```
35
+ */
36
+ filters?: SearchFilter[];
37
+ /**
38
+ * When true, the AI extracts filters from the query automatically.
39
+ * Returns `appliedFilters` and `refinedQuery` in the result.
40
+ * Mutually exclusive with `filters`.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * // Query: "Van Gogh paintings after 1888"
45
+ * // → refinedQuery: "paintings"
46
+ * // → appliedFilters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }, ...]
47
+ * autoFilters={true}
48
+ * ```
49
+ */
50
+ autoFilters?: boolean;
51
+ baseUrl?: string;
52
+ };
53
+ export type UseEtoileSearchReturn = {
54
+ /** Results returned by the last successful search. Empty while loading or on error. */
55
+ results: SearchResult[];
56
+ /** True while a fetch is in flight. */
57
+ isLoading: boolean;
58
+ /** Set when the last request failed; null otherwise. */
59
+ error: unknown;
60
+ /** True when `error` is set. */
61
+ isError: boolean;
62
+ /** Filters that were applied. Populated when `filters` or `autoFilters` is used. */
63
+ appliedFilters?: SearchFilter[];
64
+ /**
65
+ * Query rewritten by the AI after extracting filters.
66
+ * Only populated when `autoFilters` is `true`.
67
+ */
68
+ refinedQuery?: string;
69
+ };
70
+ /**
71
+ * Fetch live Etoile results for a query.
72
+ *
73
+ * Handles debouncing, in-flight cancellation, loading, and error state.
74
+ * Use this hook when composing headless primitives with your own layout.
75
+ *
76
+ * @param options - Hook options.
77
+ * @param options.apiKey - Your Etoile API key.
78
+ * @param options.collections - Collections to search in (e.g. `["paintings", "artists"]`).
79
+ * @param options.query - Current query string (controlled externally).
80
+ * @param options.limit - Maximum results to return (default: `10`).
81
+ * @param options.offset - Number of results to skip for pagination (default: `0`).
82
+ * @param options.debounceMs - Debounce delay in ms before firing the request (default: `100`).
83
+ * @param options.shouldRetryOnError - Retry failed requests automatically (default: `true`).
84
+ * @param options.errorRetryCount - Maximum retries after a failed request (default: `2`).
85
+ * @param options.errorRetryInterval - Delay between retries in ms (default: `1000`).
86
+ * @param options.filters - Explicit metadata filters. Mutually exclusive with `autoFilters`.
87
+ * @param options.autoFilters - When `true`, the AI extracts filters from the query automatically.
88
+ * @returns Current search state: `results`, `isLoading`, `error`, `isError`, `appliedFilters`, `refinedQuery`.
89
+ *
90
+ * @example Basic usage
91
+ * ```tsx
92
+ * const [query, setQuery] = useState("");
93
+ * const { results, isLoading } = useEtoileSearch({
94
+ * apiKey: process.env.ETOILE_API_KEY!,
95
+ * collections: ["paintings"],
96
+ * query,
97
+ * });
98
+ * ```
99
+ *
100
+ * @example With explicit filters
101
+ * ```tsx
102
+ * const { results } = useEtoileSearch({
103
+ * apiKey: process.env.ETOILE_API_KEY!,
104
+ * collections: ["paintings"],
105
+ * query,
106
+ * filters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }],
107
+ * });
108
+ * ```
109
+ *
110
+ * @example With AI-powered auto-filters
111
+ * ```tsx
112
+ * // Query: "Van Gogh paintings after 1888"
113
+ * // → refinedQuery: "paintings"
114
+ * // → appliedFilters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }, ...]
115
+ * const { results, refinedQuery, appliedFilters } = useEtoileSearch({
116
+ * apiKey: process.env.ETOILE_API_KEY!,
117
+ * collections: ["paintings"],
118
+ * query,
119
+ * autoFilters: true,
120
+ * });
121
+ * ```
122
+ */
123
+ export declare function useEtoileSearch({ apiKey, collections, query, limit, offset, debounceMs, shouldRetryOnError, errorRetryCount, errorRetryInterval, filters, autoFilters, baseUrl, }: UseEtoileSearchOptions): UseEtoileSearchReturn;
124
+ /**
125
+ * Alias of `useEtoileSearch` for ergonomic imports in examples.
126
+ *
127
+ * @example
128
+ * ```tsx
129
+ * const { results } = useSearch({
130
+ * apiKey: process.env.ETOILE_API_KEY!,
131
+ * collections: ["paintings"],
132
+ * query,
133
+ * });
134
+ * ```
135
+ */
136
+ export declare const useSearch: typeof useEtoileSearch;
@@ -0,0 +1,187 @@
1
+ import * as React from "react";
2
+ import { Etoile } from "@etoile-dev/client";
3
+ /**
4
+ * Fetch live Etoile results for a query.
5
+ *
6
+ * Handles debouncing, in-flight cancellation, loading, and error state.
7
+ * Use this hook when composing headless primitives with your own layout.
8
+ *
9
+ * @param options - Hook options.
10
+ * @param options.apiKey - Your Etoile API key.
11
+ * @param options.collections - Collections to search in (e.g. `["paintings", "artists"]`).
12
+ * @param options.query - Current query string (controlled externally).
13
+ * @param options.limit - Maximum results to return (default: `10`).
14
+ * @param options.offset - Number of results to skip for pagination (default: `0`).
15
+ * @param options.debounceMs - Debounce delay in ms before firing the request (default: `100`).
16
+ * @param options.shouldRetryOnError - Retry failed requests automatically (default: `true`).
17
+ * @param options.errorRetryCount - Maximum retries after a failed request (default: `2`).
18
+ * @param options.errorRetryInterval - Delay between retries in ms (default: `1000`).
19
+ * @param options.filters - Explicit metadata filters. Mutually exclusive with `autoFilters`.
20
+ * @param options.autoFilters - When `true`, the AI extracts filters from the query automatically.
21
+ * @returns Current search state: `results`, `isLoading`, `error`, `isError`, `appliedFilters`, `refinedQuery`.
22
+ *
23
+ * @example Basic usage
24
+ * ```tsx
25
+ * const [query, setQuery] = useState("");
26
+ * const { results, isLoading } = useEtoileSearch({
27
+ * apiKey: process.env.ETOILE_API_KEY!,
28
+ * collections: ["paintings"],
29
+ * query,
30
+ * });
31
+ * ```
32
+ *
33
+ * @example With explicit filters
34
+ * ```tsx
35
+ * const { results } = useEtoileSearch({
36
+ * apiKey: process.env.ETOILE_API_KEY!,
37
+ * collections: ["paintings"],
38
+ * query,
39
+ * filters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }],
40
+ * });
41
+ * ```
42
+ *
43
+ * @example With AI-powered auto-filters
44
+ * ```tsx
45
+ * // Query: "Van Gogh paintings after 1888"
46
+ * // → refinedQuery: "paintings"
47
+ * // → appliedFilters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }, ...]
48
+ * const { results, refinedQuery, appliedFilters } = useEtoileSearch({
49
+ * apiKey: process.env.ETOILE_API_KEY!,
50
+ * collections: ["paintings"],
51
+ * query,
52
+ * autoFilters: true,
53
+ * });
54
+ * ```
55
+ */
56
+ export function useEtoileSearch({ apiKey, collections, query, limit = 10, offset, debounceMs = 100, shouldRetryOnError = true, errorRetryCount = 2, errorRetryInterval = 1000, filters, autoFilters, baseUrl, }) {
57
+ const [results, setResults] = React.useState([]);
58
+ const [isLoading, setIsLoading] = React.useState(false);
59
+ const [error, setError] = React.useState(null);
60
+ const [appliedFilters, setAppliedFilters] = React.useState(undefined);
61
+ const [refinedQuery, setRefinedQuery] = React.useState(undefined);
62
+ const client = React.useMemo(() => new Etoile({ apiKey, baseUrl }), [apiKey, baseUrl]);
63
+ // Stable refs so the effect dep array doesn't need filters/autoFilters arrays
64
+ const filtersRef = React.useRef(filters);
65
+ filtersRef.current = filters;
66
+ const autoFiltersRef = React.useRef(autoFilters);
67
+ autoFiltersRef.current = autoFilters;
68
+ const collectionsRef = React.useRef(collections);
69
+ collectionsRef.current = collections;
70
+ // Compare value-based inputs by value so inline objects/arrays don't retrigger endlessly.
71
+ const collectionsKey = React.useMemo(() => collections.join("\u001f"), [collections]);
72
+ const filtersKey = React.useMemo(() => JSON.stringify(filters ?? null), [filters]);
73
+ const autoFiltersKey = autoFilters === undefined ? "unset" : autoFilters ? "true" : "false";
74
+ const retryCount = Number.isFinite(errorRetryCount)
75
+ ? Math.max(0, Math.floor(errorRetryCount))
76
+ : 0;
77
+ const retryInterval = Number.isFinite(errorRetryInterval)
78
+ ? Math.max(0, errorRetryInterval)
79
+ : 0;
80
+ // Debounce the raw query
81
+ const [debouncedQuery, setDebouncedQuery] = React.useState(query);
82
+ React.useEffect(() => {
83
+ const handle = setTimeout(() => setDebouncedQuery(query), debounceMs);
84
+ return () => clearTimeout(handle);
85
+ }, [query, debounceMs]);
86
+ // Show loading as soon as user types (avoids "no results" flash before debounced fetch)
87
+ React.useEffect(() => {
88
+ if (query.trim() !== "") {
89
+ setIsLoading(true);
90
+ }
91
+ else {
92
+ setIsLoading(false);
93
+ }
94
+ }, [query]);
95
+ // Fetch on debounced query / collection / filter / retry option change
96
+ React.useEffect(() => {
97
+ if (debouncedQuery.trim() === "") {
98
+ setResults((prev) => (prev.length === 0 ? prev : []));
99
+ setAppliedFilters(undefined);
100
+ setRefinedQuery(undefined);
101
+ setIsLoading(false);
102
+ setError(null);
103
+ return;
104
+ }
105
+ let active = true;
106
+ let retryTimer;
107
+ let retryAttempt = 0;
108
+ const runSearch = () => {
109
+ if (!active)
110
+ return;
111
+ setIsLoading(true);
112
+ client
113
+ .search({
114
+ collections: collectionsRef.current,
115
+ query: debouncedQuery,
116
+ limit,
117
+ ...(offset !== undefined && { offset }),
118
+ ...(filtersRef.current !== undefined && { filters: filtersRef.current }),
119
+ ...(autoFiltersRef.current !== undefined && { autoFilters: autoFiltersRef.current }),
120
+ })
121
+ .then((res) => {
122
+ if (!active)
123
+ return;
124
+ setResults(Array.isArray(res.results) ? res.results : []);
125
+ setAppliedFilters(res.appliedFilters);
126
+ setRefinedQuery(res.refinedQuery);
127
+ setError(null);
128
+ setIsLoading(false);
129
+ })
130
+ .catch((err) => {
131
+ if (!active)
132
+ return;
133
+ const canRetry = shouldRetryOnError && retryAttempt < retryCount;
134
+ if (canRetry) {
135
+ retryAttempt += 1;
136
+ retryTimer = setTimeout(runSearch, retryInterval);
137
+ return;
138
+ }
139
+ setResults([]);
140
+ setAppliedFilters(undefined);
141
+ setRefinedQuery(undefined);
142
+ setError(err);
143
+ setIsLoading(false);
144
+ });
145
+ };
146
+ setError(null);
147
+ runSearch();
148
+ return () => {
149
+ active = false;
150
+ if (retryTimer) {
151
+ clearTimeout(retryTimer);
152
+ }
153
+ };
154
+ }, [
155
+ client,
156
+ collectionsKey,
157
+ filtersKey,
158
+ autoFiltersKey,
159
+ debouncedQuery,
160
+ limit,
161
+ offset,
162
+ shouldRetryOnError,
163
+ retryCount,
164
+ retryInterval,
165
+ ]);
166
+ return {
167
+ results,
168
+ isLoading,
169
+ error,
170
+ isError: error != null,
171
+ appliedFilters,
172
+ refinedQuery,
173
+ };
174
+ }
175
+ /**
176
+ * Alias of `useEtoileSearch` for ergonomic imports in examples.
177
+ *
178
+ * @example
179
+ * ```tsx
180
+ * const { results } = useSearch({
181
+ * apiKey: process.env.ETOILE_API_KEY!,
182
+ * collections: ["paintings"],
183
+ * query,
184
+ * });
185
+ * ```
186
+ */
187
+ export const useSearch = useEtoileSearch;
package/dist/index.d.ts CHANGED
@@ -1,20 +1,45 @@
1
- export { SearchRoot } from "./components/SearchRoot.js";
2
- export { SearchInput } from "./components/SearchInput.js";
3
- export { SearchResults } from "./components/SearchResults.js";
4
- export { SearchResult } from "./components/SearchResult.js";
5
- export { SearchResultThumbnail } from "./components/SearchResultThumbnail.js";
6
- export { SearchIcon } from "./components/SearchIcon.js";
7
- export { SearchKbd } from "./components/SearchKbd.js";
8
- export { Search } from "./Search.js";
9
- export { useSearch } from "./hooks/useSearch.js";
10
- export { useSearchContext } from "./context/SearchContext.js";
1
+ export { Searchbar, SearchModal } from "./Searchbar.js";
2
+ export { Root as SearchbarRoot } from "./primitives/Root.js";
3
+ export { Input as SearchbarInput } from "./primitives/Input.js";
4
+ export { List as SearchbarList } from "./primitives/List.js";
5
+ export { Item as SearchbarItem } from "./primitives/Item.js";
6
+ export { Group as SearchbarGroup } from "./primitives/Group.js";
7
+ export { Separator as SearchbarSeparator } from "./primitives/Separator.js";
8
+ export { Empty as SearchbarEmpty } from "./primitives/Empty.js";
9
+ export { Loading as SearchbarLoading } from "./primitives/Loading.js";
10
+ export { Error as SearchbarError } from "./primitives/Error.js";
11
+ export { Portal as SearchbarPortal } from "./primitives/Portal.js";
12
+ export { Overlay as SearchbarOverlay } from "./primitives/Overlay.js";
13
+ export { Content as SearchbarContent } from "./primitives/Content.js";
14
+ export { Modal as SearchbarModal } from "./primitives/Modal.js";
15
+ export { ModalInput as SearchbarModalInput } from "./primitives/ModalInput.js";
16
+ export { Trigger as SearchbarTrigger } from "./primitives/Trigger.js";
17
+ export { Icon as SearchbarIcon } from "./primitives/Icon.js";
18
+ export { Kbd as SearchbarKbd } from "./primitives/Kbd.js";
19
+ export { Thumbnail as SearchbarThumbnail } from "./primitives/Thumbnail.js";
20
+ export { useSearchbarContext, useSearchbarStore, useSearchbarState } from "./context.js";
21
+ export { useEtoileSearch, useSearch } from "./hooks/useEtoileSearch.js";
22
+ export type { SearchResult, SearchFilter, FilterOperator } from "@etoile-dev/client";
23
+ /** @deprecated Use `SearchResult` from `@etoile-dev/client` instead. */
11
24
  export type { SearchResultData } from "./types.js";
12
- export type { SearchRootProps } from "./components/SearchRoot.js";
13
- export type { SearchInputProps } from "./components/SearchInput.js";
14
- export type { SearchResultsProps } from "./components/SearchResults.js";
15
- export type { SearchResultProps } from "./components/SearchResult.js";
16
- export type { SearchResultThumbnailProps } from "./components/SearchResultThumbnail.js";
17
- export type { SearchIconProps } from "./components/SearchIcon.js";
18
- export type { SearchKbdProps } from "./components/SearchKbd.js";
19
- export type { SearchProps } from "./Search.js";
20
- export type { UseSearchOptions, UseSearchReturn } from "./hooks/useSearch.js";
25
+ export type { SearchbarProps, SearchModalProps } from "./Searchbar.js";
26
+ export type { SearchbarRootProps } from "./primitives/Root.js";
27
+ export type { SearchbarInputProps } from "./primitives/Input.js";
28
+ export type { SearchbarListProps } from "./primitives/List.js";
29
+ export type { SearchbarItemProps } from "./primitives/Item.js";
30
+ export type { SearchbarGroupProps } from "./primitives/Group.js";
31
+ export type { SearchbarSeparatorProps } from "./primitives/Separator.js";
32
+ export type { SearchbarEmptyProps } from "./primitives/Empty.js";
33
+ export type { SearchbarLoadingProps } from "./primitives/Loading.js";
34
+ export type { SearchbarErrorProps } from "./primitives/Error.js";
35
+ export type { SearchbarPortalProps } from "./primitives/Portal.js";
36
+ export type { SearchbarOverlayProps } from "./primitives/Overlay.js";
37
+ export type { SearchbarContentProps } from "./primitives/Content.js";
38
+ export type { SearchbarModalProps } from "./primitives/Modal.js";
39
+ export type { SearchbarModalInputProps } from "./primitives/ModalInput.js";
40
+ export type { SearchbarTriggerProps } from "./primitives/Trigger.js";
41
+ export type { SearchbarIconProps } from "./primitives/Icon.js";
42
+ export type { SearchbarKbdProps } from "./primitives/Kbd.js";
43
+ export type { SearchbarThumbnailProps } from "./primitives/Thumbnail.js";
44
+ export type { SearchbarState, SearchbarStore } from "./store.js";
45
+ export type { UseEtoileSearchOptions, UseEtoileSearchReturn } from "./hooks/useEtoileSearch.js";
package/dist/index.js CHANGED
@@ -1,12 +1,37 @@
1
- // Components
2
- export { SearchRoot } from "./components/SearchRoot.js";
3
- export { SearchInput } from "./components/SearchInput.js";
4
- export { SearchResults } from "./components/SearchResults.js";
5
- export { SearchResult } from "./components/SearchResult.js";
6
- export { SearchResultThumbnail } from "./components/SearchResultThumbnail.js";
7
- export { SearchIcon } from "./components/SearchIcon.js";
8
- export { SearchKbd } from "./components/SearchKbd.js";
9
- export { Search } from "./Search.js";
10
- // Hooks
11
- export { useSearch } from "./hooks/useSearch.js";
12
- export { useSearchContext } from "./context/SearchContext.js";
1
+ // ─── Primary export ───────────────────────────────────────────────────────────
2
+ //
3
+ // `Searchbar` is both a ready-to-use Etoile search component AND
4
+ // the namespace for all headless primitives:
5
+ //
6
+ // <Searchbar apiKey="…" collections={["paintings"]} />
7
+ //
8
+ // <Searchbar.Root onSelect={}>
9
+ // <Searchbar.Input />
10
+ // <Searchbar.List>
11
+ // <Searchbar.Item value="…">…</Searchbar.Item>
12
+ // <Searchbar.Empty>No results</Searchbar.Empty>
13
+ // </Searchbar.List>
14
+ // </Searchbar.Root>
15
+ export { Searchbar, SearchModal } from "./Searchbar.js";
16
+ // ─── Individual primitive exports (tree-shakeable) ────────────────────────────
17
+ export { Root as SearchbarRoot } from "./primitives/Root.js";
18
+ export { Input as SearchbarInput } from "./primitives/Input.js";
19
+ export { List as SearchbarList } from "./primitives/List.js";
20
+ export { Item as SearchbarItem } from "./primitives/Item.js";
21
+ export { Group as SearchbarGroup } from "./primitives/Group.js";
22
+ export { Separator as SearchbarSeparator } from "./primitives/Separator.js";
23
+ export { Empty as SearchbarEmpty } from "./primitives/Empty.js";
24
+ export { Loading as SearchbarLoading } from "./primitives/Loading.js";
25
+ export { Error as SearchbarError } from "./primitives/Error.js";
26
+ export { Portal as SearchbarPortal } from "./primitives/Portal.js";
27
+ export { Overlay as SearchbarOverlay } from "./primitives/Overlay.js";
28
+ export { Content as SearchbarContent } from "./primitives/Content.js";
29
+ export { Modal as SearchbarModal } from "./primitives/Modal.js";
30
+ export { ModalInput as SearchbarModalInput } from "./primitives/ModalInput.js";
31
+ export { Trigger as SearchbarTrigger } from "./primitives/Trigger.js";
32
+ export { Icon as SearchbarIcon } from "./primitives/Icon.js";
33
+ export { Kbd as SearchbarKbd } from "./primitives/Kbd.js";
34
+ export { Thumbnail as SearchbarThumbnail } from "./primitives/Thumbnail.js";
35
+ // ─── Context hook (advanced usage) ───────────────────────────────────────────
36
+ export { useSearchbarContext, useSearchbarStore, useSearchbarState } from "./context.js";
37
+ export { useEtoileSearch, useSearch } from "./hooks/useEtoileSearch.js";
@@ -0,0 +1,34 @@
1
+ import * as React from "react";
2
+ export type SearchbarContentProps = {
3
+ /** Accessible label for the dialog */
4
+ "aria-label"?: string;
5
+ className?: string;
6
+ asChild?: boolean;
7
+ children: React.ReactNode;
8
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "role">;
9
+ /**
10
+ * Floating content panel for command palette / modal mode.
11
+ *
12
+ * Implements `role="dialog"` with `aria-modal`, focus trapping (focus stays
13
+ * inside when open), and restores focus to the Trigger on close.
14
+ *
15
+ * Height animates smoothly when results appear or disappear via ResizeObserver.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <Searchbar.Portal>
20
+ * <Searchbar.Overlay />
21
+ * <Searchbar.Content aria-label="Search">
22
+ * <Searchbar.ModalInput />
23
+ * <Searchbar.List>…</Searchbar.List>
24
+ * </Searchbar.Content>
25
+ * </Searchbar.Portal>
26
+ * ```
27
+ */
28
+ export declare const Content: React.ForwardRefExoticComponent<{
29
+ /** Accessible label for the dialog */
30
+ "aria-label"?: string;
31
+ className?: string;
32
+ asChild?: boolean;
33
+ children: React.ReactNode;
34
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "role"> & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { useSearchbarContext, useSearchbarStore, SearchbarHideListWhenQueryEmptyContext, } from "../context.js";
4
+ import { Slot } from "../utils/slot.js";
5
+ const PRESENCE_DURATION_MS = 300;
6
+ /**
7
+ * Floating content panel for command palette / modal mode.
8
+ *
9
+ * Implements `role="dialog"` with `aria-modal`, focus trapping (focus stays
10
+ * inside when open), and restores focus to the Trigger on close.
11
+ *
12
+ * Height animates smoothly when results appear or disappear via ResizeObserver.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <Searchbar.Portal>
17
+ * <Searchbar.Overlay />
18
+ * <Searchbar.Content aria-label="Search">
19
+ * <Searchbar.ModalInput />
20
+ * <Searchbar.List>…</Searchbar.List>
21
+ * </Searchbar.Content>
22
+ * </Searchbar.Portal>
23
+ * ```
24
+ */
25
+ export const Content = React.forwardRef(({ "aria-label": ariaLabel = "Search", className, asChild = false, children, ...props }, forwardedRef) => {
26
+ const { store, rootId, rootClassName, handleKeyDown, triggerRef } = useSearchbarContext();
27
+ const isOpen = useSearchbarStore(store, (s) => s.open);
28
+ const [present, setPresent] = React.useState(isOpen);
29
+ const contentRef = React.useRef(null);
30
+ const innerRef = React.useRef(null);
31
+ const mergedClassName = [rootClassName, className].filter(Boolean).join(" ") || undefined;
32
+ // Focus first focusable on open; restore focus to Trigger on close; trap focus
33
+ React.useEffect(() => {
34
+ if (!isOpen)
35
+ return;
36
+ const node = contentRef.current;
37
+ const getFocusables = () => Array.from(node?.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])') ?? []);
38
+ const focusable = getFocusables()[0];
39
+ focusable?.focus();
40
+ const handleFocusIn = (e) => {
41
+ if (!node?.contains(e.target)) {
42
+ e.preventDefault();
43
+ getFocusables()[0]?.focus();
44
+ }
45
+ };
46
+ document.addEventListener("focusin", handleFocusIn);
47
+ return () => {
48
+ document.removeEventListener("focusin", handleFocusIn);
49
+ triggerRef?.current?.focus();
50
+ };
51
+ }, [isOpen, triggerRef]);
52
+ React.useEffect(() => {
53
+ if (isOpen) {
54
+ setPresent(true);
55
+ return;
56
+ }
57
+ const timeout = window.setTimeout(() => setPresent(false), PRESENCE_DURATION_MS);
58
+ return () => window.clearTimeout(timeout);
59
+ }, [isOpen]);
60
+ // Animate height via ResizeObserver watching the inner content wrapper.
61
+ // The first measurement is applied instantly (no transition) so the open
62
+ // animation isn't interrupted. Subsequent changes (results appearing /
63
+ // disappearing) are handled by the CSS transition on the outer panel.
64
+ React.useLayoutEffect(() => {
65
+ const inner = innerRef.current;
66
+ const outer = contentRef.current;
67
+ if (!inner || !outer)
68
+ return;
69
+ const outerStyle = getComputedStyle(outer);
70
+ const paddingY = parseFloat(outerStyle.paddingTop) + parseFloat(outerStyle.paddingBottom);
71
+ let initial = true;
72
+ const observer = new ResizeObserver(([entry]) => {
73
+ const targetHeight = entry.contentRect.height + paddingY;
74
+ if (initial) {
75
+ // Set instantly on first render — don't fight the open animation.
76
+ outer.style.transition = "none";
77
+ outer.style.height = `${targetHeight}px`;
78
+ requestAnimationFrame(() => {
79
+ outer.style.transition = "";
80
+ initial = false;
81
+ });
82
+ }
83
+ else {
84
+ outer.style.height = `${targetHeight}px`;
85
+ }
86
+ });
87
+ observer.observe(inner);
88
+ return () => {
89
+ observer.disconnect();
90
+ outer.style.height = "";
91
+ outer.style.transition = "";
92
+ };
93
+ }, [present]);
94
+ if (!present)
95
+ return null;
96
+ const Comp = asChild ? Slot : "div";
97
+ return (_jsx(SearchbarHideListWhenQueryEmptyContext.Provider, { value: true, children: _jsx(Comp, { ...props, ref: (node) => {
98
+ contentRef.current = node;
99
+ if (typeof forwardedRef === "function")
100
+ forwardedRef(node);
101
+ else if (forwardedRef)
102
+ forwardedRef.current = node;
103
+ }, role: "dialog", "aria-modal": "true", "aria-label": ariaLabel, className: mergedClassName, "data-state": isOpen ? "open" : "closed", "data-slot": "searchbar-content", "data-searchbar-root": rootId, onKeyDown: (event) => {
104
+ props.onKeyDown?.(event);
105
+ handleKeyDown(event);
106
+ }, children: _jsx("div", { ref: innerRef, "data-slot": "searchbar-content-inner", children: children }) }) }));
107
+ });
108
+ Content.displayName = "Searchbar.Content";
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+ export type SearchbarEmptyProps = {
3
+ /** Defaults to "No results." */
4
+ children?: React.ReactNode;
5
+ className?: string;
6
+ asChild?: boolean;
7
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "role">;
8
+ /**
9
+ * Renders only when the list is open, query is non-empty, no items match the
10
+ * current filter, and a search is not in progress.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <Searchbar.List>
15
+ * {items.map(…)}
16
+ * <Searchbar.Empty>No paintings found.</Searchbar.Empty>
17
+ * </Searchbar.List>
18
+ * ```
19
+ */
20
+ export declare const Empty: React.ForwardRefExoticComponent<{
21
+ /** Defaults to "No results." */
22
+ children?: React.ReactNode;
23
+ className?: string;
24
+ asChild?: boolean;
25
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "role"> & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { useSearchbarContext, useSearchbarStore } from "../context.js";
4
+ import { Slot } from "../utils/slot.js";
5
+ /**
6
+ * Renders only when the list is open, query is non-empty, no items match the
7
+ * current filter, and a search is not in progress.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * <Searchbar.List>
12
+ * {items.map(…)}
13
+ * <Searchbar.Empty>No paintings found.</Searchbar.Empty>
14
+ * </Searchbar.List>
15
+ * ```
16
+ */
17
+ export const Empty = React.forwardRef(({ children = "No results.", className, asChild = false, ...props }, forwardedRef) => {
18
+ const { store } = useSearchbarContext();
19
+ const show = useSearchbarStore(store, (s) => s.open && s.query.trim() !== "" && s.filteredValues.length === 0 && !s.isLoading);
20
+ if (!show)
21
+ return null;
22
+ const Comp = asChild ? Slot : "div";
23
+ return (_jsx(Comp, { ...props, ref: forwardedRef, role: "status", className: className, "data-slot": "searchbar-empty", "data-state": "empty", children: children }));
24
+ });
25
+ Empty.displayName = "Searchbar.Empty";
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ export type SearchbarErrorProps = {
3
+ /**
4
+ * Content to render. Can be a node or a render function that receives the
5
+ * current error value.
6
+ */
7
+ children?: React.ReactNode | ((error: unknown) => React.ReactNode);
8
+ className?: string;
9
+ asChild?: boolean;
10
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "role" | "children">;
11
+ /**
12
+ * Renders only when `error` is set on Searchbar.Root.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <Searchbar.Error>
17
+ * {(err) => `Search failed: ${String(err)}`}
18
+ * </Searchbar.Error>
19
+ * ```
20
+ */
21
+ export declare const Error: React.ForwardRefExoticComponent<{
22
+ /**
23
+ * Content to render. Can be a node or a render function that receives the
24
+ * current error value.
25
+ */
26
+ children?: React.ReactNode | ((error: unknown) => React.ReactNode);
27
+ className?: string;
28
+ asChild?: boolean;
29
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "role"> & React.RefAttributes<HTMLDivElement>>;