@etoile-dev/react 0.2.0 → 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` —
|
|
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
|
|
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
|
|
@@ -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
|
|
27
|
-
const activeId = selectedIndex >= 0 &&
|
|
28
|
-
return (_jsx("input", { type: "text", placeholder: placeholder, className: className, value: query, autoFocus: autoFocus, role: "combobox", "aria-expanded":
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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))) }));
|
|
@@ -31,6 +31,44 @@ export const SearchRoot = ({ apiKey, collections, limit, debounceMs, autoFocus =
|
|
|
31
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
|
-
|
|
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;
|