@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,58 +1,479 @@
1
1
  //
2
- // Copyright 2023 DXOS.org
2
+ // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React from 'react';
6
+ import React, { useState } from 'react';
7
7
 
8
8
  import { faker } from '@dxos/random';
9
9
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
10
 
11
11
  import { translations } from '../../translations';
12
12
 
13
+ import { useSearchListInput, useSearchListItem, useSearchListResults } from './hooks';
13
14
  import { SearchList } from './SearchList';
14
15
 
15
16
  faker.seed(1234);
16
17
 
17
- type StoryItems = Record<string, string>;
18
+ type StoryItem = {
19
+ id: string;
20
+ label: string;
21
+ icon?: string;
22
+ };
23
+
24
+ const defaultItems: StoryItem[] = faker.helpers.uniqueArray(faker.commerce.productName, 16).map((label) => ({
25
+ id: faker.string.uuid(),
26
+ label,
27
+ icon: 'ph--file--regular',
28
+ }));
29
+
30
+ //
31
+ // Default Story - Basic composition with SearchList.Item
32
+ //
33
+
34
+ type DefaultStoryProps = {
35
+ items?: StoryItem[];
36
+ };
37
+
38
+ const DefaultStory = ({ items = defaultItems }: DefaultStoryProps) => {
39
+ const { results, handleSearch } = useSearchListResults({ items });
40
+
41
+ return (
42
+ <div className='flex flex-col is-full bs-[400px]'>
43
+ <SearchList.Root onSearch={handleSearch}>
44
+ <SearchList.Input placeholder='Search items...' autoFocus />
45
+ <SearchList.Content>
46
+ <SearchList.Viewport>
47
+ {results.length > 0 ? (
48
+ results.map((item) => (
49
+ <SearchList.Item
50
+ key={item.id}
51
+ value={item.id}
52
+ label={item.label}
53
+ icon={item.icon}
54
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id, item.label)}
55
+ />
56
+ ))
57
+ ) : (
58
+ <SearchList.Empty>No results found</SearchList.Empty>
59
+ )}
60
+ </SearchList.Viewport>
61
+ </SearchList.Content>
62
+ </SearchList.Root>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ //
68
+ // Controlled Story - Controlled query state
69
+ //
70
+
71
+ const ControlledStory = ({ items = defaultItems }: DefaultStoryProps) => {
72
+ const [query, setQuery] = useState('');
73
+ const [results, setResults] = useState<StoryItem[]>(items);
74
+
75
+ const handleSearch = (searchQuery: string) => {
76
+ if (!searchQuery) {
77
+ setResults(items);
78
+ return;
79
+ }
80
+ const filtered = items.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase()));
81
+ setResults(filtered);
82
+ };
83
+
84
+ const handleQueryChange = (newQuery: string) => {
85
+ setQuery(newQuery);
86
+ handleSearch(newQuery);
87
+ };
88
+
89
+ return (
90
+ <div className='is-full bs-[400px] flex flex-col gap-2'>
91
+ <div className='text-sm text-description'>Controlled query: &quot;{query}&quot;</div>
92
+ <SearchList.Root onSearch={handleSearch} value={query}>
93
+ <SearchList.Input placeholder='Controlled search...' onChange={(e) => handleQueryChange(e.target.value)} />
94
+ <SearchList.Content>
95
+ <SearchList.Viewport>
96
+ {results.map((item) => (
97
+ <SearchList.Item
98
+ key={item.id}
99
+ value={item.id}
100
+ label={item.label}
101
+ icon={item.icon}
102
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id)}
103
+ />
104
+ ))}
105
+ </SearchList.Viewport>
106
+ </SearchList.Content>
107
+ </SearchList.Root>
108
+ <button className='pli-2 plb-1 rounded bg-accentSurface text-accentText' onClick={() => handleQueryChange('')}>
109
+ Clear Query
110
+ </button>
111
+ </div>
112
+ );
113
+ };
114
+
115
+ //
116
+ // Custom Rendering Story - Custom components in Content using useSearchItem hook
117
+ //
118
+
119
+ type CustomItemProps = {
120
+ value: string;
121
+ label: string;
122
+ description: string;
123
+ onSelect?: () => void;
124
+ };
125
+
126
+ const CustomItem = ({ value, label, description, onSelect }: CustomItemProps) => {
127
+ const { selectedValue, registerItem, unregisterItem } = useSearchListItem();
128
+ const ref = React.useRef<HTMLDivElement>(null);
129
+ const isSelected = selectedValue === value;
130
+
131
+ React.useEffect(() => {
132
+ registerItem(value, ref.current, onSelect);
133
+ return () => unregisterItem(value);
134
+ }, [value, onSelect, registerItem, unregisterItem]);
135
+
136
+ // Scroll into view when selected.
137
+ React.useEffect(() => {
138
+ if (isSelected && ref.current) {
139
+ ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
140
+ }
141
+ }, [isSelected]);
142
+
143
+ return (
144
+ <div
145
+ ref={ref}
146
+ role='option'
147
+ aria-selected={isSelected}
148
+ data-selected={isSelected}
149
+ className={`p-2 border-be border-separator cursor-pointer ${isSelected ? 'bg-hoverOverlay' : 'hover:bg-hoverOverlay'}`}
150
+ onClick={onSelect}
151
+ >
152
+ <div className='font-medium'>{label}</div>
153
+ <div className='text-xs text-description'>{description}</div>
154
+ </div>
155
+ );
156
+ };
157
+
158
+ const CustomRenderingStory = ({ items = defaultItems }: DefaultStoryProps) => {
159
+ const { results, handleSearch } = useSearchListResults({ items });
160
+
161
+ return (
162
+ <div className='is-full bs-[400px] flex flex-col'>
163
+ <SearchList.Root onSearch={handleSearch}>
164
+ <SearchList.Input placeholder='Search with custom rendering...' autoFocus />
165
+ <SearchList.Content>
166
+ <SearchList.Viewport>
167
+ {results.map((item) => (
168
+ <CustomItem
169
+ key={item.id}
170
+ value={item.id}
171
+ label={item.label}
172
+ description={`ID: ${item.id}`}
173
+ onSelect={() => console.log('[CustomItem.onSelect]', item.id, item.label)}
174
+ />
175
+ ))}
176
+ </SearchList.Viewport>
177
+ </SearchList.Content>
178
+ </SearchList.Root>
179
+ </div>
180
+ );
181
+ };
182
+
183
+ //
184
+ // With Empty Story - Show Empty component when no results
185
+ //
186
+
187
+ const WithEmptyStory = () => {
188
+ const [hasSearched, setHasSearched] = useState(false);
189
+
190
+ const handleSearch = (query: string) => {
191
+ setHasSearched(!!query);
192
+ };
193
+
194
+ return (
195
+ <div className='is-full bs-[400px] flex flex-col'>
196
+ <SearchList.Root onSearch={handleSearch}>
197
+ <SearchList.Input placeholder='Try searching for anything...' />
198
+ <SearchList.Content>
199
+ {hasSearched ? (
200
+ <SearchList.Empty classNames='text-center text-description p-4'>
201
+ <div className='text-lg'>🔍</div>
202
+ <div>No results found</div>
203
+ <div className='text-xs'>Try a different search term</div>
204
+ </SearchList.Empty>
205
+ ) : (
206
+ <SearchList.Empty classNames='text-center text-description p-4'>
207
+ <div>Start typing to search</div>
208
+ </SearchList.Empty>
209
+ )}
210
+ </SearchList.Content>
211
+ </SearchList.Root>
212
+ </div>
213
+ );
214
+ };
215
+
216
+ //
217
+ // Without Viewport Story - Content without scrolling
218
+ //
219
+
220
+ const WithoutViewportStory = ({ items = defaultItems }: DefaultStoryProps) => {
221
+ const { results, handleSearch } = useSearchListResults({ items });
222
+
223
+ return (
224
+ <div className='is-full bs-[300px] flex flex-col'>
225
+ <SearchList.Root onSearch={handleSearch}>
226
+ <SearchList.Input placeholder='Search without viewport (no scroll)...' classNames='shrink-0' />
227
+ <SearchList.Content>
228
+ {results.map((item) => (
229
+ <SearchList.Item
230
+ key={item.id}
231
+ value={item.id}
232
+ label={item.label}
233
+ icon={item.icon}
234
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id)}
235
+ />
236
+ ))}
237
+ </SearchList.Content>
238
+ </SearchList.Root>
239
+ </div>
240
+ );
241
+ };
242
+
243
+ //
244
+ // With Icons Story - Various icon configurations
245
+ //
246
+
247
+ const iconsItems: StoryItem[] = [
248
+ { id: '1', label: 'Document', icon: 'ph--file-text--regular' },
249
+ { id: '2', label: 'Folder', icon: 'ph--folder--regular' },
250
+ { id: '3', label: 'Image', icon: 'ph--image--regular' },
251
+ { id: '4', label: 'Settings', icon: 'ph--gear--regular' },
252
+ { id: '5', label: 'No icon item' },
253
+ ];
18
254
 
