@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.
- package/README.md +344 -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 +136 -0
- package/dist/hooks/useEtoileSearch.js +187 -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,413 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { createSearchbarStore } from "../store.js";
|
|
4
|
+
import { SearchbarProvider, useSearchbarStore, } from "../context.js";
|
|
5
|
+
import { Slot } from "../utils/slot.js";
|
|
6
|
+
/** Clear query/results only after Content has unmounted (presence is 300ms) */
|
|
7
|
+
const CLEAR_AFTER_CLOSE_MS = 350;
|
|
8
|
+
const escapeSelectorValue = (value) => {
|
|
9
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
10
|
+
return CSS.escape(value);
|
|
11
|
+
}
|
|
12
|
+
return value.replace(/["\\]/g, "\\$&");
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Root of the Searchbar component tree. Manages all search state and provides
|
|
16
|
+
* it to child primitives via an external store (`useSyncExternalStore`).
|
|
17
|
+
*
|
|
18
|
+
* Supports fully controlled, fully uncontrolled, and mixed modes for `open`,
|
|
19
|
+
* `search`, and `value`. Handles keyboard navigation, selection, escape
|
|
20
|
+
* behavior, outside click close, and portal-aware focus boundaries.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <Searchbar.Root onSelect={(value) => console.log(value)}>
|
|
25
|
+
* <Searchbar.Input />
|
|
26
|
+
* <Searchbar.List>
|
|
27
|
+
* {items.map((item) => (
|
|
28
|
+
* <Searchbar.Item key={item.id} value={item.id}>{item.title}</Searchbar.Item>
|
|
29
|
+
* ))}
|
|
30
|
+
* <Searchbar.Empty>No results</Searchbar.Empty>
|
|
31
|
+
* </Searchbar.List>
|
|
32
|
+
* </Searchbar.Root>
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @example Command palette
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <Searchbar.Root hotkey="mod+k" className="etoile-search">
|
|
38
|
+
* <Searchbar.Trigger>
|
|
39
|
+
* <Searchbar.Icon />
|
|
40
|
+
* Search paintings…
|
|
41
|
+
* <Searchbar.Kbd />
|
|
42
|
+
* </Searchbar.Trigger>
|
|
43
|
+
* <Searchbar.Portal>
|
|
44
|
+
* <Searchbar.Overlay />
|
|
45
|
+
* <Searchbar.Content aria-label="Search paintings">
|
|
46
|
+
* <Searchbar.Input />
|
|
47
|
+
* <Searchbar.List>
|
|
48
|
+
* <Searchbar.Item value="starry-night">The Starry Night</Searchbar.Item>
|
|
49
|
+
* </Searchbar.List>
|
|
50
|
+
* </Searchbar.Content>
|
|
51
|
+
* </Searchbar.Portal>
|
|
52
|
+
* </Searchbar.Root>
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export const Root = React.forwardRef(({ open: controlledOpen, defaultOpen = false, onOpenChange, search: controlledSearch, defaultSearch = "", onSearchChange, value: controlledValue, defaultValue = null, onValueChange, isLoading = false, error, hotkey, hotkeyBehavior = "toggle", onSelect, children, className, asChild = false, ...domProps }, forwardedRef) => {
|
|
56
|
+
const rootRef = React.useRef(null);
|
|
57
|
+
const triggerRef = React.useRef(null);
|
|
58
|
+
const listId = React.useId();
|
|
59
|
+
const baseId = React.useId();
|
|
60
|
+
const rootId = React.useId();
|
|
61
|
+
const isOpenControlled = controlledOpen !== undefined;
|
|
62
|
+
const isSearchControlled = controlledSearch !== undefined;
|
|
63
|
+
const isValueControlled = controlledValue !== undefined;
|
|
64
|
+
// Create the store once; never recreate it
|
|
65
|
+
const [store] = React.useState(() => createSearchbarStore({
|
|
66
|
+
open: isOpenControlled ? controlledOpen : defaultOpen,
|
|
67
|
+
query: isSearchControlled ? controlledSearch : defaultSearch,
|
|
68
|
+
selectedValue: isValueControlled ? (controlledValue ?? null) : defaultValue,
|
|
69
|
+
}));
|
|
70
|
+
// ── Sync controlled props into the store ─────────────────────────────
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
if (isOpenControlled) {
|
|
73
|
+
store.setState((s) => ({ ...s, open: controlledOpen }));
|
|
74
|
+
}
|
|
75
|
+
}, [isOpenControlled, controlledOpen, store]);
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (isSearchControlled) {
|
|
78
|
+
store.setState((s) => ({ ...s, query: controlledSearch }));
|
|
79
|
+
}
|
|
80
|
+
}, [isSearchControlled, controlledSearch, store]);
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
if (isValueControlled) {
|
|
83
|
+
store.setState((s) => ({ ...s, selectedValue: controlledValue ?? null }));
|
|
84
|
+
}
|
|
85
|
+
}, [isValueControlled, controlledValue, store]);
|
|
86
|
+
// ── Sync loading / error from external source ────────────────────────
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
store.setState((s) => ({ ...s, isLoading, error: error ?? null }));
|
|
89
|
+
}, [isLoading, error, store]);
|
|
90
|
+
// ── Store subscriber: fire callbacks + auto-open/close ────────────────
|
|
91
|
+
const prevQueryRef = React.useRef(store.getState().query);
|
|
92
|
+
const prevOpenRef = React.useRef(store.getState().open);
|
|
93
|
+
const prevValueRef = React.useRef(store.getState().selectedValue);
|
|
94
|
+
React.useEffect(() => {
|
|
95
|
+
let clearTimeoutId = null;
|
|
96
|
+
const unsub = store.subscribe(() => {
|
|
97
|
+
const state = store.getState();
|
|
98
|
+
// Capture previous values before updating refs
|
|
99
|
+
const prevQuery = prevQueryRef.current;
|
|
100
|
+
const prevOpen = prevOpenRef.current;
|
|
101
|
+
// Fire callbacks only for uncontrolled props (single source of truth).
|
|
102
|
+
// Controlled props: setter calls the callback when requesting a change.
|
|
103
|
+
const queryChanged = state.query !== prevQuery;
|
|
104
|
+
if (queryChanged) {
|
|
105
|
+
prevQueryRef.current = state.query;
|
|
106
|
+
if (!isSearchControlled)
|
|
107
|
+
onSearchChange?.(state.query);
|
|
108
|
+
}
|
|
109
|
+
if (state.open !== prevOpen) {
|
|
110
|
+
prevOpenRef.current = state.open;
|
|
111
|
+
if (!isOpenControlled)
|
|
112
|
+
onOpenChange?.(state.open);
|
|
113
|
+
if (prevOpen && !state.open) {
|
|
114
|
+
// Defer clearing query/selection until close animation finishes.
|
|
115
|
+
// Avoids flash of empty state during exit.
|
|
116
|
+
clearTimeoutId = window.setTimeout(() => {
|
|
117
|
+
if (!isSearchControlled) {
|
|
118
|
+
store.setState((s) => ({ ...s, query: "" }));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
onSearchChange?.("");
|
|
122
|
+
}
|
|
123
|
+
if (!isValueControlled) {
|
|
124
|
+
store.setState((s) => ({ ...s, selectedValue: null }));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
onValueChange?.(null);
|
|
128
|
+
}
|
|
129
|
+
clearTimeoutId = null;
|
|
130
|
+
}, CLEAR_AFTER_CLOSE_MS);
|
|
131
|
+
}
|
|
132
|
+
else if (!prevOpen && state.open && clearTimeoutId) {
|
|
133
|
+
// Reopened before clear fired — cancel it.
|
|
134
|
+
window.clearTimeout(clearTimeoutId);
|
|
135
|
+
clearTimeoutId = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (state.selectedValue !== prevValueRef.current) {
|
|
139
|
+
prevValueRef.current = state.selectedValue;
|
|
140
|
+
if (!isValueControlled)
|
|
141
|
+
onValueChange?.(state.selectedValue);
|
|
142
|
+
}
|
|
143
|
+
// Auto-open only when query actually changes to non-empty. This avoids
|
|
144
|
+
// immediate reopen after explicit close actions (Escape / outside click).
|
|
145
|
+
if (queryChanged && state.query.trim() !== "" && !state.open && !isOpenControlled) {
|
|
146
|
+
store.setState((s) => ({ ...s, open: true }));
|
|
147
|
+
}
|
|
148
|
+
// Auto-close when query becomes empty (e.g. user deletes all letters).
|
|
149
|
+
if (queryChanged && state.query.trim() === "" && state.open && !isOpenControlled) {
|
|
150
|
+
store.setState((s) => ({ ...s, open: false }));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return () => {
|
|
154
|
+
if (clearTimeoutId)
|
|
155
|
+
window.clearTimeout(clearTimeoutId);
|
|
156
|
+
unsub();
|
|
157
|
+
};
|
|
158
|
+
}, [store]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
159
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
160
|
+
const getItemId = React.useCallback((value) => `${baseId}-item-${value}`, [baseId]);
|
|
161
|
+
// Setters update store (uncontrolled) or notify parent (controlled).
|
|
162
|
+
// Subscriber fires callbacks when store changes — avoid firing from both.
|
|
163
|
+
const setOpen = React.useCallback((next) => {
|
|
164
|
+
if (isOpenControlled) {
|
|
165
|
+
onOpenChange?.(next);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
store.setState((s) => ({ ...s, open: next }));
|
|
169
|
+
}
|
|
170
|
+
}, [isOpenControlled, store, onOpenChange]);
|
|
171
|
+
// ── Global hotkey (e.g. "mod+k", "/") ─────────────────────────────────
|
|
172
|
+
React.useEffect(() => {
|
|
173
|
+
if (!hotkey)
|
|
174
|
+
return;
|
|
175
|
+
const parts = hotkey.toLowerCase().split("+");
|
|
176
|
+
const key = parts[parts.length - 1];
|
|
177
|
+
const needsMod = parts.includes("mod");
|
|
178
|
+
const needsCtrl = parts.includes("ctrl");
|
|
179
|
+
const needsShift = parts.includes("shift");
|
|
180
|
+
const needsAlt = parts.includes("alt");
|
|
181
|
+
const normalizeKey = (k) => (k === "slash" ? "/" : k);
|
|
182
|
+
const handler = (e) => {
|
|
183
|
+
if (e.target instanceof HTMLElement &&
|
|
184
|
+
(e.target.closest("input") || e.target.closest("textarea") || e.target.closest("[contenteditable]"))) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const isMac = /mac/i.test(navigator.platform);
|
|
188
|
+
if (needsMod && !(isMac ? e.metaKey : e.ctrlKey))
|
|
189
|
+
return;
|
|
190
|
+
if (needsCtrl && !e.ctrlKey)
|
|
191
|
+
return;
|
|
192
|
+
if (needsShift && !e.shiftKey)
|
|
193
|
+
return;
|
|
194
|
+
if (needsAlt && !e.altKey)
|
|
195
|
+
return;
|
|
196
|
+
const eventKey = normalizeKey(e.key.toLowerCase());
|
|
197
|
+
const expectedKey = normalizeKey(key);
|
|
198
|
+
if (eventKey !== expectedKey)
|
|
199
|
+
return;
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
if (hotkeyBehavior === "focus") {
|
|
202
|
+
const input = rootRef.current?.querySelector('input[role="combobox"]');
|
|
203
|
+
input?.focus();
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
setOpen(!store.getState().open);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
document.addEventListener("keydown", handler);
|
|
210
|
+
return () => document.removeEventListener("keydown", handler);
|
|
211
|
+
}, [hotkey, hotkeyBehavior, store, setOpen]);
|
|
212
|
+
const setQuery = React.useCallback((next) => {
|
|
213
|
+
if (isSearchControlled) {
|
|
214
|
+
onSearchChange?.(next);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
store.setState((s) => ({ ...s, query: next }));
|
|
218
|
+
}
|
|
219
|
+
}, [isSearchControlled, store, onSearchChange]);
|
|
220
|
+
const setSelectedValue = React.useCallback((next) => {
|
|
221
|
+
if (isValueControlled) {
|
|
222
|
+
onValueChange?.(next);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
store.setState((s) => ({ ...s, selectedValue: next }));
|
|
226
|
+
}
|
|
227
|
+
}, [isValueControlled, store, onValueChange]);
|
|
228
|
+
const handleSelect = React.useCallback((value) => {
|
|
229
|
+
const item = store.getState().items.get(value);
|
|
230
|
+
item?.onSelect?.(value);
|
|
231
|
+
onSelect?.(value);
|
|
232
|
+
setSelectedValue(value);
|
|
233
|
+
setOpen(false);
|
|
234
|
+
}, [store, onSelect, setSelectedValue, setOpen]);
|
|
235
|
+
const registerItem = React.useCallback((meta) => {
|
|
236
|
+
store.setState((s) => {
|
|
237
|
+
const items = new Map(s.items);
|
|
238
|
+
items.set(meta.value, meta);
|
|
239
|
+
const sortedValues = s.sortedValues.includes(meta.value)
|
|
240
|
+
? s.sortedValues
|
|
241
|
+
: [...s.sortedValues, meta.value];
|
|
242
|
+
return { ...s, items, sortedValues };
|
|
243
|
+
});
|
|
244
|
+
}, [store]);
|
|
245
|
+
const unregisterItem = React.useCallback((value) => {
|
|
246
|
+
store.setState((s) => {
|
|
247
|
+
const items = new Map(s.items);
|
|
248
|
+
items.delete(value);
|
|
249
|
+
const sortedValues = s.sortedValues.filter((v) => v !== value);
|
|
250
|
+
return { ...s, items, sortedValues };
|
|
251
|
+
});
|
|
252
|
+
}, [store]);
|
|
253
|
+
// Defer scroll so it runs after React commits.
|
|
254
|
+
// Two RAFs are intentionally used here so DOM updates from selection state
|
|
255
|
+
// are reflected before calling scrollIntoView (prevents missed scrolls).
|
|
256
|
+
const scheduleScrollSelectedIntoView = React.useCallback(() => {
|
|
257
|
+
const tryScroll = () => {
|
|
258
|
+
const state = store.getState();
|
|
259
|
+
if (!state.open || !state.selectedValue)
|
|
260
|
+
return false;
|
|
261
|
+
const node = state.items.get(state.selectedValue)?.node;
|
|
262
|
+
if (!node)
|
|
263
|
+
return false;
|
|
264
|
+
node.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
265
|
+
return true;
|
|
266
|
+
};
|
|
267
|
+
requestAnimationFrame(() => {
|
|
268
|
+
requestAnimationFrame(() => {
|
|
269
|
+
if (tryScroll())
|
|
270
|
+
return;
|
|
271
|
+
// Fallback for slower renders where the node reference lands later.
|
|
272
|
+
window.setTimeout(() => {
|
|
273
|
+
tryScroll();
|
|
274
|
+
}, 0);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}, [store]);
|
|
278
|
+
// ── Keyboard navigation (cmdk-inspired: IME guard, Home/End) ─────────────
|
|
279
|
+
const handleKeyDown = React.useCallback((event) => {
|
|
280
|
+
// Don't trigger navigation while IME composition is active (CJK input).
|
|
281
|
+
// keyCode 229 = IME composition in legacy browsers.
|
|
282
|
+
const isComposing = event.nativeEvent.isComposing ||
|
|
283
|
+
event.nativeEvent.keyCode === 229;
|
|
284
|
+
if (event.defaultPrevented || isComposing)
|
|
285
|
+
return;
|
|
286
|
+
const state = store.getState();
|
|
287
|
+
const values = state.filteredValues;
|
|
288
|
+
if (event.key === "ArrowDown") {
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
setOpen(true);
|
|
291
|
+
const currentIdx = values.indexOf(state.selectedValue ?? "");
|
|
292
|
+
const nextIdx = currentIdx < values.length - 1 ? currentIdx + 1 : 0;
|
|
293
|
+
const nextValue = values[nextIdx];
|
|
294
|
+
if (nextValue !== undefined) {
|
|
295
|
+
store.setState((s) => ({ ...s, selectedValue: nextValue }));
|
|
296
|
+
scheduleScrollSelectedIntoView();
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (event.key === "ArrowUp") {
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
setOpen(true);
|
|
303
|
+
const currentIdx = values.indexOf(state.selectedValue ?? "");
|
|
304
|
+
const prevIdx = currentIdx > 0 ? currentIdx - 1 : values.length - 1;
|
|
305
|
+
const prevValue = values[prevIdx];
|
|
306
|
+
if (prevValue !== undefined) {
|
|
307
|
+
store.setState((s) => ({ ...s, selectedValue: prevValue }));
|
|
308
|
+
scheduleScrollSelectedIntoView();
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (event.key === "Home") {
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
setOpen(true);
|
|
315
|
+
const first = values[0];
|
|
316
|
+
if (first !== undefined) {
|
|
317
|
+
store.setState((s) => ({ ...s, selectedValue: first }));
|
|
318
|
+
scheduleScrollSelectedIntoView();
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (event.key === "End") {
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
setOpen(true);
|
|
325
|
+
const last = values[values.length - 1];
|
|
326
|
+
if (last !== undefined) {
|
|
327
|
+
store.setState((s) => ({ ...s, selectedValue: last }));
|
|
328
|
+
scheduleScrollSelectedIntoView();
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (event.key === "Enter") {
|
|
333
|
+
if (state.selectedValue && state.open) {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
handleSelect(state.selectedValue);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (event.key === "Escape") {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
if (state.open) {
|
|
342
|
+
setOpen(false);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}, [store, setOpen, handleSelect]);
|
|
347
|
+
// ── Click-outside (portal-aware) ──────────────────────────────────────
|
|
348
|
+
// We tag every DOM node belonging to this instance with data-searchbar-root
|
|
349
|
+
// (including portaled Content). The check uses .closest() so portal nodes
|
|
350
|
+
// that aren't inside rootRef are still recognised as "inside" the searchbar.
|
|
351
|
+
React.useEffect(() => {
|
|
352
|
+
const escapedRootId = escapeSelectorValue(rootId);
|
|
353
|
+
const handlePointerDown = (event) => {
|
|
354
|
+
if (!(event.target instanceof Element))
|
|
355
|
+
return;
|
|
356
|
+
const inside = event.target.closest(`[data-searchbar-root="${escapedRootId}"]`);
|
|
357
|
+
if (!inside) {
|
|
358
|
+
setOpen(false);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
362
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
363
|
+
}, [rootId, setOpen]);
|
|
364
|
+
// Focus-out for the inline (non-portal) case.
|
|
365
|
+
// In portal mode the Input is outside rootRef so this is a no-op there;
|
|
366
|
+
// the click-outside and Escape handlers cover that path instead.
|
|
367
|
+
const handleBlur = (event) => {
|
|
368
|
+
const related = event.relatedTarget;
|
|
369
|
+
if (!related) {
|
|
370
|
+
setOpen(false);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (related instanceof Element) {
|
|
374
|
+
const escapedRootId = escapeSelectorValue(rootId);
|
|
375
|
+
const inside = related.closest(`[data-searchbar-root="${escapedRootId}"]`);
|
|
376
|
+
if (!inside)
|
|
377
|
+
setOpen(false);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
// ── Context value ─────────────────────────────────────────────────────
|
|
381
|
+
const ctx = React.useMemo(() => ({
|
|
382
|
+
store,
|
|
383
|
+
listId,
|
|
384
|
+
rootId,
|
|
385
|
+
rootClassName: className,
|
|
386
|
+
isSearchControlled,
|
|
387
|
+
onSearchChange,
|
|
388
|
+
triggerRef,
|
|
389
|
+
getItemId,
|
|
390
|
+
onSelect: handleSelect,
|
|
391
|
+
setOpen,
|
|
392
|
+
handleKeyDown,
|
|
393
|
+
registerItem,
|
|
394
|
+
unregisterItem,
|
|
395
|
+
}), [store, listId, rootId, className, isSearchControlled, onSearchChange, triggerRef, getItemId, handleSelect, setOpen, handleKeyDown, registerItem, unregisterItem]);
|
|
396
|
+
const Comp = asChild ? Slot : "div";
|
|
397
|
+
return (_jsx(SearchbarProvider, { value: ctx, children: _jsx(RootInner, { comp: Comp, store: store, domProps: domProps, forwardedRef: forwardedRef, rootRef: rootRef, rootId: rootId, className: className, handleBlur: handleBlur, handleKeyDown: handleKeyDown, children: children }) }));
|
|
398
|
+
});
|
|
399
|
+
Root.displayName = "Searchbar.Root";
|
|
400
|
+
// Inner component so we can legally call useSearchbarStore inside JSX
|
|
401
|
+
const RootInner = ({ comp: Comp, store, domProps, forwardedRef, rootRef, rootId, className, handleBlur, handleKeyDown, children, }) => {
|
|
402
|
+
const dataState = useSearchbarStore(store, (s) => (s.open ? "open" : "closed"));
|
|
403
|
+
return (_jsx(Comp, { ...domProps, ref: (node) => {
|
|
404
|
+
rootRef.current = node;
|
|
405
|
+
if (typeof forwardedRef === "function")
|
|
406
|
+
forwardedRef(node);
|
|
407
|
+
else if (forwardedRef)
|
|
408
|
+
forwardedRef.current = node;
|
|
409
|
+
}, className: className, onBlur: handleBlur, onKeyDown: (event) => {
|
|
410
|
+
domProps.onKeyDown?.(event);
|
|
411
|
+
handleKeyDown(event);
|
|
412
|
+
}, "data-slot": "searchbar-root", "data-state": dataState, "data-searchbar-root": rootId, children: children }));
|
|
413
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type SearchbarSeparatorProps = {
|
|
3
|
+
className?: string;
|
|
4
|
+
asChild?: boolean;
|
|
5
|
+
} & React.HTMLAttributes<HTMLDivElement>;
|
|
6
|
+
/**
|
|
7
|
+
* Visual separator between groups or sections.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <Searchbar.Group label="Paintings">…</Searchbar.Group>
|
|
12
|
+
* <Searchbar.Separator />
|
|
13
|
+
* <Searchbar.Group label="Artists">…</Searchbar.Group>
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare const Separator: React.ForwardRefExoticComponent<{
|
|
17
|
+
className?: string;
|
|
18
|
+
asChild?: boolean;
|
|
19
|
+
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Slot } from "../utils/slot.js";
|
|
4
|
+
/**
|
|
5
|
+
* Visual separator between groups or sections.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <Searchbar.Group label="Paintings">…</Searchbar.Group>
|
|
10
|
+
* <Searchbar.Separator />
|
|
11
|
+
* <Searchbar.Group label="Artists">…</Searchbar.Group>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export const Separator = React.forwardRef(({ className, asChild = false, ...props }, forwardedRef) => {
|
|
15
|
+
const Comp = asChild ? Slot : "div";
|
|
16
|
+
return (_jsx(Comp, { ...props, ref: forwardedRef, role: "separator", "aria-orientation": "horizontal", className: className, "data-slot": "searchbar-separator" }));
|
|
17
|
+
});
|
|
18
|
+
Separator.displayName = "Searchbar.Separator";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type SearchbarThumbnailProps = {
|
|
3
|
+
/** Explicit image source. Defaults to `metadata.thumbnailUrl` from item context. */
|
|
4
|
+
src?: string;
|
|
5
|
+
/** Alt text. Defaults to item title from context. */
|
|
6
|
+
alt?: string;
|
|
7
|
+
/** Width and height in pixels (default: 40) */
|
|
8
|
+
size?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
} & Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "width" | "height">;
|
|
11
|
+
/**
|
|
12
|
+
* Thumbnail image for a search result item.
|
|
13
|
+
*
|
|
14
|
+
* When used inside the Etoile `<Searchbar />` wrapper, automatically reads
|
|
15
|
+
* `metadata.thumbnailUrl` and the item title from context. Pass `src` and
|
|
16
|
+
* `alt` explicitly when using headless primitives.
|
|
17
|
+
*
|
|
18
|
+
* Returns null if no source is found.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <Searchbar.Item value={result.id}>
|
|
23
|
+
* <Searchbar.Thumbnail src={result.thumbnailUrl} alt={result.title} />
|
|
24
|
+
* {result.title}
|
|
25
|
+
* </Searchbar.Item>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare const Thumbnail: {
|
|
29
|
+
({ src, alt, size, className, ...props }: SearchbarThumbnailProps): import("react/jsx-runtime").JSX.Element | null;
|
|
30
|
+
displayName: string;
|
|
31
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { SearchbarItemDataContext } from "../context.js";
|
|
4
|
+
/**
|
|
5
|
+
* Thumbnail image for a search result item.
|
|
6
|
+
*
|
|
7
|
+
* When used inside the Etoile `<Searchbar />` wrapper, automatically reads
|
|
8
|
+
* `metadata.thumbnailUrl` and the item title from context. Pass `src` and
|
|
9
|
+
* `alt` explicitly when using headless primitives.
|
|
10
|
+
*
|
|
11
|
+
* Returns null if no source is found.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <Searchbar.Item value={result.id}>
|
|
16
|
+
* <Searchbar.Thumbnail src={result.thumbnailUrl} alt={result.title} />
|
|
17
|
+
* {result.title}
|
|
18
|
+
* </Searchbar.Item>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export const Thumbnail = ({ src, alt, size = 40, className, ...props }) => {
|
|
22
|
+
const itemData = React.useContext(SearchbarItemDataContext);
|
|
23
|
+
const metadata = itemData?.metadata;
|
|
24
|
+
const imageSrc = src ??
|
|
25
|
+
readImageSource(metadata, [
|
|
26
|
+
"thumbnailUrl",
|
|
27
|
+
"thumbnail_url",
|
|
28
|
+
"thumbnail",
|
|
29
|
+
"image",
|
|
30
|
+
"imageUrl",
|
|
31
|
+
"image_url",
|
|
32
|
+
"cover",
|
|
33
|
+
"coverUrl",
|
|
34
|
+
"cover_url",
|
|
35
|
+
"artwork",
|
|
36
|
+
"artworkUrl",
|
|
37
|
+
"artwork_url",
|
|
38
|
+
]);
|
|
39
|
+
const imageAlt = alt ?? itemData?.title ?? "";
|
|
40
|
+
if (!imageSrc)
|
|
41
|
+
return null;
|
|
42
|
+
return (_jsx("img", { ...props, src: imageSrc, alt: imageAlt, width: size, height: size, className: className, draggable: false }));
|
|
43
|
+
};
|
|
44
|
+
Thumbnail.displayName = "Searchbar.Thumbnail";
|
|
45
|
+
const readImageSource = (metadata, keys) => {
|
|
46
|
+
if (!metadata)
|
|
47
|
+
return undefined;
|
|
48
|
+
for (const key of keys) {
|
|
49
|
+
const value = metadata[key];
|
|
50
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
51
|
+
return value;
|
|
52
|
+
if (value && typeof value === "object") {
|
|
53
|
+
const nestedUrl = value.url;
|
|
54
|
+
if (typeof nestedUrl === "string" && nestedUrl.trim() !== "")
|
|
55
|
+
return nestedUrl;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type SearchbarTriggerProps = {
|
|
3
|
+
children?: React.ReactNode;
|
|
4
|
+
className?: string;
|
|
5
|
+
asChild?: boolean;
|
|
6
|
+
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
7
|
+
/**
|
|
8
|
+
* Button that toggles the search open/closed state.
|
|
9
|
+
* Designed for command palette / modal mode.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <Searchbar.Root>
|
|
14
|
+
* <Searchbar.Trigger>
|
|
15
|
+
* <Searchbar.Icon /> Search
|
|
16
|
+
* </Searchbar.Trigger>
|
|
17
|
+
* <Searchbar.Portal>
|
|
18
|
+
* <Searchbar.Overlay />
|
|
19
|
+
* <Searchbar.Content>…</Searchbar.Content>
|
|
20
|
+
* </Searchbar.Portal>
|
|
21
|
+
* </Searchbar.Root>
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare const Trigger: React.ForwardRefExoticComponent<{
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
asChild?: boolean;
|
|
28
|
+
} & React.ButtonHTMLAttributes<HTMLButtonElement> & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import { useComposeRefs } from "../utils/composeRefs.js";
|
|
6
|
+
/**
|
|
7
|
+
* Button that toggles the search open/closed state.
|
|
8
|
+
* Designed for command palette / modal mode.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <Searchbar.Root>
|
|
13
|
+
* <Searchbar.Trigger>
|
|
14
|
+
* <Searchbar.Icon /> Search
|
|
15
|
+
* </Searchbar.Trigger>
|
|
16
|
+
* <Searchbar.Portal>
|
|
17
|
+
* <Searchbar.Overlay />
|
|
18
|
+
* <Searchbar.Content>…</Searchbar.Content>
|
|
19
|
+
* </Searchbar.Portal>
|
|
20
|
+
* </Searchbar.Root>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export const Trigger = React.forwardRef(({ children, className, asChild = false, ...props }, forwardedRef) => {
|
|
24
|
+
const { store, triggerRef } = useSearchbarContext();
|
|
25
|
+
const isOpen = useSearchbarStore(store, (s) => s.open);
|
|
26
|
+
const composedRef = useComposeRefs(triggerRef, forwardedRef);
|
|
27
|
+
const Comp = asChild ? Slot : "button";
|
|
28
|
+
return (_jsx(Comp, { ...props, ref: composedRef, type: asChild ? undefined : "button", "aria-expanded": isOpen, "aria-haspopup": "listbox", className: className, "data-state": isOpen ? "open" : "closed", "data-slot": "searchbar-trigger", onClick: (event) => {
|
|
29
|
+
props.onClick?.(event);
|
|
30
|
+
if (event.defaultPrevented)
|
|
31
|
+
return;
|
|
32
|
+
store.setState((s) => ({ ...s, open: !s.open }));
|
|
33
|
+
}, children: children }));
|
|
34
|
+
});
|
|
35
|
+
Trigger.displayName = "Searchbar.Trigger";
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchbarStore — external state container for the Searchbar primitives.
|
|
3
|
+
*
|
|
4
|
+
* Uses the subscribe/getSnapshot pattern compatible with useSyncExternalStore,
|
|
5
|
+
* so each component can subscribe to exactly the slice of state it needs and
|
|
6
|
+
* avoid re-rendering on unrelated updates (e.g. a keystroke should not force
|
|
7
|
+
* every item to re-render).
|
|
8
|
+
*/
|
|
9
|
+
export type ItemMeta = {
|
|
10
|
+
/** Stable identifier matching the `value` prop on Searchbar.Item */
|
|
11
|
+
value: string;
|
|
12
|
+
/** Optional item label (consumer-defined, useful for analytics/debugging). */
|
|
13
|
+
label: string;
|
|
14
|
+
disabled: boolean;
|
|
15
|
+
node: HTMLElement | null;
|
|
16
|
+
/** Per-item select handler — called in addition to root onSelect */
|
|
17
|
+
onSelect?: (value: string) => void;
|
|
18
|
+
};
|
|
19
|
+
export type SearchbarState = {
|
|
20
|
+
query: string;
|
|
21
|
+
open: boolean;
|
|
22
|
+
selectedValue: string | null;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
error: unknown;
|
|
25
|
+
items: Map<string, ItemMeta>;
|
|
26
|
+
/** Registration order — determines keyboard navigation sequence */
|
|
27
|
+
sortedValues: string[];
|
|
28
|
+
/** Subset of enabled items currently rendered in navigation order */
|
|
29
|
+
filteredValues: string[];
|
|
30
|
+
/** Set mirror of filteredValues for O(1) lookup by Item primitives */
|
|
31
|
+
filteredSet: Set<string>;
|
|
32
|
+
};
|
|
33
|
+
export type SearchbarStore = {
|
|
34
|
+
getState: () => SearchbarState;
|
|
35
|
+
setState: (updater: (prev: SearchbarState) => SearchbarState) => void;
|
|
36
|
+
subscribe: (listener: () => void) => () => void;
|
|
37
|
+
};
|
|
38
|
+
export declare function createSearchbarStore(initial?: Partial<Pick<SearchbarState, "query" | "open" | "selectedValue" | "isLoading">>): SearchbarStore;
|