@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,122 @@
|
|
|
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
|
+
* Explicit metadata filters applied to results.
|
|
17
|
+
* Mutually exclusive with `autoFilters`.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* filters={[
|
|
22
|
+
* { key: "artist", operator: "eq", value: "Vincent van Gogh" },
|
|
23
|
+
* { key: "year", operator: "lte", value: 1900 },
|
|
24
|
+
* ]}
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
filters?: SearchFilter[];
|
|
28
|
+
/**
|
|
29
|
+
* When true, the AI extracts filters from the query automatically.
|
|
30
|
+
* Returns `appliedFilters` and `refinedQuery` in the result.
|
|
31
|
+
* Mutually exclusive with `filters`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* // Query: "Van Gogh paintings after 1888"
|
|
36
|
+
* // → refinedQuery: "paintings"
|
|
37
|
+
* // → appliedFilters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }, ...]
|
|
38
|
+
* autoFilters={true}
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
autoFilters?: boolean;
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
};
|
|
44
|
+
export type UseEtoileSearchReturn = {
|
|
45
|
+
/** Results returned by the last successful search. Empty while loading or on error. */
|
|
46
|
+
results: SearchResult[];
|
|
47
|
+
/** True while a fetch is in flight. */
|
|
48
|
+
isLoading: boolean;
|
|
49
|
+
/** Set when the last request failed; null otherwise. */
|
|
50
|
+
error: unknown;
|
|
51
|
+
/** Filters that were applied. Populated when `filters` or `autoFilters` is used. */
|
|
52
|
+
appliedFilters?: SearchFilter[];
|
|
53
|
+
/**
|
|
54
|
+
* Query rewritten by the AI after extracting filters.
|
|
55
|
+
* Only populated when `autoFilters` is `true`.
|
|
56
|
+
*/
|
|
57
|
+
refinedQuery?: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Fetch live Etoile results for a query.
|
|
61
|
+
*
|
|
62
|
+
* Handles debouncing, in-flight cancellation, loading, and error state.
|
|
63
|
+
* Use this hook when composing headless primitives with your own layout.
|
|
64
|
+
*
|
|
65
|
+
* @param options - Hook options.
|
|
66
|
+
* @param options.apiKey - Your Etoile API key.
|
|
67
|
+
* @param options.collections - Collections to search in (e.g. `["paintings", "artists"]`).
|
|
68
|
+
* @param options.query - Current query string (controlled externally).
|
|
69
|
+
* @param options.limit - Maximum results to return (default: `10`).
|
|
70
|
+
* @param options.offset - Number of results to skip for pagination (default: `0`).
|
|
71
|
+
* @param options.debounceMs - Debounce delay in ms before firing the request (default: `100`).
|
|
72
|
+
* @param options.filters - Explicit metadata filters. Mutually exclusive with `autoFilters`.
|
|
73
|
+
* @param options.autoFilters - When `true`, the AI extracts filters from the query automatically.
|
|
74
|
+
* @returns Current search state: `results`, `isLoading`, `error`, `appliedFilters`, `refinedQuery`.
|
|
75
|
+
*
|
|
76
|
+
* @example Basic usage
|
|
77
|
+
* ```tsx
|
|
78
|
+
* const [query, setQuery] = useState("");
|
|
79
|
+
* const { results, isLoading } = useEtoileSearch({
|
|
80
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
81
|
+
* collections: ["paintings"],
|
|
82
|
+
* query,
|
|
83
|
+
* });
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* @example With explicit filters
|
|
87
|
+
* ```tsx
|
|
88
|
+
* const { results } = useEtoileSearch({
|
|
89
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
90
|
+
* collections: ["paintings"],
|
|
91
|
+
* query,
|
|
92
|
+
* filters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }],
|
|
93
|
+
* });
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* @example With AI-powered auto-filters
|
|
97
|
+
* ```tsx
|
|
98
|
+
* // Query: "Van Gogh paintings after 1888"
|
|
99
|
+
* // → refinedQuery: "paintings"
|
|
100
|
+
* // → appliedFilters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }, ...]
|
|
101
|
+
* const { results, refinedQuery, appliedFilters } = useEtoileSearch({
|
|
102
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
103
|
+
* collections: ["paintings"],
|
|
104
|
+
* query,
|
|
105
|
+
* autoFilters: true,
|
|
106
|
+
* });
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export declare function useEtoileSearch({ apiKey, collections, query, limit, offset, debounceMs, filters, autoFilters, baseUrl, }: UseEtoileSearchOptions): UseEtoileSearchReturn;
|
|
110
|
+
/**
|
|
111
|
+
* Alias of `useEtoileSearch` for ergonomic imports in examples.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* const { results } = useSearch({
|
|
116
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
117
|
+
* collections: ["paintings"],
|
|
118
|
+
* query,
|
|
119
|
+
* });
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export declare const useSearch: typeof useEtoileSearch;
|
|
@@ -0,0 +1,138 @@
|
|
|
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.filters - Explicit metadata filters. Mutually exclusive with `autoFilters`.
|
|
17
|
+
* @param options.autoFilters - When `true`, the AI extracts filters from the query automatically.
|
|
18
|
+
* @returns Current search state: `results`, `isLoading`, `error`, `appliedFilters`, `refinedQuery`.
|
|
19
|
+
*
|
|
20
|
+
* @example Basic usage
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const [query, setQuery] = useState("");
|
|
23
|
+
* const { results, isLoading } = useEtoileSearch({
|
|
24
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
25
|
+
* collections: ["paintings"],
|
|
26
|
+
* query,
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @example With explicit filters
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const { results } = useEtoileSearch({
|
|
33
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
34
|
+
* collections: ["paintings"],
|
|
35
|
+
* query,
|
|
36
|
+
* filters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }],
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example With AI-powered auto-filters
|
|
41
|
+
* ```tsx
|
|
42
|
+
* // Query: "Van Gogh paintings after 1888"
|
|
43
|
+
* // → refinedQuery: "paintings"
|
|
44
|
+
* // → appliedFilters: [{ key: "artist", operator: "eq", value: "Vincent van Gogh" }, ...]
|
|
45
|
+
* const { results, refinedQuery, appliedFilters } = useEtoileSearch({
|
|
46
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
47
|
+
* collections: ["paintings"],
|
|
48
|
+
* query,
|
|
49
|
+
* autoFilters: true,
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function useEtoileSearch({ apiKey, collections, query, limit = 10, offset, debounceMs = 100, filters, autoFilters, baseUrl, }) {
|
|
54
|
+
const [results, setResults] = React.useState([]);
|
|
55
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
56
|
+
const [error, setError] = React.useState(null);
|
|
57
|
+
const [appliedFilters, setAppliedFilters] = React.useState(undefined);
|
|
58
|
+
const [refinedQuery, setRefinedQuery] = React.useState(undefined);
|
|
59
|
+
const client = React.useMemo(() => new Etoile({ apiKey, baseUrl }), [apiKey, baseUrl]);
|
|
60
|
+
// Stable refs so the effect dep array doesn't need filters/autoFilters arrays
|
|
61
|
+
const filtersRef = React.useRef(filters);
|
|
62
|
+
filtersRef.current = filters;
|
|
63
|
+
const autoFiltersRef = React.useRef(autoFilters);
|
|
64
|
+
autoFiltersRef.current = autoFilters;
|
|
65
|
+
// Debounce the raw query
|
|
66
|
+
const [debouncedQuery, setDebouncedQuery] = React.useState(query);
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
const handle = setTimeout(() => setDebouncedQuery(query), debounceMs);
|
|
69
|
+
return () => clearTimeout(handle);
|
|
70
|
+
}, [query, debounceMs]);
|
|
71
|
+
// Show loading as soon as user types (avoids "no results" flash before debounced fetch)
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
if (query.trim() !== "") {
|
|
74
|
+
setIsLoading(true);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
setIsLoading(false);
|
|
78
|
+
}
|
|
79
|
+
}, [query]);
|
|
80
|
+
// Fetch on debounced query / filter change
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
if (debouncedQuery.trim() === "") {
|
|
83
|
+
setResults([]);
|
|
84
|
+
setAppliedFilters(undefined);
|
|
85
|
+
setRefinedQuery(undefined);
|
|
86
|
+
setIsLoading(false);
|
|
87
|
+
setError(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let active = true;
|
|
91
|
+
setError(null);
|
|
92
|
+
client
|
|
93
|
+
.search({
|
|
94
|
+
collections,
|
|
95
|
+
query: debouncedQuery,
|
|
96
|
+
limit,
|
|
97
|
+
...(offset !== undefined && { offset }),
|
|
98
|
+
...(filtersRef.current !== undefined && { filters: filtersRef.current }),
|
|
99
|
+
...(autoFiltersRef.current !== undefined && { autoFilters: autoFiltersRef.current }),
|
|
100
|
+
})
|
|
101
|
+
.then((res) => {
|
|
102
|
+
if (!active)
|
|
103
|
+
return;
|
|
104
|
+
setResults(Array.isArray(res.results) ? res.results : []);
|
|
105
|
+
setAppliedFilters(res.appliedFilters);
|
|
106
|
+
setRefinedQuery(res.refinedQuery);
|
|
107
|
+
})
|
|
108
|
+
.catch((err) => {
|
|
109
|
+
if (!active)
|
|
110
|
+
return;
|
|
111
|
+
setResults([]);
|
|
112
|
+
setAppliedFilters(undefined);
|
|
113
|
+
setRefinedQuery(undefined);
|
|
114
|
+
setError(err);
|
|
115
|
+
})
|
|
116
|
+
.finally(() => {
|
|
117
|
+
if (active)
|
|
118
|
+
setIsLoading(false);
|
|
119
|
+
});
|
|
120
|
+
return () => {
|
|
121
|
+
active = false;
|
|
122
|
+
};
|
|
123
|
+
}, [client, collections, debouncedQuery, limit, offset]);
|
|
124
|
+
return { results, isLoading, error, appliedFilters, refinedQuery };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Alias of `useEtoileSearch` for ergonomic imports in examples.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```tsx
|
|
131
|
+
* const { results } = useSearch({
|
|
132
|
+
* apiKey: process.env.ETOILE_API_KEY!,
|
|
133
|
+
* collections: ["paintings"],
|
|
134
|
+
* query,
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export const useSearch = useEtoileSearch;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,20 +1,45 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
10
|
-
export {
|
|
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 {
|
|
13
|
-
export type {
|
|
14
|
-
export type {
|
|
15
|
-
export type {
|
|
16
|
-
export type {
|
|
17
|
-
export type {
|
|
18
|
-
export type {
|
|
19
|
-
export type {
|
|
20
|
-
export type {
|
|
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
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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>>;
|
|
@@ -0,0 +1,26 @@
|
|
|
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 `error` is set on Searchbar.Root.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <Searchbar.Error>
|
|
11
|
+
* {(err) => `Search failed: ${String(err)}`}
|
|
12
|
+
* </Searchbar.Error>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export const Error = React.forwardRef(({ children = "Something went wrong.", className, asChild = false, ...props }, forwardedRef) => {
|
|
16
|
+
const { store } = useSearchbarContext();
|
|
17
|
+
const error = useSearchbarStore(store, (s) => s.error);
|
|
18
|
+
if (!error)
|
|
19
|
+
return null;
|
|
20
|
+
const Comp = asChild ? Slot : "div";
|
|
21
|
+
const content = typeof children === "function"
|
|
22
|
+
? children(error)
|
|
23
|
+
: children;
|
|
24
|
+
return (_jsx(Comp, { ...props, ref: forwardedRef, role: "alert", className: className, "data-slot": "searchbar-error", "data-state": "error", children: content }));
|
|
25
|
+
});
|
|
26
|
+
Error.displayName = "Searchbar.Error";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type SearchbarGroupProps = {
|
|
3
|
+
/** Accessible label for the group */
|
|
4
|
+
label?: string;
|
|
5
|
+
/** Additional CSS class name */
|
|
6
|
+
className?: string;
|
|
7
|
+
asChild?: boolean;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
} & React.HTMLAttributes<HTMLDivElement>;
|
|
10
|
+
/**
|
|
11
|
+
* Groups related search items under a labelled section.
|
|
12
|
+
*
|
|
13
|
+
* Renders a heading and wraps children in an ARIA group role.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <Searchbar.Group label="Paintings">
|
|
18
|
+
* <Searchbar.Item value="starry-night">The Starry Night</Searchbar.Item>
|
|
19
|
+
* <Searchbar.Item value="irises">Irises</Searchbar.Item>
|
|
20
|
+
* </Searchbar.Group>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare const Group: React.ForwardRefExoticComponent<{
|
|
24
|
+
/** Accessible label for the group */
|
|
25
|
+
label?: string;
|
|
26
|
+
/** Additional CSS class name */
|
|
27
|
+
className?: string;
|
|
28
|
+
asChild?: boolean;
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>>;
|