@dxos/react-ui-searchlist 0.8.4-main.ae835ea → 0.8.4-main.bc674ce

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