@etoile-dev/react 0.2.2 → 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.
Files changed (74) hide show
  1. package/README.md +341 -205
  2. package/dist/Searchbar.d.ts +315 -0
  3. package/dist/Searchbar.js +207 -0
  4. package/dist/context.d.ts +57 -0
  5. package/dist/context.js +32 -0
  6. package/dist/hooks/useEtoileSearch.d.ts +122 -0
  7. package/dist/hooks/useEtoileSearch.js +138 -0
  8. package/dist/index.d.ts +44 -19
  9. package/dist/index.js +37 -12
  10. package/dist/primitives/Content.d.ts +34 -0
  11. package/dist/primitives/Content.js +108 -0
  12. package/dist/primitives/Empty.d.ts +25 -0
  13. package/dist/primitives/Empty.js +25 -0
  14. package/dist/primitives/Error.d.ts +29 -0
  15. package/dist/primitives/Error.js +26 -0
  16. package/dist/primitives/Group.d.ts +30 -0
  17. package/dist/primitives/Group.js +22 -0
  18. package/dist/primitives/Icon.d.ts +21 -0
  19. package/dist/primitives/Icon.js +14 -0
  20. package/dist/primitives/Input.d.ts +32 -0
  21. package/dist/primitives/Input.js +70 -0
  22. package/dist/primitives/Item.d.ts +61 -0
  23. package/dist/primitives/Item.js +76 -0
  24. package/dist/primitives/Kbd.d.ts +20 -0
  25. package/dist/primitives/Kbd.js +13 -0
  26. package/dist/primitives/List.d.ts +35 -0
  27. package/dist/primitives/List.js +37 -0
  28. package/dist/primitives/Loading.d.ts +25 -0
  29. package/dist/primitives/Loading.js +26 -0
  30. package/dist/primitives/Modal.d.ts +39 -0
  31. package/dist/primitives/Modal.js +37 -0
  32. package/dist/primitives/ModalInput.d.ts +61 -0
  33. package/dist/primitives/ModalInput.js +33 -0
  34. package/dist/primitives/Overlay.d.ts +21 -0
  35. package/dist/primitives/Overlay.js +41 -0
  36. package/dist/primitives/Portal.d.ts +28 -0
  37. package/dist/primitives/Portal.js +30 -0
  38. package/dist/primitives/Root.d.ts +116 -0
  39. package/dist/primitives/Root.js +413 -0
  40. package/dist/primitives/Separator.d.ts +19 -0
  41. package/dist/primitives/Separator.js +18 -0
  42. package/dist/primitives/Thumbnail.d.ts +31 -0
  43. package/dist/primitives/Thumbnail.js +59 -0
  44. package/dist/primitives/Trigger.d.ts +28 -0
  45. package/dist/primitives/Trigger.js +35 -0
  46. package/dist/store.d.ts +38 -0
  47. package/dist/store.js +63 -0
  48. package/dist/styles.css +480 -133
  49. package/dist/types.d.ts +3 -31
  50. package/dist/utils/composeRefs.d.ts +12 -0
  51. package/dist/utils/composeRefs.js +27 -0
  52. package/dist/utils/slot.d.ts +22 -0
  53. package/dist/utils/slot.js +58 -0
  54. package/package.json +8 -4
  55. package/dist/Search.d.ts +0 -37
  56. package/dist/Search.js +0 -31
  57. package/dist/components/SearchIcon.d.ts +0 -22
  58. package/dist/components/SearchIcon.js +0 -17
  59. package/dist/components/SearchInput.d.ts +0 -30
  60. package/dist/components/SearchInput.js +0 -59
  61. package/dist/components/SearchKbd.d.ts +0 -30
  62. package/dist/components/SearchKbd.js +0 -24
  63. package/dist/components/SearchResult.d.ts +0 -31
  64. package/dist/components/SearchResult.js +0 -40
  65. package/dist/components/SearchResultThumbnail.d.ts +0 -38
  66. package/dist/components/SearchResultThumbnail.js +0 -38
  67. package/dist/components/SearchResults.d.ts +0 -39
  68. package/dist/components/SearchResults.js +0 -53
  69. package/dist/components/SearchRoot.d.ts +0 -44
  70. package/dist/components/SearchRoot.js +0 -132
  71. package/dist/context/SearchContext.d.ts +0 -55
  72. package/dist/context/SearchContext.js +0 -36
  73. package/dist/hooks/useSearch.d.ts +0 -56
  74. package/dist/hooks/useSearch.js +0 -116