19
- const defaultItems: StoryItems = faker.helpers
20
- .uniqueArray(faker.commerce.productName, 16)
21
- .sort()
22
- .reduce((acc: StoryItems, label) => {
23
- acc[faker.string.uuid()] = label;
24
- return acc;
25
- }, {});
255
+ const WithIconsStory = () => {
256
+ return (
257
+ <div className='is-full flex flex-col'>
258
+ <SearchList.Root>
259
+ <SearchList.Input placeholder='Search items with icons...' />
260
+ <SearchList.Content>
261
+ {iconsItems.map((item) => (
262
+ <SearchList.Item
263
+ key={item.id}
264
+ value={item.id}
265
+ label={item.label}
266
+ icon={item.icon}
267
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id)}
268
+ />
269
+ ))}
270
+ </SearchList.Content>
271
+ </SearchList.Root>
272
+ </div>
273
+ );
274
+ };
275
+
276
+ //
277
+ // Custom Input Story - Demonstrate using hooks for custom input
278
+ //
279
+
280
+ const CustomInput = () => {
281
+ const { query, onQueryChange, selectedValue, onSelectedValueChange, getItemValues, triggerSelect } =
282
+ useSearchListInput();
283
+
284
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
285
+ const values = getItemValues();
286
+ if (values.length === 0) {
287
+ if (event.key === 'Escape') {
288
+ onQueryChange('');
289
+ }
290
+ return;
291
+ }
292
+
293
+ const currentIndex = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
294
+
295
+ switch (event.key) {
296
+ case 'ArrowDown': {
297
+ event.preventDefault();
298
+ const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, values.length - 1);
299
+ const nextValue = values[nextIndex];
300
+ if (nextValue !== undefined) {
301
+ onSelectedValueChange(nextValue);
302
+ }
303
+ break;
304
+ }
305
+ case 'ArrowUp': {
306
+ event.preventDefault();
307
+ const prevIndex = currentIndex === -1 ? values.length - 1 : Math.max(currentIndex - 1, 0);
308
+ const prevValue = values[prevIndex];
309
+ if (prevValue !== undefined) {
310
+ onSelectedValueChange(prevValue);
311
+ }
312
+ break;
313
+ }
314
+ case 'Enter': {
315
+ if (selectedValue !== undefined) {
316
+ event.preventDefault();
317
+ triggerSelect();
318
+ }
319
+ break;
320
+ }
321
+ case 'Escape': {
322
+ event.preventDefault();
323
+ if (selectedValue !== undefined) {
324
+ onSelectedValueChange(undefined);
325
+ } else {
326
+ onQueryChange('');
327
+ }
328
+ break;
329
+ }
330
+ }
331
+ };
332
+
333
+ return (
334
+ <div className='flex gap-2 items-center p-2 bg-input rounded'>
335
+ <input
336
+ type='text'
337
+ value={query}
338
+ onChange={(ev) => onQueryChange(ev.target.value)}
339
+ onKeyDown={handleKeyDown}
340
+ placeholder='Custom input...'
341
+ className='bg-transparent outline-none grow'
342
+ />
343
+ {query && (
344
+ <button onClick={() => onQueryChange('')} className='text-description hover:text-baseText'>
345
+
346
+ </button>
347
+ )}
348
+ </div>
349
+ );
350
+ };
351
+
352
+ const CustomInputStory = ({ items = defaultItems }: DefaultStoryProps) => {
353
+ const { results, handleSearch } = useSearchListResults({ items });
26
354
 
27
- type StoryProps = {
28
- items: StoryItems;
355
+ return (
356
+ <div className='is-full bs-[400px] flex flex-col border border-separator'>
357
+ <SearchList.Root onSearch={handleSearch}>
358
+ <CustomInput />
359
+ <SearchList.Content>
360
+ <SearchList.Viewport>
361
+ {results.map((item) => (
362
+ <SearchList.Item
363
+ key={item.id}
364
+ value={item.id}
365
+ label={item.label}
366
+ icon={item.icon}
367
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id)}
368
+ />
369
+ ))}
370
+ </SearchList.Viewport>
371
+ </SearchList.Content>
372
+ </SearchList.Root>
373
+ </div>
374
+ );
29
375
  };
