@etoile-dev/react 0.1.2 → 0.2.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.
package/README.md CHANGED
@@ -254,7 +254,7 @@ Controlled input with ARIA combobox role.
254
254
  **Keyboard shortcuts:**
255
255
  - `ArrowUp` / `ArrowDown` — Navigate results
256
256
  - `Enter` — Select active result
257
- - `Escape` — Clear search
257
+ - `Escape` — Close results (press again to clear)
258
258
 
259
259
  ---
260
260
 
@@ -369,7 +369,7 @@ type SearchResultData = {
369
369
  ## Why @etoile-dev/react?
370
370
 
371
371
  - **Radix / shadcn-style primitives** — Composable and unstyled
372
- - **Accessibility built-in** — ARIA roles, keyboard navigation, focus management
372
+ - **Accessibility built-in** — ARIA combobox, keyboard navigation, focus management, click-outside dismiss
373
373
  - **Behavior, not appearance** — You own the design
374
374
  - **TypeScript-first** — Full type safety
375
375
  - **Zero dependencies** — Only React and @etoile-dev/client
package/dist/Search.d.ts CHANGED
@@ -13,6 +13,7 @@ export type SearchProps = {
13
13
  className?: string;
14
14
  /** Custom render function for each result (optional) */
15
15
  renderResult?: (result: SearchResultData) => React.ReactNode;
16
+ baseUrl?: string;
16
17
  };
17
18
  /**
18
19
  * All-in-one search component with sensible defaults.
@@ -33,4 +34,4 @@ export type SearchProps = {
33
34
  * <Search apiKey="your-api-key" collections={["paintings"]} className="dark" />
34
35
  * ```
35
36
  */
36
- export declare const Search: ({ apiKey, collections, limit, placeholder, className, renderResult, }: SearchProps) => import("react/jsx-runtime").JSX.Element;
37
+ export declare const Search: ({ apiKey, collections, limit, placeholder, className, renderResult, baseUrl, }: SearchProps) => import("react/jsx-runtime").JSX.Element;
package/dist/Search.js CHANGED
@@ -26,6 +26,6 @@ const DefaultResult = (result) => (_jsxs(SearchResult, { children: [_jsx(SearchR
26
26
  * <Search apiKey="your-api-key" collections={["paintings"]} className="dark" />
27
27
  * ```
28
28
  */
29
- export const Search = ({ apiKey, collections, limit, placeholder = "Search...", className, renderResult, }) => {
30
- return (_jsxs(SearchRoot, { apiKey: apiKey, collections: collections, limit: limit, className: className, children: [_jsxs("div", { className: "etoile-input-wrapper", children: [_jsx(SearchIcon, {}), _jsx(SearchInput, { placeholder: placeholder }), _jsx(SearchKbd, {})] }), _jsx(SearchResults, { children: renderResult ?? DefaultResult })] }));
29
+ export const Search = ({ apiKey, collections, limit, placeholder = "Search...", className, renderResult, baseUrl, }) => {
30
+ return (_jsxs(SearchRoot, { apiKey: apiKey, collections: collections, limit: limit, className: className, baseUrl: baseUrl, children: [_jsxs("div", { className: "etoile-input-wrapper", children: [_jsx(SearchIcon, {}), _jsx(SearchInput, { placeholder: placeholder }), _jsx(SearchKbd, {})] }), _jsx(SearchResults, { children: renderResult ?? DefaultResult })] }));
31
31
  };
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSearchContext } from "../context/SearchContext.js";
3
3
  /**
4
4
  * Search input component with built-in keyboard navigation and accessibility.
@@ -22,14 +22,30 @@ import { useSearchContext } from "../context/SearchContext.js";
22
22
  * ```
23
23
  */
24
24
  export const SearchInput = ({ placeholder, className }) => {
25
- const { query, setQuery, results, selectedIndex, setSelectedIndex, listboxId, getResultId, handleKeyDown, autoFocus, } = useSearchContext();
26
- const hasResults = results.length > 0;
27
- const activeId = selectedIndex >= 0 && hasResults ? getResultId(selectedIndex) : undefined;
28
- return (_jsx("input", { type: "text", placeholder: placeholder, className: className, value: query, autoFocus: autoFocus, role: "combobox", "aria-expanded": hasResults, "aria-controls": listboxId, "aria-activedescendant": activeId, onChange: (event) => {
29
- const nextValue = event.target.value;
30
- setQuery(nextValue);
31
- if (nextValue.trim() !== "") {
32
- setSelectedIndex(0);
33
- }
34
- }, onKeyDown: handleKeyDown }));
25
+ const { query, setQuery, results, isOpen, setOpen, selectedIndex, setSelectedIndex, listboxId, getResultId, handleKeyDown, autoFocus, } = useSearchContext();
26
+ const showResults = isOpen && results.length > 0;
27
+ const activeId = selectedIndex >= 0 && showResults ? getResultId(selectedIndex) : undefined;
28
+ return (_jsxs(_Fragment, { children: [_jsx("input", { type: "text", placeholder: placeholder, className: className, value: query, autoFocus: autoFocus, role: "combobox", "aria-expanded": showResults, "aria-controls": listboxId, "aria-activedescendant": activeId, "aria-autocomplete": "list", onChange: (event) => {
29
+ const nextValue = event.target.value;
30
+ setQuery(nextValue);
31
+ if (nextValue.trim() !== "") {
32
+ setSelectedIndex(0);
33
+ }
34
+ }, onFocus: () => {
35
+ if (query.trim() !== "" && results.length > 0) {
36
+ setOpen(true);
37
+ }
38
+ }, onKeyDown: handleKeyDown }), _jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", style: {
39
+ position: "absolute",
40
+ width: 1,
41
+ height: 1,
42
+ padding: 0,
43
+ margin: -1,
44
+ overflow: "hidden",
45
+ clip: "rect(0, 0, 0, 0)",
46
+ whiteSpace: "nowrap",
47
+ border: 0,
48
+ }, children: showResults
49
+ ? `${results.length} result${results.length === 1 ? "" : "s"} available`
50
+ : "" })] }));
35
51
  };
@@ -31,7 +31,7 @@ export const SearchResultDataContext = React.createContext(null);
31
31
  * ```
32
32
  */
33
33
  export const SearchResults = ({ className, children }) => {
34
- const { query, results, selectedIndex, listboxId, getResultNode } = useSearchContext();
34
+ const { query, results, isOpen, selectedIndex, listboxId, getResultNode } = useSearchContext();
35
35
  const listboxRef = React.useRef(null);
36
36
  React.useEffect(() => {
37
37
  if (selectedIndex < 0) {
@@ -45,7 +45,7 @@ export const SearchResults = ({ className, children }) => {
45
45
  activeNode.focus();
46
46
  }
47
47
  }, [getResultNode, selectedIndex]);
48
- if (query.trim() === "" || results.length === 0) {
48
+ if (!isOpen || query.trim() === "" || results.length === 0) {
49
49
  return null;
50
50
  }
51
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))) }));
@@ -14,6 +14,7 @@ export type SearchRootProps = {
14
14
  className?: string;
15
15
  /** Child components (SearchInput, SearchResults, etc.) */
16
16
  children: React.ReactNode;
17
+ baseUrl?: string;
17
18
  };
18
19
  /**
19
20
  * Root component for Étoile search that provides context to all child components.
@@ -40,4 +41,4 @@ export type SearchRootProps = {
40
41
  * </SearchRoot>
41
42
  * ```
42
43
  */
43
- export declare const SearchRoot: ({ apiKey, collections, limit, debounceMs, autoFocus, className, children, }: SearchRootProps) => import("react/jsx-runtime").JSX.Element;
44
+ export declare const SearchRoot: ({ apiKey, collections, limit, debounceMs, autoFocus, className, children, baseUrl, }: SearchRootProps) => import("react/jsx-runtime").JSX.Element;
@@ -27,10 +27,48 @@ import { useSearch } from "../hooks/useSearch.js";
27
27
  * </SearchRoot>
28
28
  * ```
29
29
  */
30
- export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus = false, className, children, }) => {
31
- const search = useSearch({ apiKey, collections, limit, debounceMs });
30
+ export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus = false, className, children, baseUrl, }) => {
31
+ const search = useSearch({ apiKey, collections, limit, debounceMs, baseUrl });
32
32
  const listboxId = React.useId();
33
33
  const resultRefs = React.useRef(new Map());
34
+ const rootRef = React.useRef(null);
35
+ const [isOpen, setOpen] = React.useState(false);
36
+ // Open the results list whenever results arrive and query is non-empty
37
+ React.useEffect(() => {
38
+ if (search.results.length > 0 && search.query.trim() !== "") {
39
+ setOpen(true);
40
+ }
41
+ }, [search.results, search.query]);
42
+ // Close results when query is cleared
43
+ React.useEffect(() => {
44
+ if (search.query.trim() === "") {
45
+ setOpen(false);
46
+ }
47
+ }, [search.query]);
48
+ // Click-outside: close results when clicking outside the root element
49
+ React.useEffect(() => {
50
+ const handlePointerDown = (event) => {
51
+ if (rootRef.current &&
52
+ event.target instanceof Node &&
53
+ !rootRef.current.contains(event.target)) {
54
+ setOpen(false);
55
+ }
56
+ };
57
+ document.addEventListener("pointerdown", handlePointerDown);
58
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
59
+ }, []);
60
+ // Focus-out: close results when focus leaves the component entirely
61
+ const handleFocusOut = (event) => {
62
+ if (rootRef.current &&
63
+ event.relatedTarget instanceof Node &&
64
+ !rootRef.current.contains(event.relatedTarget)) {
65
+ setOpen(false);
66
+ }
67
+ // relatedTarget is null when focus moves outside the document (e.g. address bar)
68
+ if (!event.relatedTarget) {
69
+ setOpen(false);
70
+ }
71
+ };
34
72
  const registerResult = (index, node) => {
35
73
  resultRefs.current.set(index, node);
36
74
  };
@@ -50,11 +88,13 @@ export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus =
50
88
  const handleKeyDown = (event) => {
51
89
  if (event.key === "ArrowDown") {
52
90
  event.preventDefault();
91
+ setOpen(true);
53
92
  search.setSelectedIndex(search.selectedIndex + 1);
54
93
  return;
55
94
  }
56
95
  if (event.key === "ArrowUp") {
57
96
  event.preventDefault();
97
+ setOpen(true);
58
98
  search.setSelectedIndex(search.selectedIndex - 1);
59
99
  return;
60
100
  }
@@ -67,11 +107,19 @@ export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus =
67
107
  }
68
108
  if (event.key === "Escape") {
69
109
  event.preventDefault();
70
- search.clear();
110
+ // First Escape closes results, second clears the query
111
+ if (isOpen) {
112
+ setOpen(false);
113
+ }
114
+ else {
115
+ search.clear();
116
+ }
71
117
  }
72
118
  };
73
119
  const value = React.useMemo(() => ({
74
120
  ...search,
121
+ isOpen,
122
+ setOpen,
75
123
  listboxId,
76
124
  getResultId,
77
125
  registerResult,
@@ -79,6 +127,6 @@ export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus =
79
127
  selectActiveResult,
80
128
  handleKeyDown,
81
129
  autoFocus,
82
- }), [search, listboxId, autoFocus]);
83
- return (_jsx(SearchProvider, { value: value, children: _jsx("div", { className: className ? `etoile-search ${className}` : "etoile-search", children: children }) }));
130
+ }), [search, isOpen, listboxId, autoFocus]);
131
+ return (_jsx(SearchProvider, { value: value, children: _jsx("div", { ref: rootRef, className: className ? `etoile-search ${className}` : "etoile-search", onBlur: handleFocusOut, children: children }) }));
84
132
  };
@@ -12,6 +12,10 @@ type SearchContextValue = {
12
12
  selectedIndex: number;
13
13
  setSelectedIndex: (i: number) => void;
14
14
  clear: () => void;
15
+ /** Whether the results list is currently open/visible */
16
+ isOpen: boolean;
17
+ /** Open or close the results list */
18
+ setOpen: (open: boolean) => void;
15
19
  listboxId: string;
16
20
  getResultId: (index: number) => string;
17
21
  registerResult: (index: number, node: HTMLElement | null) => void;
@@ -8,6 +8,7 @@ export type UseSearchOptions = {
8
8
  limit?: number;
9
9
  /** Debounce delay in milliseconds before triggering search (default: 100) */
10
10
  debounceMs?: number;
11
+ baseUrl?: string;
11
12
  };
12
13
  export type UseSearchReturn = {
13
14
  /** Current search query string */
@@ -52,4 +53,4 @@ export type UseSearchReturn = {
52
53
  * });
53
54
  * ```
54
55
  */
55
- export declare const useSearch: ({ apiKey, collections, limit, debounceMs, }: UseSearchOptions) => UseSearchReturn;
56
+ export declare const useSearch: ({ apiKey, collections, limit, debounceMs, baseUrl, }: UseSearchOptions) => UseSearchReturn;
@@ -39,12 +39,13 @@ const clampIndex = (index, length) => {
39
39
  * });
40
40
  * ```
41
41
  */
42
- export const useSearch = ({ apiKey, collections, limit = 10, debounceMs = 100, }) => {
42
+ export const useSearch = ({ apiKey, collections, limit = 10, debounceMs = 100, baseUrl, }) => {
43
43
  const [query, setQuery] = React.useState("");
44
44
  const [debouncedQuery, setDebouncedQuery] = React.useState("");
45
45
  const [results, setResults] = React.useState([]);
46
46
  const [isLoading, setIsLoading] = React.useState(false);
47
47
  const [selectedIndex, setSelectedIndexState] = React.useState(-1);
48
+ const client = React.useMemo(() => new Etoile({ apiKey, baseUrl }), [apiKey, baseUrl]);
48
49
  React.useEffect(() => {
49
50
  const handle = setTimeout(() => {
50
51
  setDebouncedQuery(query);
@@ -63,7 +64,6 @@ export const useSearch = ({ apiKey, collections, limit = 10, debounceMs = 100, }
63
64
  const runSearch = async () => {
64
65
  setIsLoading(true);
65
66
  try {
66
- const client = new Etoile({ apiKey });
67
67
  const response = await client.search({
68
68
  collections,
69
69
  query: debouncedQuery,
@@ -90,7 +90,7 @@ export const useSearch = ({ apiKey, collections, limit = 10, debounceMs = 100, }
90
90
  return () => {
91
91
  isActive = false;
92
92
  };
93
- }, [apiKey, collections, debouncedQuery, limit]);
93
+ }, [client, collections, debouncedQuery, limit]);
94
94
  React.useEffect(() => {
95
95
  setSelectedIndexState((current) => clampIndex(current, results.length));
96
96
  }, [results.length]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@etoile-dev/react",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Official React primitives for Étoile - Headless, composable search components",
5
5
  "keywords": [
6
6
  "etoile",
@@ -49,10 +49,10 @@
49
49
  "react": ">=18"
50
50
  },
51
51
  "dependencies": {
52
- "@etoile-dev/client": "^0.1.0"
52
+ "@etoile-dev/client": "^0.2.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/react": "^18.0.0",
56
56
  "typescript": "^5.0.0"
57
57
  }
58
- }
58
+ }