@@ -0,0 +1,41 @@
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
+ const PRESENCE_DURATION_MS = 300;
6
+ /**
7
+ * Backdrop overlay for command palette / modal mode.
8
+ * Only renders when the search is open.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * <Searchbar.Portal>
13
+ * <Searchbar.Overlay className="fixed inset-0 bg-black/40" />
14
+ * <Searchbar.Content>…</Searchbar.Content>
15
+ * </Searchbar.Portal>
16
+ * ```
17
+ */
18
+ export const Overlay = React.forwardRef(({ className, asChild = false, ...props }, forwardedRef) => {
19
+ const { store, rootId, rootClassName, setOpen } = useSearchbarContext();
20
+ const isOpen = useSearchbarStore(store, (s) => s.open);
21
+ const [present, setPresent] = React.useState(isOpen);
22
+ const mergedClassName = [rootClassName, className].filter(Boolean).join(" ") || undefined;
23
+ React.useEffect(() => {
24
+ if (isOpen) {
25
+ setPresent(true);
26
+ return;
27
+ }
28
+ const timeout = window.setTimeout(() => setPresent(false), PRESENCE_DURATION_MS);
29
+ return () => window.clearTimeout(timeout);
30
+ }, [isOpen]);
31
+ if (!present)
32
+ return null;
33
+ const Comp = asChild ? Slot : "div";
34
+ return (_jsx(Comp, { ...props, ref: forwardedRef, "aria-hidden": "true", className: mergedClassName, "data-state": isOpen ? "open" : "closed", "data-slot": "searchbar-overlay", "data-searchbar-root": rootId, onPointerDown: (event) => {
35
+ props.onPointerDown?.(event);
36
+ if (event.defaultPrevented)
37
+ return;
38
+ setOpen(false);
39
+ } }));
40
+ });
41
+ Overlay.displayName = "Searchbar.Overlay";
@@ -0,0 +1,28 @@
1
+ import * as React from "react";
2
+ export type SearchbarPortalProps = {
3
+ /** DOM node to portal into (default: document.body) */
4
+ container?: Element | null;
5
+ children: React.ReactNode;
6
+ };
7
+ /**
8
+ * Renders children into a portal — useful for command palette / modal mode
9
+ * to escape stacking contexts.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * <Searchbar.Root>
14
+ * <Searchbar.Trigger />
15
+ * <Searchbar.Portal>
16
+ * <Searchbar.Overlay />
17
+ * <Searchbar.Content>
18
+ * <Searchbar.Input />
19
+ * <Searchbar.List>…</Searchbar.List>
20
+ * </Searchbar.Content>
21
+ * </Searchbar.Portal>
22
+ * </Searchbar.Root>
23
+ * ```
24
+ */
25
+ export declare const Portal: {
26
+ ({ container, children }: SearchbarPortalProps): React.ReactPortal | null;
27
+ displayName: string;
28
+ };
@@ -0,0 +1,30 @@
1
+ import * as React from "react";
2
+ import { createPortal } from "react-dom";
3
+ /**
4
+ * Renders children into a portal — useful for command palette / modal mode
5
+ * to escape stacking contexts.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * <Searchbar.Root>
10
+ * <Searchbar.Trigger />
11
+ * <Searchbar.Portal>
12
+ * <Searchbar.Overlay />
13
+ * <Searchbar.Content>
14
+ * <Searchbar.Input />
15
+ * <Searchbar.List>…</Searchbar.List>
16
+ * </Searchbar.Content>
17
+ * </Searchbar.Portal>
18
+ * </Searchbar.Root>
19
+ * ```
20
+ */
21
+ export const Portal = ({ container, children }) => {
22
+ const [mounted, setMounted] = React.useState(false);
23
+ React.useEffect(() => {
24
+ setMounted(true);
25
+ }, []);
26
+ if (!mounted)
27
+ return null;
28
+ return createPortal(children, container ?? document.body);
29
+ };
30
+ Portal.displayName = "Searchbar.Portal";
@@ -0,0 +1,116 @@
1
+ import * as React from "react";
2
+ export type SearchbarRootProps = {
3
+ /** Controlled open state */
4
+ open?: boolean;
5
+ defaultOpen?: boolean;
6
+ onOpenChange?: (open: boolean) => void;
7
+ /** Controlled search query */
8
+ search?: string;
9
+ defaultSearch?: string;
10
+ onSearchChange?: (search: string) => void;
11
+ /** Controlled selected item value */
12
+ value?: string | null;
13
+ defaultValue?: string | null;
14
+ onValueChange?: (value: string | null) => void;
15
+ /** Whether a search is currently in progress */
16
+ isLoading?: boolean;
17
+ /** Current error, if any */
18
+ error?: unknown;
19
+ /**
20
+ * Global keyboard shortcut.
21
+ * Use `"mod+k"` for ⌘K on Mac / Ctrl+K elsewhere, or `"/"` for the widely used search shortcut.
22
+ * Supports: `mod`, `ctrl`, `shift`, `alt` modifiers + any key (e.g. `"mod+k"`, `"/"`).
23
+ *
24
+ * @example `hotkey="mod+k"` — command palette (toggle modal)
25
+ * @example `hotkey="/"` with `hotkeyBehavior="focus"` — focus input (inline searchbar)
26
+ */
27
+ hotkey?: string;
28
+ /**
29
+ * What the hotkey does: `"toggle"` (default) opens/closes the modal; `"focus"` focuses the input.
30
+ * Use `"focus"` for inline Searchbar so the hotkey focuses the input instead of toggling.
31
+ */
32
+ hotkeyBehavior?: "focus" | "toggle";
33
+ /** Called when an item is selected. Receives the item's `value`. */
34
+ onSelect?: (value: string) => void;
35
+ children: React.ReactNode;
36
+ className?: string;
37
+ asChild?: boolean;
38
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect">;
39
+ /**
40
+ * Root of the Searchbar component tree. Manages all search state and provides
41
+ * it to child primitives via an external store (`useSyncExternalStore`).
42
+ *
43
+ * Supports fully controlled, fully uncontrolled, and mixed modes for `open`,
44
+ * `search`, and `value`. Handles keyboard navigation, selection, escape
45
+ * behavior, outside click close, and portal-aware focus boundaries.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * <Searchbar.Root onSelect={(value) => console.log(value)}>
50
+ * <Searchbar.Input />
51
+ * <Searchbar.List>
52
+ * {items.map((item) => (
53
+ * <Searchbar.Item key={item.id} value={item.id}>{item.title}</Searchbar.Item>
54
+ * ))}
55
+ * <Searchbar.Empty>No results</Searchbar.Empty>
56
+ * </Searchbar.List>
57
+ * </Searchbar.Root>
58
+ * ```
59
+ *
60
+ * @example Command palette
61
+ * ```tsx
62
+ * <Searchbar.Root hotkey="mod+k" className="etoile-search">
63
+ * <Searchbar.Trigger>
64
+ * <Searchbar.Icon />
65
+ * Search paintings…
66
+ * <Searchbar.Kbd />
67
+ * </Searchbar.Trigger>
68
+ * <Searchbar.Portal>
69
+ * <Searchbar.Overlay />
70
+ * <Searchbar.Content aria-label="Search paintings">
71
+ * <Searchbar.Input />
72
+ * <Searchbar.List>
73
+ * <Searchbar.Item value="starry-night">The Starry Night</Searchbar.Item>
74
+ * </Searchbar.List>
75
+ * </Searchbar.Content>
76
+ * </Searchbar.Portal>
77
+ * </Searchbar.Root>
78
+ * ```
79
+ */
80
+ export declare const Root: React.ForwardRefExoticComponent<{
81
+ /** Controlled open state */
82
+ open?: boolean;
83
+ defaultOpen?: boolean;
84
+ onOpenChange?: (open: boolean) => void;
85
+ /** Controlled search query */
86
+ search?: string;
87
+ defaultSearch?: string;
88
+ onSearchChange?: (search: string) => void;
89
+ /** Controlled selected item value */
90
+ value?: string | null;
91
+ defaultValue?: string | null;
92
+ onValueChange?: (value: string | null) => void;
93
+ /** Whether a search is currently in progress */
94
+ isLoading?: boolean;
95
+ /** Current error, if any */
96
+ error?: unknown;
97
+ /**
98
+ * Global keyboard shortcut.
99
+ * Use `"mod+k"` for ⌘K on Mac / Ctrl+K elsewhere, or `"/"` for the widely used search shortcut.
100
+ * Supports: `mod`, `ctrl`, `shift`, `alt` modifiers + any key (e.g. `"mod+k"`, `"/"`).
101
+ *
102
+ * @example `hotkey="mod+k"` — command palette (toggle modal)
103
+ * @example `hotkey="/"` with `hotkeyBehavior="focus"` — focus input (inline searchbar)
104
+ */
105
+ hotkey?: string;
106
+ /**
107
+ * What the hotkey does: `"toggle"` (default) opens/closes the modal; `"focus"` focuses the input.
108
+ * Use `"focus"` for inline Searchbar so the hotkey focuses the input instead of toggling.
109
+ */
110
+ hotkeyBehavior?: "focus" | "toggle";
111
+ /** Called when an item is selected. Receives the item's `value`. */
112
+ onSelect?: (value: string) => void;
113
+ children: React.ReactNode;
114
+ className?: string;
115
+ asChild?: boolean;
116
+ } & Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> & React.RefAttributes<HTMLDivElement>>;
@@ -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";