@dxos/react-ui-searchlist 0.8.4-main.e8ec1fe → 0.8.4-main.ef1bc66f44
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/dist/lib/browser/index.mjs +665 -336
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +665 -336
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/Combobox/Combobox.d.ts +50 -10
- package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/types/src/components/Combobox/Combobox.stories.d.ts +1 -1
- package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts +1 -1
- package/dist/types/src/components/SearchList/SearchList.d.ts +84 -20
- package/dist/types/src/components/SearchList/SearchList.d.ts.map +1 -1
- package/dist/types/src/components/SearchList/SearchList.stories.d.ts +10 -7
- package/dist/types/src/components/SearchList/SearchList.stories.d.ts.map +1 -1
- package/dist/types/src/components/SearchList/context.d.ts +33 -0
- package/dist/types/src/components/SearchList/context.d.ts.map +1 -0
- package/dist/types/src/components/SearchList/hooks/index.d.ts +5 -0
- package/dist/types/src/components/SearchList/hooks/index.d.ts.map +1 -0
- package/dist/types/src/components/SearchList/hooks/useGlobalFilter.d.ts +34 -0
- package/dist/types/src/components/SearchList/hooks/useGlobalFilter.d.ts.map +1 -0
- package/dist/types/src/components/SearchList/hooks/useSearchListInput.d.ts +12 -0
- package/dist/types/src/components/SearchList/hooks/useSearchListInput.d.ts.map +1 -0
- package/dist/types/src/components/SearchList/hooks/useSearchListItem.d.ts +10 -0
- package/dist/types/src/components/SearchList/hooks/useSearchListItem.d.ts.map +1 -0
- package/dist/types/src/components/SearchList/hooks/useSearchListResults.d.ts +36 -0
- package/dist/types/src/components/SearchList/hooks/useSearchListResults.d.ts.map +1 -0
- package/dist/types/src/components/SearchList/index.d.ts +1 -0
- package/dist/types/src/components/SearchList/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +2 -2
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -17
- package/src/components/Combobox/Combobox.stories.tsx +9 -4
- package/src/components/Combobox/Combobox.tsx +36 -23
- package/src/components/Listbox/Listbox.stories.tsx +1 -1
- package/src/components/Listbox/Listbox.tsx +8 -3
- package/src/components/SearchList/SearchList.stories.tsx +496 -28
- package/src/components/SearchList/SearchList.tsx +460 -62
- package/src/components/SearchList/context.ts +43 -0
- package/src/components/SearchList/hooks/index.ts +8 -0
- package/src/components/SearchList/hooks/useGlobalFilter.tsx +61 -0
- package/src/components/SearchList/hooks/useSearchListInput.ts +14 -0
- package/src/components/SearchList/hooks/useSearchListItem.ts +14 -0
- package/src/components/SearchList/hooks/useSearchListResults.ts +104 -0
- package/src/components/SearchList/index.ts +1 -0
- package/src/translations.ts +1 -1
- package/src/types/command-score.d.ts +16 -0
|
@@ -1,76 +1,386 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Copyright
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import React, {
|
|
5
|
+
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
|
6
|
+
import React, {
|
|
7
|
+
type ChangeEvent,
|
|
8
|
+
type ComponentPropsWithRef,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
type PropsWithChildren,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
forwardRef,
|
|
13
|
+
useCallback,
|
|
14
|
+
useEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from 'react';
|
|
7
19
|
|
|
8
20
|
import {
|
|
9
|
-
type
|
|
21
|
+
type Density,
|
|
22
|
+
type Elevation,
|
|
23
|
+
Icon,
|
|
10
24
|
type ThemedClassName,
|
|
11
25
|
useDensityContext,
|
|
12
26
|
useElevationContext,
|
|
13
27
|
useThemeContext,
|
|
14
28
|
useTranslation,
|
|
15
29
|
} from '@dxos/react-ui';
|
|
16
|
-
import { mx } from '@dxos/
|
|
30
|
+
import { descriptionText, mx } from '@dxos/ui-theme';
|
|
17
31
|
|
|
18
32
|
import { translationKey } from '../../translations';
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
import {
|
|
35
|
+
SearchListInputContextProvider,
|
|
36
|
+
SearchListItemContextProvider,
|
|
37
|
+
useSearchListInputContext,
|
|
38
|
+
useSearchListItemContext,
|
|
39
|
+
} from './context';
|
|
40
|
+
|
|
41
|
+
//
|
|
42
|
+
// Internal types
|
|
43
|
+
//
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
type ItemData = {
|
|
46
|
+
element: HTMLElement;
|
|
47
|
+
disabled?: boolean;
|
|
48
|
+
onSelect?: () => void;
|
|
49
|
+
};
|
|
26
50
|
|
|
27
51
|
//
|
|
28
52
|
// Root
|
|
29
53
|
//
|
|
30
54
|
|
|
31
|
-
type
|
|
55
|
+
type SearchListRootProps = PropsWithChildren<{
|
|
56
|
+
/** Controlled query value. */
|
|
57
|
+
value?: string;
|
|
58
|
+
|
|
59
|
+
/** Default query value for uncontrolled mode. */
|
|
60
|
+
defaultValue?: string;
|
|
61
|
+
|
|
62
|
+
/** Debounce delay in milliseconds. */
|
|
63
|
+
debounceMs?: number;
|
|
64
|
+
|
|
65
|
+
/** Callback when search query changes (debounced). */
|
|
66
|
+
onSearch?: (query: string) => void;
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
const SearchListRoot = ({
|
|
70
|
+
children,
|
|
71
|
+
value: valueProp,
|
|
72
|
+
defaultValue = '',
|
|
73
|
+
debounceMs = 200,
|
|
74
|
+
onSearch,
|
|
75
|
+
}: SearchListRootProps) => {
|
|
76
|
+
const [query = '', setQuery] = useControllableState({
|
|
77
|
+
prop: valueProp,
|
|
78
|
+
defaultProp: defaultValue,
|
|
79
|
+
onChange: undefined,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
|
|
83
|
+
|
|
84
|
+
// Track registered items: value -> { element, onSelect, disabled }.
|
|
85
|
+
const itemsRef = useRef<Map<string, ItemData>>(new Map());
|
|
86
|
+
|
|
87
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
88
|
+
|
|
89
|
+
const handleQueryChange = useCallback(
|
|
90
|
+
(newQuery: string) => {
|
|
91
|
+
setQuery(newQuery);
|
|
92
|
+
// Don't update selectedValue here - let the effect handle it when items actually change.
|
|
93
|
+
// This prevents unnecessary re-renders of items when query changes.
|
|
94
|
+
|
|
95
|
+
// Debounce onSearch callback.
|
|
96
|
+
if (debounceRef.current) {
|
|
97
|
+
clearTimeout(debounceRef.current);
|
|
98
|
+
}
|
|
99
|
+
debounceRef.current = setTimeout(() => {
|
|
100
|
+
onSearch?.(newQuery);
|
|
101
|
+
}, debounceMs);
|
|
102
|
+
},
|
|
103
|
+
[setQuery, onSearch, debounceMs],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Track when items change to trigger first-item selection.
|
|
107
|
+
const [itemVersion, setItemVersion] = useState(0);
|
|
108
|
+
|
|
109
|
+
// Cleanup debounce on unmount.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
return () => {
|
|
112
|
+
if (debounceRef.current) {
|
|
113
|
+
clearTimeout(debounceRef.current);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
// Auto-select first non-disabled item when items change and no valid selection exists.
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
// Check if current selection is still valid (exists and not disabled).
|
|
121
|
+
const currentItem = selectedValue !== undefined ? itemsRef.current.get(selectedValue) : undefined;
|
|
122
|
+
const isSelectionValid = currentItem !== undefined && !currentItem.disabled;
|
|
123
|
+
if (!isSelectionValid && itemsRef.current.size > 0) {
|
|
124
|
+
// Get first non-disabled item in DOM order.
|
|
125
|
+
const entries = Array.from(itemsRef.current.entries()).filter(([, data]) => !data.disabled);
|
|
126
|
+
if (entries.length > 0) {
|
|
127
|
+
entries.sort(([, a], [, b]) => {
|
|
128
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
129
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
130
|
+
return -1;
|
|
131
|
+
}
|
|
132
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
return 0;
|
|
136
|
+
});
|
|
137
|
+
const firstValue = entries[0]?.[0];
|
|
138
|
+
if (firstValue !== undefined && firstValue !== selectedValue) {
|
|
139
|
+
setSelectedValue(firstValue);
|
|
140
|
+
}
|
|
141
|
+
} else if (selectedValue !== undefined) {
|
|
142
|
+
// No valid items available, clear selection
|
|
143
|
+
setSelectedValue(undefined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}, [itemVersion, selectedValue]);
|
|
147
|
+
|
|
148
|
+
const registerItem = useCallback(
|
|
149
|
+
(value: string, element: HTMLElement | null, onSelect: (() => void) | undefined, disabled?: boolean) => {
|
|
150
|
+
if (element) {
|
|
151
|
+
itemsRef.current.set(value, { element, onSelect, disabled });
|
|
152
|
+
setItemVersion((v) => v + 1);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const unregisterItem = useCallback((value: string) => {
|
|
159
|
+
itemsRef.current.delete(value);
|
|
160
|
+
setItemVersion((v) => v + 1);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// Get item values in DOM order by sorting registered elements (excludes disabled items).
|
|
164
|
+
const getItemValues = useCallback(() => {
|
|
165
|
+
return Array.from(itemsRef.current.entries())
|
|
166
|
+
.filter(([, data]) => !data.disabled)
|
|
167
|
+
.sort(([, a], [, b]) => {
|
|
168
|
+
// Sort by DOM position using compareDocumentPosition.
|
|
169
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
170
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : position & Node.DOCUMENT_POSITION_PRECEDING ? 1 : 0;
|
|
171
|
+
})
|
|
172
|
+
.map(([value]) => value);
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const triggerSelect = useCallback(() => {
|
|
176
|
+
if (selectedValue !== undefined) {
|
|
177
|
+
const item = itemsRef.current.get(selectedValue);
|
|
178
|
+
item?.onSelect?.();
|
|
179
|
+
}
|
|
180
|
+
}, [selectedValue]);
|
|
181
|
+
|
|
182
|
+
// Item context; stable, doesn't change when query changes.
|
|
183
|
+
const itemContextValue = useMemo(
|
|
184
|
+
() => ({
|
|
185
|
+
selectedValue,
|
|
186
|
+
onSelectedValueChange: setSelectedValue,
|
|
187
|
+
registerItem,
|
|
188
|
+
unregisterItem,
|
|
189
|
+
}),
|
|
190
|
+
[selectedValue, registerItem, unregisterItem],
|
|
191
|
+
);
|
|
32
192
|
|
|
33
|
-
|
|
34
|
-
|
|
193
|
+
const inputContextValue = useMemo(
|
|
194
|
+
() => ({
|
|
195
|
+
query,
|
|
196
|
+
onQueryChange: handleQueryChange,
|
|
197
|
+
selectedValue,
|
|
198
|
+
onSelectedValueChange: setSelectedValue,
|
|
199
|
+
getItemValues,
|
|
200
|
+
triggerSelect,
|
|
201
|
+
}),
|
|
202
|
+
[query, handleQueryChange, selectedValue, getItemValues, triggerSelect],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// NOTE: Separate contexts for items and input to avoid unnecessary re-renders of items when query changes.
|
|
206
|
+
return (
|
|
207
|
+
<SearchListInputContextProvider
|
|
208
|
+
query={inputContextValue.query}
|
|
209
|
+
onQueryChange={inputContextValue.onQueryChange}
|
|
210
|
+
selectedValue={inputContextValue.selectedValue}
|
|
211
|
+
onSelectedValueChange={inputContextValue.onSelectedValueChange}
|
|
212
|
+
getItemValues={inputContextValue.getItemValues}
|
|
213
|
+
triggerSelect={inputContextValue.triggerSelect}
|
|
214
|
+
>
|
|
215
|
+
<SearchListItemContextProvider
|
|
216
|
+
selectedValue={itemContextValue.selectedValue}
|
|
217
|
+
onSelectedValueChange={itemContextValue.onSelectedValueChange}
|
|
218
|
+
registerItem={itemContextValue.registerItem}
|
|
219
|
+
unregisterItem={itemContextValue.unregisterItem}
|
|
220
|
+
>
|
|
221
|
+
{children}
|
|
222
|
+
</SearchListItemContextProvider>
|
|
223
|
+
</SearchListInputContextProvider>
|
|
224
|
+
);
|
|
35
225
|
};
|
|
36
226
|
|
|
37
|
-
|
|
38
|
-
|
|
227
|
+
SearchListRoot.displayName = 'SearchList.Root';
|
|
228
|
+
|
|
229
|
+
//
|
|
230
|
+
// Content
|
|
231
|
+
//
|
|
232
|
+
|
|
233
|
+
type SearchListContentProps = ThemedClassName<PropsWithChildren<{}>>;
|
|
234
|
+
|
|
235
|
+
const SearchListContent = forwardRef<HTMLDivElement, SearchListContentProps>(
|
|
236
|
+
({ classNames, children }, forwardedRef) => {
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
role='none'
|
|
240
|
+
// TODO(burdon): Remove p-1 hack.
|
|
241
|
+
className={mx('flex flex-col gap-2 bs-full is-full min-bs-0 overflow-hidden p-1', classNames)}
|
|
242
|
+
ref={forwardedRef}
|
|
243
|
+
>
|
|
244
|
+
{children}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
SearchListContent.displayName = 'SearchList.Content';
|
|
251
|
+
|
|
252
|
+
//
|
|
253
|
+
// Viewport
|
|
254
|
+
//
|
|
255
|
+
|
|
256
|
+
type SearchListViewportProps = ThemedClassName<PropsWithChildren>;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Scrollable viewport wrapper for Content.
|
|
260
|
+
* Only Content wrapped in Viewport will be scrollable.
|
|
261
|
+
*/
|
|
262
|
+
// TODO(burdon): Reconcile with Mosaic.Viewport (factor out common core?).
|
|
263
|
+
const SearchListViewport = forwardRef<HTMLDivElement, SearchListViewportProps>(
|
|
264
|
+
({ classNames, children }, forwardedRef) => {
|
|
39
265
|
return (
|
|
40
|
-
<
|
|
266
|
+
<div role='listbox' className={mx('bs-full is-full min-bs-0 overflow-y-auto', classNames)} ref={forwardedRef}>
|
|
41
267
|
{children}
|
|
42
|
-
</
|
|
268
|
+
</div>
|
|
43
269
|
);
|
|
44
270
|
},
|
|
45
271
|
);
|
|
46
272
|
|
|
47
|
-
|
|
273
|
+
SearchListViewport.displayName = 'SearchList.Viewport';
|
|
48
274
|
|
|
49
275
|
//
|
|
50
276
|
// Input
|
|
51
277
|
//
|
|
52
278
|
|
|
53
|
-
type
|
|
279
|
+
type InputVariant = 'default' | 'subdued';
|
|
54
280
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
281
|
+
type SearchListInputProps = ThemedClassName<
|
|
282
|
+
Omit<ComponentPropsWithRef<'input'>, 'value'> & {
|
|
283
|
+
density?: Density;
|
|
284
|
+
elevation?: Elevation;
|
|
285
|
+
variant?: InputVariant;
|
|
286
|
+
}
|
|
287
|
+
>;
|
|
58
288
|
|
|
59
289
|
const SearchListInput = forwardRef<HTMLInputElement, SearchListInputProps>(
|
|
60
|
-
(
|
|
290
|
+
(
|
|
291
|
+
{ classNames, density: propsDensity, elevation: propsElevation, variant, placeholder, onChange, ...props },
|
|
292
|
+
forwardedRef,
|
|
293
|
+
) => {
|
|
294
|
+
const { query, onQueryChange, selectedValue, onSelectedValueChange, getItemValues, triggerSelect } =
|
|
295
|
+
useSearchListInputContext('SearchList.Input');
|
|
61
296
|
const { t } = useTranslation(translationKey);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
// TODO(thure): Keep this in-sync with `TextInput`, or submit a PR for `cmdk` to support `asChild` so we don’t have to.
|
|
65
|
-
const { hasIosKeyboard } = useThemeContext();
|
|
66
|
-
const { tx } = useThemeContext();
|
|
297
|
+
const { hasIosKeyboard, tx } = useThemeContext();
|
|
67
298
|
const density = useDensityContext(propsDensity);
|
|
68
299
|
const elevation = useElevationContext(propsElevation);
|
|
300
|
+
const defaultPlaceholder = t('search.placeholder');
|
|
301
|
+
|
|
302
|
+
const handleChange = useCallback(
|
|
303
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
304
|
+
onQueryChange(event.target.value);
|
|
305
|
+
onChange?.(event);
|
|
306
|
+
},
|
|
307
|
+
[onQueryChange, onChange],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const handleKeyDown = useCallback(
|
|
311
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
312
|
+
const values = getItemValues();
|
|
313
|
+
if (values.length === 0) {
|
|
314
|
+
if (event.key === 'Escape') {
|
|
315
|
+
onQueryChange('');
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const currentIndex = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
|
|
321
|
+
|
|
322
|
+
switch (event.key) {
|
|
323
|
+
case 'ArrowDown': {
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, values.length - 1);
|
|
326
|
+
const nextValue = values[nextIndex];
|
|
327
|
+
if (nextValue !== undefined) {
|
|
328
|
+
onSelectedValueChange(nextValue);
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case 'ArrowUp': {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
const prevIndex = currentIndex === -1 ? values.length - 1 : Math.max(currentIndex - 1, 0);
|
|
335
|
+
const prevValue = values[prevIndex];
|
|
336
|
+
if (prevValue !== undefined) {
|
|
337
|
+
onSelectedValueChange(prevValue);
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case 'Enter': {
|
|
342
|
+
if (selectedValue !== undefined) {
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
triggerSelect();
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
case 'Home': {
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
const firstValue = values[0];
|
|
351
|
+
if (firstValue !== undefined) {
|
|
352
|
+
onSelectedValueChange(firstValue);
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case 'End': {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
const lastValue = values[values.length - 1];
|
|
359
|
+
if (lastValue !== undefined) {
|
|
360
|
+
onSelectedValueChange(lastValue);
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case 'Escape': {
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
if (selectedValue !== undefined) {
|
|
367
|
+
onSelectedValueChange(undefined);
|
|
368
|
+
} else {
|
|
369
|
+
onQueryChange('');
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
[selectedValue, onSelectedValueChange, getItemValues, triggerSelect, onQueryChange],
|
|
376
|
+
);
|
|
69
377
|
|
|
70
378
|
return (
|
|
71
|
-
<
|
|
379
|
+
<input
|
|
72
380
|
{...props}
|
|
73
|
-
|
|
381
|
+
{...(props.autoFocus && !hasIosKeyboard && { autoFocus: true })}
|
|
382
|
+
type='text'
|
|
383
|
+
placeholder={placeholder ?? defaultPlaceholder}
|
|
74
384
|
className={tx(
|
|
75
385
|
'input.input',
|
|
76
386
|
'input',
|
|
@@ -80,65 +390,151 @@ const SearchListInput = forwardRef<HTMLInputElement, SearchListInputProps>(
|
|
|
80
390
|
density,
|
|
81
391
|
elevation,
|
|
82
392
|
},
|
|
83
|
-
'mbe-cardSpacingBlock',
|
|
84
393
|
classNames,
|
|
85
394
|
)}
|
|
86
|
-
{
|
|
395
|
+
value={query}
|
|
396
|
+
onChange={handleChange}
|
|
397
|
+
onKeyDown={handleKeyDown}
|
|
87
398
|
ref={forwardedRef}
|
|
88
399
|
/>
|
|
89
400
|
);
|
|
90
401
|
},
|
|
91
402
|
);
|
|
92
403
|
|
|
404
|
+
SearchListInput.displayName = 'SearchList.Input';
|
|
405
|
+
|
|
93
406
|
//
|
|
94
|
-
//
|
|
407
|
+
// Item
|
|
95
408
|
//
|
|
96
409
|
|
|
97
|
-
type
|
|
410
|
+
type SearchListItemProps = ThemedClassName<{
|
|
411
|
+
/** Unique identifier for the item. */
|
|
412
|
+
value: string;
|
|
413
|
+
/** Display label for the item. */
|
|
414
|
+
label: string;
|
|
415
|
+
/** Icon to display (string identifier for Icon component). */
|
|
416
|
+
icon?: string;
|
|
417
|
+
/** Whether to show a check icon. */
|
|
418
|
+
checked?: boolean;
|
|
419
|
+
/** Suffix text to display after the label. */
|
|
420
|
+
suffix?: string;
|
|
421
|
+
/** Callback when item is selected. */
|
|
422
|
+
onSelect?: () => void;
|
|
423
|
+
/** Whether the item is disabled. */
|
|
424
|
+
disabled?: boolean;
|
|
425
|
+
}>;
|
|
426
|
+
|
|
427
|
+
const SearchListItem = forwardRef<HTMLDivElement, SearchListItemProps>(
|
|
428
|
+
({ classNames, value, label, icon, checked, suffix, onSelect, disabled }, forwardedRef) => {
|
|
429
|
+
const { selectedValue, registerItem, unregisterItem } = useSearchListItemContext('SearchList.Item');
|
|
430
|
+
const internalRef = useRef<HTMLDivElement>(null);
|
|
431
|
+
|
|
432
|
+
const isSelected = selectedValue === value && !disabled;
|
|
433
|
+
|
|
434
|
+
// Register this item.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
const element = internalRef.current;
|
|
437
|
+
if (element) {
|
|
438
|
+
registerItem(value, element, onSelect, disabled);
|
|
439
|
+
}
|
|
440
|
+
return () => unregisterItem(value);
|
|
441
|
+
}, [value, onSelect, disabled, registerItem, unregisterItem]);
|
|
442
|
+
|
|
443
|
+
// Scroll into view when selected.
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (isSelected && internalRef.current) {
|
|
446
|
+
internalRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
447
|
+
}
|
|
448
|
+
}, [isSelected]);
|
|
449
|
+
|
|
450
|
+
const handleClick = useCallback(() => {
|
|
451
|
+
if (!disabled) {
|
|
452
|
+
onSelect?.();
|
|
453
|
+
}
|
|
454
|
+
}, [onSelect, disabled]);
|
|
98
455
|
|
|
99
|
-
const SearchListContent = forwardRef<HTMLDivElement, SearchListContentProps>(
|
|
100
|
-
({ children, classNames, ...props }, forwardedRef) => {
|
|
101
456
|
return (
|
|
102
|
-
<
|
|
103
|
-
{
|
|
104
|
-
|
|
457
|
+
<div
|
|
458
|
+
ref={(node) => {
|
|
459
|
+
internalRef.current = node;
|
|
460
|
+
if (typeof forwardedRef === 'function') {
|
|
461
|
+
forwardedRef(node);
|
|
462
|
+
} else if (forwardedRef) {
|
|
463
|
+
forwardedRef.current = node;
|
|
464
|
+
}
|
|
465
|
+
}}
|
|
466
|
+
role='option'
|
|
467
|
+
aria-selected={isSelected}
|
|
468
|
+
aria-disabled={disabled}
|
|
469
|
+
data-selected={isSelected}
|
|
470
|
+
data-disabled={disabled}
|
|
471
|
+
data-value={value}
|
|
472
|
+
tabIndex={-1}
|
|
473
|
+
className={mx(
|
|
474
|
+
'flex gap-2 items-center',
|
|
475
|
+
'plb-1 pli-2 rounded-sm select-none cursor-pointer data-[selected=true]:bg-hoverOverlay hover:bg-hoverOverlay',
|
|
476
|
+
disabled && 'opacity-50 cursor-not-allowed hover:bg-transparent data-[selected=true]:bg-transparent',
|
|
477
|
+
classNames,
|
|
478
|
+
)}
|
|
479
|
+
onClick={handleClick}
|
|
480
|
+
>
|
|
481
|
+
{icon && <Icon icon={icon} size={5} />}
|
|
482
|
+
<span className='is-0 grow truncate'>{label}</span>
|
|
483
|
+
{suffix && <span className={mx('shrink-0', descriptionText)}>{suffix}</span>}
|
|
484
|
+
{checked && <Icon icon='ph--check--regular' size={5} />}
|
|
485
|
+
</div>
|
|
105
486
|
);
|
|
106
487
|
},
|
|
107
488
|
);
|
|
108
489
|
|
|
490
|
+
SearchListItem.displayName = 'SearchList.Item';
|
|
491
|
+
|
|
109
492
|
//
|
|
110
493
|
// Empty
|
|
111
494
|
//
|
|
112
495
|
|
|
113
|
-
type SearchListEmptyProps = ThemedClassName<
|
|
496
|
+
type SearchListEmptyProps = ThemedClassName<PropsWithChildren<{}>>;
|
|
114
497
|
|
|
115
|
-
const SearchListEmpty =
|
|
116
|
-
(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
498
|
+
const SearchListEmpty = ({ classNames, children }: SearchListEmptyProps) => {
|
|
499
|
+
return (
|
|
500
|
+
<div role='status' className={mx('flex flex-col is-full pli-2 plb-1', classNames)}>
|
|
501
|
+
{children}
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
SearchListEmpty.displayName = 'SearchList.Empty';
|
|
124
507
|
|
|
125
508
|
//
|
|
126
|
-
//
|
|
509
|
+
// Group
|
|
127
510
|
//
|
|
128
511
|
|
|
129
|
-
type
|
|
512
|
+
type SearchListGroupProps = ThemedClassName<
|
|
513
|
+
PropsWithChildren<{
|
|
514
|
+
/** Heading for the group. */
|
|
515
|
+
heading?: ReactNode;
|
|
516
|
+
}>
|
|
517
|
+
>;
|
|
130
518
|
|
|
131
|
-
|
|
132
|
-
|
|
519
|
+
/**
|
|
520
|
+
* Groups related search items with an optional heading.
|
|
521
|
+
*/
|
|
522
|
+
const SearchListGroup = forwardRef<HTMLDivElement, SearchListGroupProps>(
|
|
523
|
+
({ classNames, heading, children }, forwardedRef) => {
|
|
133
524
|
return (
|
|
134
|
-
<
|
|
525
|
+
<div ref={forwardedRef} role='group' className={mx('flex flex-col', classNames)}>
|
|
526
|
+
{heading && (
|
|
527
|
+
<div role='presentation' className='pli-2 plb-1 text-xs font-medium text-description'>
|
|
528
|
+
{heading}
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
135
531
|
{children}
|
|
136
|
-
</
|
|
532
|
+
</div>
|
|
137
533
|
);
|
|
138
534
|
},
|
|
139
535
|
);
|
|
140
536
|
|
|
141
|
-
|
|
537
|
+
SearchListGroup.displayName = 'SearchList.Group';
|
|
142
538
|
|
|
143
539
|
//
|
|
144
540
|
// SearchList
|
|
@@ -146,18 +542,20 @@ SearchListItem.displayName = SEARCHLIST_ITEM_NAME;
|
|
|
146
542
|
|
|
147
543
|
export const SearchList = {
|
|
148
544
|
Root: SearchListRoot,
|
|
149
|
-
Input: SearchListInput,
|
|
150
545
|
Content: SearchListContent,
|
|
151
|
-
|
|
546
|
+
Viewport: SearchListViewport,
|
|
547
|
+
Input: SearchListInput,
|
|
152
548
|
Item: SearchListItem,
|
|
549
|
+
Empty: SearchListEmpty,
|
|
550
|
+
Group: SearchListGroup,
|
|
153
551
|
};
|
|
154
552
|
|
|
155
553
|
export type {
|
|
156
554
|
SearchListRootProps,
|
|
157
|
-
SearchListInputProps,
|
|
158
555
|
SearchListContentProps,
|
|
159
|
-
|
|
556
|
+
SearchListViewportProps,
|
|
557
|
+
SearchListInputProps,
|
|
160
558
|
SearchListItemProps,
|
|
559
|
+
SearchListEmptyProps,
|
|
560
|
+
SearchListGroupProps,
|
|
161
561
|
};
|
|
162
|
-
|
|
163
|
-
export { commandItem, searchListItem };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createContext } from '@radix-ui/react-context';
|
|
6
|
+
|
|
7
|
+
/** Context for items - stable, doesn't change when query changes */
|
|
8
|
+
export type SearchListItemContextValue = {
|
|
9
|
+
/** Currently selected item value for keyboard navigation. */
|
|
10
|
+
selectedValue: string | undefined;
|
|
11
|
+
/** Update the selected value. */
|
|
12
|
+
onSelectedValueChange: (value: string | undefined) => void;
|
|
13
|
+
/** Register an item for keyboard navigation. */
|
|
14
|
+
registerItem: (
|
|
15
|
+
value: string,
|
|
16
|
+
element: HTMLElement | null,
|
|
17
|
+
onSelect: (() => void) | undefined,
|
|
18
|
+
disabled?: boolean,
|
|
19
|
+
) => void;
|
|
20
|
+
/** Unregister an item. */
|
|
21
|
+
unregisterItem: (value: string) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Context for input - can change frequently with query */
|
|
25
|
+
export type SearchListInputContextValue = {
|
|
26
|
+
/** Current search query. */
|
|
27
|
+
query: string;
|
|
28
|
+
/** Update the query value. */
|
|
29
|
+
onQueryChange: (query: string) => void;
|
|
30
|
+
/** Currently selected item value for keyboard navigation. */
|
|
31
|
+
selectedValue: string | undefined;
|
|
32
|
+
/** Update the selected value. */
|
|
33
|
+
onSelectedValueChange: (value: string | undefined) => void;
|
|
34
|
+
/** Get ordered list of registered item values (excludes disabled items). */
|
|
35
|
+
getItemValues: () => string[];
|
|
36
|
+
/** Trigger selection of the currently highlighted item. */
|
|
37
|
+
triggerSelect: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const [SearchListItemContextProvider, useSearchListItemContext] =
|
|
41
|
+
createContext<SearchListItemContextValue>('SearchListItem');
|
|
42
|
+
export const [SearchListInputContextProvider, useSearchListInputContext] =
|
|
43
|
+
createContext<SearchListInputContextValue>('SearchListInput');
|