30
376
 
31
- const DefaultStory = ({ items = defaultItems }: StoryProps) => {
377
+ //
378
+ // With Disabled Items Story
379
+ //
380
+
381
+ const disabledItems: StoryItem[] = [
382
+ { id: '1', label: 'Available item 1', icon: 'ph--check--regular' },
383
+ { id: '2', label: 'Disabled item (cannot select)', icon: 'ph--prohibit--regular' },
384
+ { id: '3', label: 'Available item 2', icon: 'ph--check--regular' },
385
+ { id: '4', label: 'Disabled item 2', icon: 'ph--prohibit--regular' },
386
+ { id: '5', label: 'Available item 3', icon: 'ph--check--regular' },
387
+ ];
388
+
389
+ const WithDisabledItemsStory = () => {
390
+ return (
391
+ <div className='is-full flex flex-col'>
392
+ <SearchList.Root>
393
+ <SearchList.Input placeholder='Arrow keys skip disabled items...' autoFocus />
394
+ <SearchList.Content>
395
+ {disabledItems.map((item, index) => (
396
+ <SearchList.Item
397
+ key={item.id}
398
+ value={item.id}
399
+ label={item.label}
400
+ icon={item.icon}
401
+ disabled={index === 1 || index === 3}
402
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id)}
403
+ />
404
+ ))}
405
+ </SearchList.Content>
406
+ </SearchList.Root>
407
+ </div>
408
+ );
409
+ };
410
+
411
+ //
412
+ // With Groups Story
413
+ //
414
+
415
+ type GroupedItem = StoryItem & { category: string };
416
+
417
+ const groupedItems: GroupedItem[] = [
418
+ { id: '1', label: 'Document 1', icon: 'ph--file-text--regular', category: 'Documents' },
419
+ { id: '2', label: 'Document 2', icon: 'ph--file-text--regular', category: 'Documents' },
420
+ { id: '3', label: 'Image 1', icon: 'ph--image--regular', category: 'Images' },
421
+ { id: '4', label: 'Image 2', icon: 'ph--image--regular', category: 'Images' },
422
+ { id: '5', label: 'Settings', icon: 'ph--gear--regular', category: 'Other' },
423
+ ];
424
+
425
+ const WithGroupsStory = () => {
426
+ const { results, handleSearch } = useSearchListResults({ items: groupedItems });
427
+
428
+ // Group items by category.
429
+ const grouped = results.reduce(
430
+ (acc, item) => {
431
+ if (!acc[item.category]) {
432
+ acc[item.category] = [];
433
+ }
434
+ acc[item.category].push(item);
435
+ return acc;
436
+ },
437
+ {} as Record<string, GroupedItem[]>,
438
+ );
439
+
32
440
  return (
33
- <SearchList.Root filter={(value, search) => (items[value].toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}>
34
- <SearchList.Input />
35
- <SearchList.Content>
36
- {Object.entries(items).map(([value, label]) => (
37
- <SearchList.Item
38
- key={value}
39
- value={value}
40
- onSelect={(value) => console.log('[SearchList.Item.onSelect]', value)}
41
- >
42
- {label}
43
- </SearchList.Item>
44
- ))}
45
- </SearchList.Content>
46
- </SearchList.Root>
441
+ <div className='is-full bs-[400px] flex flex-col'>
442
+ <SearchList.Root onSearch={handleSearch}>
443
+ <SearchList.Input placeholder='Search grouped items...' autoFocus />
444
+ <SearchList.Content>
445
+ <SearchList.Viewport>
446
+ {Object.entries(grouped).map(([category, items]) => (
447
+ <SearchList.Group key={category} heading={category}>
448
+ {items.map((item) => (
449
+ <SearchList.Item
450
+ key={item.id}
451
+ value={item.id}
452
+ label={item.label}
453
+ icon={item.icon}
454
+ onSelect={() => console.log('[SearchList.Item.onSelect]', item.id, item.label)}
455
+ />
456
+ ))}
457
+ </SearchList.Group>
458
+ ))}
459
+ {results.length === 0 && <SearchList.Empty>No results found</SearchList.Empty>}
460
+ </SearchList.Viewport>
461
+ </SearchList.Content>
462
+ </SearchList.Root>
463
+ </div>
47
464
  );
48
465
  };
49
466
 
467
+ //
468
+ // Meta
469
+ //
470
+
50
471
  const meta = {
51
472
  title: 'ui/react-ui-searchlist/SearchList',
52
473
  component: SearchList.Root as any,
53
- render: DefaultStory,
54
- decorators: [withTheme, withLayout({ container: 'column', classNames: 'p-2' })],
474
+ decorators: [withTheme, withLayout({ layout: 'column' })],
55
475
  parameters: {
476
+ layout: 'fullscreen',
56
477
  translations,
57
478
  },
58
479
  } satisfies Meta<typeof DefaultStory>;
@@ -61,4 +482,53 @@ export default meta;
61
482
 
62
483
  type Story = StoryObj<typeof meta>;
63
484
 
64
- export const Default: Story = {};
485
+ export const Default: Story = {
486
+ render: DefaultStory,
487
+ args: {
488
+ items: defaultItems,
489
+ },
490
+ };
491
+
492
+ export const Controlled: Story = {
493
+ render: ControlledStory,
494
+ args: {
495
+ items: defaultItems,
496
+ },
497
+ };
498
+
499
+ export const CustomRendering: Story = {
500
+ render: CustomRenderingStory,
501
+ args: {
502
+ items: defaultItems,
503
+ },
504
+ };
505
+
506
+ export const WithEmpty: Story = {
507
+ render: WithEmptyStory,
508
+ };
509
+
510
+ export const WithoutViewport: Story = {
511
+ render: WithoutViewportStory,
512
+ args: {
513
+ items: defaultItems,
514
+ },
515
+ };
516
+
517
+ export const WithIcons: Story = {
518
+ render: WithIconsStory,
519
+ };
520
+
521
+ export const CustomInputExample: Story = {
522
+ render: CustomInputStory,
523
+ args: {
524
+ items: defaultItems,
525
+ },
526
+ };
527
+
528
+ export const WithDisabledItems: Story = {
529
+ render: WithDisabledItemsStory,
530
+ };
531
+
532
+ export const WithGroups: Story = {
533
+ render: WithGroupsStory,
534
+ };