@etoile-dev/react 0.1.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.
@@ -0,0 +1,36 @@
1
+ export type SearchResultThumbnailProps = {
2
+ /** Image source URL (defaults to result.metadata.thumbnailUrl) */
3
+ src?: string;
4
+ /** Alt text for the image (defaults to result.title) */
5
+ alt?: string;
6
+ /** Width and height in pixels (default: 40) */
7
+ size?: number;
8
+ /** CSS class name for styling */
9
+ className?: string;
10
+ };
11
+ /**
12
+ * Thumbnail image for search results with automatic source detection.
13
+ *
14
+ * Automatically uses `metadata.thumbnailUrl` if available. Returns null
15
+ * if no image source is found. Must be used inside SearchResults.
16
+ *
17
+ * @param props - Component props
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <SearchResults>
22
+ * {(result) => (
23
+ * <SearchResult>
24
+ * <SearchResultThumbnail />
25
+ * <span>{result.title}</span>
26
+ * </SearchResult>
27
+ * )}
28
+ * </SearchResults>
29
+ * ```
30
+ *
31
+ * @example With custom size and styling
32
+ * ```tsx
33
+ * <SearchResultThumbnail size={48} className="rounded-full" />
34
+ * ```
35
+ */
36
+ export declare const SearchResultThumbnail: ({ src, alt, size, className, }: SearchResultThumbnailProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { SearchResultDataContext } from "./SearchResults.js";
4
+ /**
5
+ * Thumbnail image for search results with automatic source detection.
6
+ *
7
+ * Automatically uses `metadata.thumbnailUrl` if available. Returns null
8
+ * if no image source is found. Must be used inside SearchResults.
9
+ *
10
+ * @param props - Component props
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <SearchResults>
15
+ * {(result) => (
16
+ * <SearchResult>
17
+ * <SearchResultThumbnail />
18
+ * <span>{result.title}</span>
19
+ * </SearchResult>
20
+ * )}
21
+ * </SearchResults>
22
+ * ```
23
+ *
24
+ * @example With custom size and styling
25
+ * ```tsx
26
+ * <SearchResultThumbnail size={48} className="rounded-full" />
27
+ * ```
28
+ */
29
+ export const SearchResultThumbnail = ({ src, alt, size = 40, className, }) => {
30
+ const result = React.useContext(SearchResultDataContext);
31
+ const imageSrc = src ?? result?.metadata?.thumbnailUrl;
32
+ const imageAlt = alt ?? result?.title ?? "";
33
+ if (!imageSrc) {
34
+ return null;
35
+ }
36
+ return (_jsx("img", { src: imageSrc, alt: imageAlt, width: size, height: size, className: className, draggable: false }));
37
+ };
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ import type { SearchResultData } from "../types.js";
3
+ export type SearchResultsProps = {
4
+ /** CSS class name for the results container */
5
+ className?: string;
6
+ /** Render function that receives each search result */
7
+ children: (result: SearchResultData) => React.ReactNode;
8
+ };
9
+ export declare const SearchResultIndexContext: React.Context<number | null>;
10
+ export declare const SearchResultDataContext: React.Context<SearchResultData | null>;
11
+ /**
12
+ * Container component for rendering search results with keyboard navigation.
13
+ *
14
+ * Accepts a render function that receives each result. Automatically hides
15
+ * when query is empty or no results found. Includes ARIA listbox role.
16
+ *
17
+ * @param props - Component props
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <SearchResults>
22
+ * {(result) => <SearchResult>{result.title}</SearchResult>}
23
+ * </SearchResults>
24
+ * ```
25
+ *
26
+ * @example With styling and metadata
27
+ * ```tsx
28
+ * <SearchResults className="mt-2 border rounded-lg">
29
+ * {(result) => (
30
+ * <SearchResult className="p-4 hover:bg-gray-50">
31
+ * <h3>{result.title}</h3>
32
+ * <p>{result.metadata?.artist}</p>
33
+ * </SearchResult>
34
+ * )}
35
+ * </SearchResults>
36
+ * ```
37
+ */
38
+ export declare const SearchResults: ({ className, children }: SearchResultsProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { useSearchContext } from "../context/SearchContext.js";
4
+ export const SearchResultIndexContext = React.createContext(null);
5
+ export const SearchResultDataContext = React.createContext(null);
6
+ /**
7
+ * Container component for rendering search results with keyboard navigation.
8
+ *
9
+ * Accepts a render function that receives each result. Automatically hides
10
+ * when query is empty or no results found. Includes ARIA listbox role.
11
+ *
12
+ * @param props - Component props
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <SearchResults>
17
+ * {(result) => <SearchResult>{result.title}</SearchResult>}
18
+ * </SearchResults>
19
+ * ```
20
+ *
21
+ * @example With styling and metadata
22
+ * ```tsx
23
+ * <SearchResults className="mt-2 border rounded-lg">
24
+ * {(result) => (
25
+ * <SearchResult className="p-4 hover:bg-gray-50">
26
+ * <h3>{result.title}</h3>
27
+ * <p>{result.metadata?.artist}</p>
28
+ * </SearchResult>
29
+ * )}
30
+ * </SearchResults>
31
+ * ```
32
+ */
33
+ export const SearchResults = ({ className, children }) => {
34
+ const { query, results, selectedIndex, listboxId, getResultNode } = useSearchContext();
35
+ const listboxRef = React.useRef(null);
36
+ React.useEffect(() => {
37
+ if (selectedIndex < 0) {
38
+ return;
39
+ }
40
+ const activeNode = getResultNode(selectedIndex);
41
+ activeNode?.scrollIntoView({ block: "nearest" });
42
+ if (activeNode &&
43
+ listboxRef.current &&
44
+ listboxRef.current.contains(document.activeElement)) {
45
+ activeNode.focus();
46
+ }
47
+ }, [getResultNode, selectedIndex]);
48
+ if (query.trim() === "" || results.length === 0) {
49
+ return null;
50
+ }
51
+ return (_jsx("div", { role: "listbox", id: listboxId, className: className, ref: listboxRef, children: results.map((result, index) => (_jsx(SearchResultIndexContext.Provider, { value: index, children: _jsx(SearchResultDataContext.Provider, { value: result, children: children(result) }) }, result.external_id))) }));
52
+ };
@@ -0,0 +1,43 @@
1
+ import * as React from "react";
2
+ export type SearchRootProps = {
3
+ /** Your Étoile API key. Get one at https://etoile.dev */
4
+ apiKey: string;
5
+ /** Collections to search in (e.g., ["paintings", "artists"]) */
6
+ collections: string[];
7
+ /** Maximum number of results to return (default: 10) */
8
+ limit?: number;
9
+ /** Debounce delay in milliseconds before triggering search (default: 100) */
10
+ debounceMs?: number;
11
+ /** Whether the search input should be focused on mount (default: false) */
12
+ autoFocus?: boolean;
13
+ /** Additional CSS class name (appended to "etoile-search") */
14
+ className?: string;
15
+ /** Child components (SearchInput, SearchResults, etc.) */
16
+ children: React.ReactNode;
17
+ };
18
+ /**
19
+ * Root component for Étoile search that provides context to all child components.
20
+ *
21
+ * Manages search state, keyboard navigation, result selection, and accessibility.
22
+ * Automatically applies `etoile-search` class for styling when using the theme.
23
+ *
24
+ * @param props - Component props
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <SearchRoot apiKey="your-api-key" collections={["paintings"]}>
29
+ * <SearchInput />
30
+ * <SearchResults>
31
+ * {(result) => <SearchResult>{result.title}</SearchResult>}
32
+ * </SearchResults>
33
+ * </SearchRoot>
34
+ * ```
35
+ *
36
+ * @example Dark mode
37
+ * ```tsx
38
+ * <SearchRoot apiKey="your-api-key" collections={["paintings"]} className="dark">
39
+ * ...
40
+ * </SearchRoot>
41
+ * ```
42
+ */
43
+ export declare const SearchRoot: ({ apiKey, collections, limit, debounceMs, autoFocus, className, children, }: SearchRootProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { SearchProvider } from "../context/SearchContext.js";
4
+ import { useSearch } from "../hooks/useSearch.js";
5
+ /**
6
+ * Root component for Étoile search that provides context to all child components.
7
+ *
8
+ * Manages search state, keyboard navigation, result selection, and accessibility.
9
+ * Automatically applies `etoile-search` class for styling when using the theme.
10
+ *
11
+ * @param props - Component props
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * <SearchRoot apiKey="your-api-key" collections={["paintings"]}>
16
+ * <SearchInput />
17
+ * <SearchResults>
18
+ * {(result) => <SearchResult>{result.title}</SearchResult>}
19
+ * </SearchResults>
20
+ * </SearchRoot>
21
+ * ```
22
+ *
23
+ * @example Dark mode
24
+ * ```tsx
25
+ * <SearchRoot apiKey="your-api-key" collections={["paintings"]} className="dark">
26
+ * ...
27
+ * </SearchRoot>
28
+ * ```
29
+ */
30
+ export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus = false, className, children, }) => {
31
+ const search = useSearch({ apiKey, collections, limit, debounceMs });
32
+ const listboxId = React.useId();
33
+ const resultRefs = React.useRef(new Map());
34
+ const registerResult = (index, node) => {
35
+ resultRefs.current.set(index, node);
36
+ };
37
+ const getResultNode = (index) => {
38
+ return resultRefs.current.get(index) ?? null;
39
+ };
40
+ const getResultId = (index) => `${listboxId}-option-${index}`;
41
+ const selectActiveResult = () => {
42
+ if (search.selectedIndex < 0) {
43
+ return;
44
+ }
45
+ const node = getResultNode(search.selectedIndex);
46
+ if (node && "click" in node) {
47
+ node.click();
48
+ }
49
+ };
50
+ const handleKeyDown = (event) => {
51
+ if (event.key === "ArrowDown") {
52
+ event.preventDefault();
53
+ search.setSelectedIndex(search.selectedIndex + 1);
54
+ return;
55
+ }
56
+ if (event.key === "ArrowUp") {
57
+ event.preventDefault();
58
+ search.setSelectedIndex(search.selectedIndex - 1);
59
+ return;
60
+ }
61
+ if (event.key === "Enter") {
62
+ if (search.selectedIndex >= 0) {
63
+ event.preventDefault();
64
+ selectActiveResult();
65
+ }
66
+ return;
67
+ }
68
+ if (event.key === "Escape") {
69
+ event.preventDefault();
70
+ search.clear();
71
+ }
72
+ };
73
+ const value = React.useMemo(() => ({
74
+ ...search,
75
+ listboxId,
76
+ getResultId,
77
+ registerResult,
78
+ getResultNode,
79
+ selectActiveResult,
80
+ handleKeyDown,
81
+ autoFocus,
82
+ }), [search, listboxId, autoFocus]);
83
+ return (_jsx(SearchProvider, { value: value, children: _jsx("div", { className: className ? `etoile-search ${className}` : "etoile-search", children: children }) }));
84
+ };
@@ -0,0 +1,51 @@
1
+ import * as React from "react";
2
+ import type { SearchResultData } from "../types.js";
3
+ /**
4
+ * Internal context value for search state and controls.
5
+ * @internal
6
+ */
7
+ type SearchContextValue = {
8
+ query: string;
9
+ setQuery: (q: string) => void;
10
+ results: SearchResultData[];
11
+ isLoading: boolean;
12
+ selectedIndex: number;
13
+ setSelectedIndex: (i: number) => void;
14
+ clear: () => void;
15
+ listboxId: string;
16
+ getResultId: (index: number) => string;
17
+ registerResult: (index: number, node: HTMLElement | null) => void;
18
+ getResultNode: (index: number) => HTMLElement | null;
19
+ selectActiveResult: () => void;
20
+ handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
21
+ autoFocus: boolean;
22
+ };
23
+ export declare const SearchProvider: React.Provider<SearchContextValue | null>;
24
+ /**
25
+ * Hook to access search context from child components.
26
+ *
27
+ * Must be used within SearchRoot component. Provides access to search state,
28
+ * results, loading status, and keyboard navigation controls.
29
+ *
30
+ * @throws Error if used outside of SearchRoot
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * import { useSearchContext } from "@etoile-dev/react";
35
+ *
36
+ * function CustomSearchComponent() {
37
+ * const { query, results, isLoading, clear } = useSearchContext();
38
+ *
39
+ * return (
40
+ * <div>
41
+ * <p>Searching for: {query}</p>
42
+ * {isLoading && <span>Loading...</span>}
43
+ * <button onClick={clear}>Clear</button>
44
+ * <p>Found {results.length} results</p>
45
+ * </div>
46
+ * );
47
+ * }
48
+ * ```
49
+ */
50
+ export declare const useSearchContext: () => SearchContextValue;
51
+ export {};
@@ -0,0 +1,36 @@
1
+ import * as React from "react";
2
+ const SearchContext = React.createContext(null);
3
+ export const SearchProvider = SearchContext.Provider;
4
+ /**
5
+ * Hook to access search context from child components.
6
+ *
7
+ * Must be used within SearchRoot component. Provides access to search state,
8
+ * results, loading status, and keyboard navigation controls.
9
+ *
10
+ * @throws Error if used outside of SearchRoot
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * import { useSearchContext } from "@etoile-dev/react";
15
+ *
16
+ * function CustomSearchComponent() {
17
+ * const { query, results, isLoading, clear } = useSearchContext();
18
+ *
19
+ * return (
20
+ * <div>
21
+ * <p>Searching for: {query}</p>
22
+ * {isLoading && <span>Loading...</span>}
23
+ * <button onClick={clear}>Clear</button>
24
+ * <p>Found {results.length} results</p>
25
+ * </div>
26
+ * );
27
+ * }
28
+ * ```
29
+ */
30
+ export const useSearchContext = () => {
31
+ const ctx = React.useContext(SearchContext);
32
+ if (!ctx) {
33
+ throw new Error("Search components must be used within SearchRoot.");
34
+ }
35
+ return ctx;
36
+ };
@@ -0,0 +1,55 @@
1
+ import type { SearchResultData } from "../types.js";
2
+ export type UseSearchOptions = {
3
+ /** Your Étoile API key. Get one at https://etoile.dev */
4
+ apiKey: string;
5
+ /** Collections to search in (e.g., ["paintings", "artists"]) */
6
+ collections: string[];
7
+ /** Maximum number of results to return (default: 10) */
8
+ limit?: number;
9
+ /** Debounce delay in milliseconds before triggering search (default: 100) */
10
+ debounceMs?: number;
11
+ };
12
+ export type UseSearchReturn = {
13
+ /** Current search query string */
14
+ query: string;
15
+ /** Update the search query */
16
+ setQuery: (q: string) => void;
17
+ /** Array of search results */
18
+ results: SearchResultData[];
19
+ /** Whether a search is currently in progress */
20
+ isLoading: boolean;
21
+ /** Index of the currently selected result (-1 if none) */
22
+ selectedIndex: number;
23
+ /** Set the selected result index */
24
+ setSelectedIndex: (i: number) => void;
25
+ /** Clear the search query and results */
26
+ clear: () => void;
27
+ };
28
+ /**
29
+ * React hook for managing search state with automatic debouncing and API integration.
30
+ *
31
+ * Handles search queries, debouncing, API calls, loading states, and result management.
32
+ * Works seamlessly with Étoile's search API.
33
+ *
34
+ * @param options - Search configuration options
35
+ * @returns Search state and control functions
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const { query, setQuery, results } = useSearch({
40
+ * apiKey: "your-api-key",
41
+ * collections: ["paintings"],
42
+ * });
43
+ * ```
44
+ *
45
+ * @example With all options
46
+ * ```tsx
47
+ * const { query, setQuery, results, isLoading, clear } = useSearch({
48
+ * apiKey: "your-api-key",
49
+ * collections: ["paintings", "artists"],
50
+ * limit: 20,
51
+ * debounceMs: 150,
52
+ * });
53
+ * ```
54
+ */
55
+ export declare const useSearch: ({ apiKey, collections, limit, debounceMs, }: UseSearchOptions) => UseSearchReturn;
@@ -0,0 +1,116 @@
1
+ import * as React from "react";
2
+ import { Etoile } from "@etoile-dev/client";
3
+ const clampIndex = (index, length) => {
4
+ if (length <= 0) {
5
+ return -1;
6
+ }
7
+ if (index < 0) {
8
+ return 0;
9
+ }
10
+ if (index > length - 1) {
11
+ return length - 1;
12
+ }
13
+ return index;
14
+ };
15
+ /**
16
+ * React hook for managing search state with automatic debouncing and API integration.
17
+ *
18
+ * Handles search queries, debouncing, API calls, loading states, and result management.
19
+ * Works seamlessly with Étoile's search API.
20
+ *
21
+ * @param options - Search configuration options
22
+ * @returns Search state and control functions
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * const { query, setQuery, results } = useSearch({
27
+ * apiKey: "your-api-key",
28
+ * collections: ["paintings"],
29
+ * });
30
+ * ```
31
+ *
32
+ * @example With all options
33
+ * ```tsx
34
+ * const { query, setQuery, results, isLoading, clear } = useSearch({
35
+ * apiKey: "your-api-key",
36
+ * collections: ["paintings", "artists"],
37
+ * limit: 20,
38
+ * debounceMs: 150,
39
+ * });
40
+ * ```
41
+ */
42
+ export const useSearch = ({ apiKey, collections, limit = 10, debounceMs = 100, }) => {
43
+ const [query, setQuery] = React.useState("");
44
+ const [debouncedQuery, setDebouncedQuery] = React.useState("");
45
+ const [results, setResults] = React.useState([]);
46
+ const [isLoading, setIsLoading] = React.useState(false);
47
+ const [selectedIndex, setSelectedIndexState] = React.useState(-1);
48
+ React.useEffect(() => {
49
+ const handle = setTimeout(() => {
50
+ setDebouncedQuery(query);
51
+ }, debounceMs);
52
+ return () => clearTimeout(handle);
53
+ }, [query, debounceMs]);
54
+ React.useEffect(() => {
55
+ let isActive = true;
56
+ if (debouncedQuery.trim() === "") {
57
+ setResults([]);
58
+ setIsLoading(false);
59
+ return () => {
60
+ isActive = false;
61
+ };
62
+ }
63
+ const runSearch = async () => {
64
+ setIsLoading(true);
65
+ try {
66
+ const client = new Etoile({ apiKey });
67
+ const response = await client.search({
68
+ collections,
69
+ query: debouncedQuery,
70
+ limit,
71
+ });
72
+ if (!isActive) {
73
+ return;
74
+ }
75
+ setResults(Array.isArray(response.results) ? response.results : []);
76
+ }
77
+ catch {
78
+ if (!isActive) {
79
+ return;
80
+ }
81
+ setResults([]);
82
+ }
83
+ finally {
84
+ if (isActive) {
85
+ setIsLoading(false);
86
+ }
87
+ }
88
+ };
89
+ runSearch();
90
+ return () => {
91
+ isActive = false;
92
+ };
93
+ }, [apiKey, collections, debouncedQuery, limit]);
94
+ React.useEffect(() => {
95
+ setSelectedIndexState((current) => clampIndex(current, results.length));
96
+ }, [results.length]);
97
+ const setSelectedIndex = React.useCallback((index) => {
98
+ setSelectedIndexState(clampIndex(index, results.length));
99
+ }, [results.length]);
100
+ const clear = React.useCallback(() => {
101
+ setQuery("");
102
+ setDebouncedQuery("");
103
+ setResults([]);
104
+ setIsLoading(false);
105
+ setSelectedIndexState(-1);
106
+ }, []);
107
+ return {
108
+ query,
109
+ setQuery,
110
+ results,
111
+ isLoading,
112
+ selectedIndex,
113
+ setSelectedIndex,
114
+ clear,
115
+ };
116
+ };
@@ -0,0 +1,20 @@
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";
11
+ 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";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
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";