@ledgerhq/lumen-ui-rnative 0.1.34 → 0.1.36

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 (120) hide show
  1. package/dist/module/index.js +1 -0
  2. package/dist/module/index.js.map +1 -1
  3. package/dist/module/lib/Animations/Pulse/Pulse.js +16 -6
  4. package/dist/module/lib/Animations/Pulse/Pulse.js.map +1 -1
  5. package/dist/module/lib/Components/BottomSheet/BottomSheet.js +12 -7
  6. package/dist/module/lib/Components/BottomSheet/BottomSheet.js.map +1 -1
  7. package/dist/module/lib/Components/BottomSheet/BottomSheet.stories.js +220 -1
  8. package/dist/module/lib/Components/BottomSheet/BottomSheet.stories.js.map +1 -1
  9. package/dist/module/lib/Components/BottomSheet/BottomSheet.test.js +73 -0
  10. package/dist/module/lib/Components/BottomSheet/BottomSheet.test.js.map +1 -1
  11. package/dist/module/lib/Components/BottomSheet/BottomSheetHeader.js +1 -1
  12. package/dist/module/lib/Components/BottomSheet/BottomSheetHeader.js.map +1 -1
  13. package/dist/module/lib/Components/BottomSheet/CustomHandle.js +15 -2
  14. package/dist/module/lib/Components/BottomSheet/CustomHandle.js.map +1 -1
  15. package/dist/module/lib/Components/Card/Card.js.map +1 -1
  16. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.js +184 -0
  17. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.js.map +1 -0
  18. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.mdx +139 -0
  19. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.stories.js +258 -0
  20. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.stories.js.map +1 -0
  21. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.test.js +94 -0
  22. package/dist/module/lib/Components/DescriptionItem/DescriptionItem.test.js.map +1 -0
  23. package/dist/module/lib/Components/DescriptionItem/index.js +5 -0
  24. package/dist/module/lib/Components/DescriptionItem/index.js.map +1 -0
  25. package/dist/module/lib/Components/DescriptionItem/types.js +4 -0
  26. package/dist/module/lib/Components/DescriptionItem/types.js.map +1 -0
  27. package/dist/module/lib/Components/ListItem/ListItem.js.map +1 -1
  28. package/dist/module/lib/Components/MediaImage/MediaImage.js +5 -1
  29. package/dist/module/lib/Components/MediaImage/MediaImage.js.map +1 -1
  30. package/dist/module/lib/Components/NavBar/CoinCapsule.js +1 -0
  31. package/dist/module/lib/Components/NavBar/CoinCapsule.js.map +1 -1
  32. package/dist/module/lib/Components/OptionList/OptionList.js +45 -4
  33. package/dist/module/lib/Components/OptionList/OptionList.js.map +1 -1
  34. package/dist/module/lib/Components/OptionList/OptionList.mdx +19 -0
  35. package/dist/module/lib/Components/OptionList/OptionList.stories.js +254 -1
  36. package/dist/module/lib/Components/OptionList/OptionList.stories.js.map +1 -1
  37. package/dist/module/lib/Components/OptionList/OptionList.test.js +136 -1
  38. package/dist/module/lib/Components/OptionList/OptionList.test.js.map +1 -1
  39. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js +39 -13
  40. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js.map +1 -1
  41. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.test.js +117 -2
  42. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.test.js.map +1 -1
  43. package/dist/module/lib/Components/PageIndicator/PageIndicator.test.js.map +1 -1
  44. package/dist/module/lib/Components/Skeleton/Skeleton.js +10 -3
  45. package/dist/module/lib/Components/Skeleton/Skeleton.js.map +1 -1
  46. package/dist/module/lib/Components/TabBar/TabBar.js +7 -6
  47. package/dist/module/lib/Components/TabBar/TabBar.js.map +1 -1
  48. package/dist/module/lib/Components/index.js +1 -0
  49. package/dist/module/lib/Components/index.js.map +1 -1
  50. package/dist/module/styles/lx/resolveStyle.js.map +1 -1
  51. package/dist/typescript/src/index.d.ts +1 -0
  52. package/dist/typescript/src/index.d.ts.map +1 -1
  53. package/dist/typescript/src/lib/Animations/Pulse/Pulse.d.ts +1 -1
  54. package/dist/typescript/src/lib/Animations/Pulse/Pulse.d.ts.map +1 -1
  55. package/dist/typescript/src/lib/Animations/Pulse/types.d.ts +2 -1
  56. package/dist/typescript/src/lib/Animations/Pulse/types.d.ts.map +1 -1
  57. package/dist/typescript/src/lib/Components/BottomSheet/BottomSheet.d.ts +1 -1
  58. package/dist/typescript/src/lib/Components/BottomSheet/BottomSheet.d.ts.map +1 -1
  59. package/dist/typescript/src/lib/Components/BottomSheet/CustomHandle.d.ts +5 -2
  60. package/dist/typescript/src/lib/Components/BottomSheet/CustomHandle.d.ts.map +1 -1
  61. package/dist/typescript/src/lib/Components/BottomSheet/types.d.ts +16 -3
  62. package/dist/typescript/src/lib/Components/BottomSheet/types.d.ts.map +1 -1
  63. package/dist/typescript/src/lib/Components/Card/Card.d.ts.map +1 -1
  64. package/dist/typescript/src/lib/Components/DescriptionItem/DescriptionItem.d.ts +42 -0
  65. package/dist/typescript/src/lib/Components/DescriptionItem/DescriptionItem.d.ts.map +1 -0
  66. package/dist/typescript/src/lib/Components/DescriptionItem/index.d.ts +3 -0
  67. package/dist/typescript/src/lib/Components/DescriptionItem/index.d.ts.map +1 -0
  68. package/dist/typescript/src/lib/Components/DescriptionItem/types.d.ts +39 -0
  69. package/dist/typescript/src/lib/Components/DescriptionItem/types.d.ts.map +1 -0
  70. package/dist/typescript/src/lib/Components/ListItem/ListItem.d.ts +3 -3
  71. package/dist/typescript/src/lib/Components/ListItem/ListItem.d.ts.map +1 -1
  72. package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts.map +1 -1
  73. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts +3 -2
  74. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts.map +1 -1
  75. package/dist/typescript/src/lib/Components/OptionList/types.d.ts +42 -5
  76. package/dist/typescript/src/lib/Components/OptionList/types.d.ts.map +1 -1
  77. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts +9 -1
  78. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts.map +1 -1
  79. package/dist/typescript/src/lib/Components/Skeleton/Skeleton.d.ts +1 -1
  80. package/dist/typescript/src/lib/Components/Skeleton/Skeleton.d.ts.map +1 -1
  81. package/dist/typescript/src/lib/Components/TabBar/TabBar.d.ts.map +1 -1
  82. package/dist/typescript/src/lib/Components/index.d.ts +1 -0
  83. package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
  84. package/dist/typescript/src/lib/types/index.d.ts +3 -3
  85. package/dist/typescript/src/lib/types/index.d.ts.map +1 -1
  86. package/dist/typescript/src/styles/lx/resolveStyle.d.ts +3 -3
  87. package/dist/typescript/src/styles/lx/resolveStyle.d.ts.map +1 -1
  88. package/package.json +1 -1
  89. package/src/index.ts +1 -0
  90. package/src/lib/Animations/Pulse/Pulse.tsx +38 -30
  91. package/src/lib/Animations/Pulse/types.ts +2 -1
  92. package/src/lib/Components/BottomSheet/BottomSheet.stories.tsx +174 -1
  93. package/src/lib/Components/BottomSheet/BottomSheet.test.tsx +59 -0
  94. package/src/lib/Components/BottomSheet/BottomSheet.tsx +19 -7
  95. package/src/lib/Components/BottomSheet/BottomSheetHeader.tsx +1 -1
  96. package/src/lib/Components/BottomSheet/CustomHandle.tsx +26 -5
  97. package/src/lib/Components/BottomSheet/types.ts +24 -3
  98. package/src/lib/Components/Card/Card.tsx +3 -3
  99. package/src/lib/Components/DescriptionItem/DescriptionItem.mdx +139 -0
  100. package/src/lib/Components/DescriptionItem/DescriptionItem.stories.tsx +234 -0
  101. package/src/lib/Components/DescriptionItem/DescriptionItem.test.tsx +112 -0
  102. package/src/lib/Components/DescriptionItem/DescriptionItem.tsx +224 -0
  103. package/src/lib/Components/DescriptionItem/index.ts +2 -0
  104. package/src/lib/Components/DescriptionItem/types.ts +44 -0
  105. package/src/lib/Components/ListItem/ListItem.tsx +3 -3
  106. package/src/lib/Components/MediaImage/MediaImage.tsx +5 -1
  107. package/src/lib/Components/NavBar/CoinCapsule.tsx +1 -0
  108. package/src/lib/Components/OptionList/OptionList.mdx +19 -0
  109. package/src/lib/Components/OptionList/OptionList.stories.tsx +254 -0
  110. package/src/lib/Components/OptionList/OptionList.test.tsx +143 -0
  111. package/src/lib/Components/OptionList/OptionList.tsx +49 -3
  112. package/src/lib/Components/OptionList/types.ts +46 -5
  113. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.test.ts +124 -2
  114. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.ts +53 -10
  115. package/src/lib/Components/PageIndicator/PageIndicator.test.tsx +2 -1
  116. package/src/lib/Components/Skeleton/Skeleton.tsx +9 -5
  117. package/src/lib/Components/TabBar/TabBar.tsx +3 -2
  118. package/src/lib/Components/index.ts +1 -0
  119. package/src/lib/types/index.ts +3 -3
  120. package/src/styles/lx/resolveStyle.ts +4 -3
@@ -36,6 +36,7 @@ It handles **selection state**, **automatic grouping** (via a `group` field on i
36
36
  - **OptionListItemContentRow**: Horizontal row for placing elements side-by-side (e.g. title + tag)
37
37
  - **OptionListItemText**: Styled title text
38
38
  - **OptionListItemDescription**: Styled description text
39
+ - **OptionListSearch**: Search input that filters items by label (default) or via a custom `filter` function
39
40
 
40
41
  ## Properties
41
42
 
@@ -63,6 +64,24 @@ Combining grouping with rich item content:
63
64
 
64
65
  <Canvas of={OptionListStories.GroupedWithContentRow} />
65
66
 
67
+ ### Search
68
+
69
+ Drop `OptionListSearch` inside the list to enable case-insensitive filtering on item labels. Pair with `OptionListEmptyState` to handle no-match scenarios. Pass a custom `filter` to match against meta (or anything else), or `filter={null}` to disable filtering. Use `filteredItems` + `onSearchValueChange` for async/server-side search.
70
+
71
+ <Canvas of={OptionListStories.WithSearch} />
72
+
73
+ Search works within grouped items too. Empty groups are hidden automatically:
74
+
75
+ <Canvas of={OptionListStories.WithSearchAndGroups} />
76
+
77
+ Pass a custom `filter` to match against fields beyond the label. Here, the query also matches each item's ticker via `meta`:
78
+
79
+ <Canvas of={OptionListStories.WithCustomSearchFilter} />
80
+
81
+ Drive the search input from the outside with `searchValue` + `onSearchValueChange`. This is useful for syncing search state with other UI or kicking off async fetches:
82
+
83
+ <Canvas of={OptionListStories.WithControlledSearch} />
84
+
66
85
  ### Trigger showcase
67
86
 
68
87
  OptionList can be opened from any trigger. `MediaButton` supports multiple appearances (`gray`, `transparent`, `no-background`), optional icons (`flat` / `rounded`), and disabled state:
@@ -22,6 +22,7 @@ import {
22
22
  OptionListItemContent,
23
23
  OptionListItemDescription,
24
24
  OptionListItemContentRow,
25
+ OptionListSearch,
25
26
  OptionListTrigger,
26
27
  OptionListItemText,
27
28
  } from './OptionList';
@@ -38,6 +39,7 @@ const meta = {
38
39
  OptionListItemText,
39
40
  OptionListItemDescription,
40
41
  OptionListItemContentRow,
42
+ OptionListSearch,
41
43
  OptionListTrigger,
42
44
  },
43
45
  decorators: [
@@ -477,6 +479,258 @@ export const GroupedWithContentRow: Story = {
477
479
  },
478
480
  };
479
481
 
482
+ export const WithSearch: Story = {
483
+ render: () => {
484
+ const [value, setValue] = useState<string | null>(null);
485
+ const bottomSheetRef = useBottomSheetRef();
486
+ const selected = CURRENCIES.find((c) => c.value === value);
487
+
488
+ return (
489
+ <>
490
+ <OptionListTrigger
491
+ label='Currency'
492
+ onPress={() => bottomSheetRef.current?.present()}
493
+ >
494
+ {selected && <Text lx={{ color: 'base' }}>{selected.label}</Text>}
495
+ </OptionListTrigger>
496
+ <BottomSheet
497
+ ref={bottomSheetRef}
498
+ enableDynamicSizing
499
+ snapPoints={null}
500
+ onClose={() => bottomSheetRef.current?.dismiss()}
501
+ >
502
+ <BottomSheetView>
503
+ <BottomSheetHeader title='Select currency' />
504
+ <OptionList
505
+ items={CURRENCIES}
506
+ value={value}
507
+ onValueChange={(v) => {
508
+ setValue(v);
509
+ bottomSheetRef.current?.dismiss();
510
+ }}
511
+ >
512
+ <OptionListSearch placeholder='Search currencies' />
513
+ <OptionListContent
514
+ renderItem={(item) => {
515
+ const ticker = (item.meta as { ticker: string }).ticker;
516
+ return (
517
+ <OptionListItem value={item.value}>
518
+ <OptionListItemLeading>
519
+ <CryptoIcon
520
+ ledgerId={(item.meta?.ledgerId as string) ?? ''}
521
+ ticker={ticker}
522
+ size={32}
523
+ />
524
+ </OptionListItemLeading>
525
+ <OptionListItemContent>
526
+ <OptionListItemText>{item.label}</OptionListItemText>
527
+ <OptionListItemDescription>
528
+ {ticker}
529
+ </OptionListItemDescription>
530
+ </OptionListItemContent>
531
+ </OptionListItem>
532
+ );
533
+ }}
534
+ />
535
+ <OptionListEmptyState
536
+ title='No currencies found'
537
+ description='Try a different search term'
538
+ />
539
+ </OptionList>
540
+ </BottomSheetView>
541
+ </BottomSheet>
542
+ </>
543
+ );
544
+ },
545
+ };
546
+
547
+ export const WithSearchAndGroups: Story = {
548
+ render: () => {
549
+ const [value, setValue] = useState<string | null>(null);
550
+ const bottomSheetRef = useBottomSheetRef();
551
+ const selected = GROUPED_NETWORKS.find((n) => n.value === value);
552
+
553
+ return (
554
+ <>
555
+ <OptionListTrigger
556
+ label='Network'
557
+ onPress={() => bottomSheetRef.current?.present()}
558
+ >
559
+ {selected && <Text lx={{ color: 'base' }}>{selected.label}</Text>}
560
+ </OptionListTrigger>
561
+ <BottomSheet
562
+ ref={bottomSheetRef}
563
+ enableDynamicSizing
564
+ snapPoints={null}
565
+ onClose={() => bottomSheetRef.current?.dismiss()}
566
+ >
567
+ <BottomSheetScrollView>
568
+ <BottomSheetHeader title='Select network' />
569
+ <OptionList
570
+ items={GROUPED_NETWORKS}
571
+ value={value}
572
+ onValueChange={(v) => {
573
+ setValue(v);
574
+ bottomSheetRef.current?.dismiss();
575
+ }}
576
+ >
577
+ <OptionListSearch placeholder='Search networks' />
578
+ <OptionListContent
579
+ renderItem={(item) => (
580
+ <OptionListItem value={item.value}>
581
+ <OptionListItemContent>
582
+ <OptionListItemText>{item.label}</OptionListItemText>
583
+ </OptionListItemContent>
584
+ </OptionListItem>
585
+ )}
586
+ />
587
+ <OptionListEmptyState title='No networks found' />
588
+ </OptionList>
589
+ </BottomSheetScrollView>
590
+ </BottomSheet>
591
+ </>
592
+ );
593
+ },
594
+ };
595
+
596
+ export const WithCustomSearchFilter: Story = {
597
+ render: () => {
598
+ const [value, setValue] = useState<string | null>(null);
599
+ const bottomSheetRef = useBottomSheetRef();
600
+ const selected = CURRENCIES.find((c) => c.value === value);
601
+
602
+ return (
603
+ <>
604
+ <OptionListTrigger
605
+ label='Currency'
606
+ onPress={() => bottomSheetRef.current?.present()}
607
+ >
608
+ {selected && <Text lx={{ color: 'base' }}>{selected.label}</Text>}
609
+ </OptionListTrigger>
610
+ <BottomSheet
611
+ ref={bottomSheetRef}
612
+ enableDynamicSizing
613
+ snapPoints={null}
614
+ onClose={() => bottomSheetRef.current?.dismiss()}
615
+ >
616
+ <BottomSheetView>
617
+ <BottomSheetHeader title='Select currency' />
618
+ <OptionList
619
+ items={CURRENCIES}
620
+ value={value}
621
+ onValueChange={(v) => {
622
+ setValue(v);
623
+ bottomSheetRef.current?.dismiss();
624
+ }}
625
+ filter={(item, query) => {
626
+ const q = query.toLowerCase();
627
+ const ticker = (item.meta as { ticker: string }).ticker;
628
+ return (
629
+ item.label.toLowerCase().includes(q) ||
630
+ ticker.toLowerCase().includes(q)
631
+ );
632
+ }}
633
+ >
634
+ <OptionListSearch placeholder='Search by name or ticker' />
635
+ <OptionListContent
636
+ renderItem={(item) => {
637
+ const ticker = (item.meta as { ticker: string }).ticker;
638
+ return (
639
+ <OptionListItem value={item.value}>
640
+ <OptionListItemLeading>
641
+ <CryptoIcon
642
+ ledgerId={(item.meta?.ledgerId as string) ?? ''}
643
+ ticker={ticker}
644
+ size={32}
645
+ />
646
+ </OptionListItemLeading>
647
+ <OptionListItemContent>
648
+ <OptionListItemText>{item.label}</OptionListItemText>
649
+ <OptionListItemDescription>
650
+ {ticker}
651
+ </OptionListItemDescription>
652
+ </OptionListItemContent>
653
+ </OptionListItem>
654
+ );
655
+ }}
656
+ />
657
+ <OptionListEmptyState title='No currencies found' />
658
+ </OptionList>
659
+ </BottomSheetView>
660
+ </BottomSheet>
661
+ </>
662
+ );
663
+ },
664
+ };
665
+
666
+ export const WithControlledSearch: Story = {
667
+ render: () => {
668
+ const [value, setValue] = useState<string | null>(null);
669
+ const [searchValue, setSearchValue] = useState('');
670
+ const bottomSheetRef = useBottomSheetRef();
671
+ const selected = CURRENCIES.find((c) => c.value === value);
672
+
673
+ return (
674
+ <>
675
+ <OptionListTrigger
676
+ label='Currency'
677
+ onPress={() => bottomSheetRef.current?.present()}
678
+ >
679
+ {selected && <Text lx={{ color: 'base' }}>{selected.label}</Text>}
680
+ </OptionListTrigger>
681
+ <BottomSheet
682
+ ref={bottomSheetRef}
683
+ enableDynamicSizing
684
+ snapPoints={null}
685
+ onClose={() => bottomSheetRef.current?.dismiss()}
686
+ >
687
+ <BottomSheetView>
688
+ <BottomSheetHeader title='Select currency' />
689
+ <Box lx={{ padding: 's8' }}>
690
+ <Text lx={{ color: 'muted' }}>Search: "{searchValue}"</Text>
691
+ </Box>
692
+ <OptionList
693
+ items={CURRENCIES}
694
+ value={value}
695
+ onValueChange={(v) => {
696
+ setValue(v);
697
+ bottomSheetRef.current?.dismiss();
698
+ }}
699
+ searchValue={searchValue}
700
+ onSearchValueChange={setSearchValue}
701
+ >
702
+ <OptionListSearch placeholder='Search currencies' />
703
+ <OptionListContent
704
+ renderItem={(item) => {
705
+ const ticker = (item.meta as { ticker: string }).ticker;
706
+ return (
707
+ <OptionListItem value={item.value}>
708
+ <OptionListItemLeading>
709
+ <CryptoIcon
710
+ ledgerId={(item.meta?.ledgerId as string) ?? ''}
711
+ ticker={ticker}
712
+ size={32}
713
+ />
714
+ </OptionListItemLeading>
715
+ <OptionListItemContent>
716
+ <OptionListItemText>{item.label}</OptionListItemText>
717
+ <OptionListItemDescription>
718
+ {ticker}
719
+ </OptionListItemDescription>
720
+ </OptionListItemContent>
721
+ </OptionListItem>
722
+ );
723
+ }}
724
+ />
725
+ <OptionListEmptyState title='No currencies found' />
726
+ </OptionList>
727
+ </BottomSheetView>
728
+ </BottomSheet>
729
+ </>
730
+ );
731
+ },
732
+ };
733
+
480
734
  export const EmptyState: Story = {
481
735
  render: () => {
482
736
  const bottomSheetRef = useBottomSheetRef();
@@ -13,6 +13,7 @@ import {
13
13
  OptionListItemText,
14
14
  OptionListItemDescription,
15
15
  OptionListEmptyState,
16
+ OptionListSearch,
16
17
  OptionListTrigger,
17
18
  } from './OptionList';
18
19
  import type { OptionListItemData } from './types';
@@ -321,6 +322,148 @@ describe('OptionList', () => {
321
322
  });
322
323
  });
323
324
 
325
+ describe('OptionListSearch', () => {
326
+ const renderSearchable = ({
327
+ items = ITEMS,
328
+ filter,
329
+ filteredItems,
330
+ searchValue,
331
+ onSearchValueChange,
332
+ }: {
333
+ items?: OptionListItemData[];
334
+ filter?: null | ((item: OptionListItemData, query: string) => boolean);
335
+ filteredItems?: OptionListItemData[];
336
+ searchValue?: string;
337
+ onSearchValueChange?: (v: string) => void;
338
+ } = {}) =>
339
+ render(
340
+ <TestWrapper>
341
+ <OptionList
342
+ items={items}
343
+ filter={filter}
344
+ filteredItems={filteredItems}
345
+ searchValue={searchValue}
346
+ onSearchValueChange={onSearchValueChange}
347
+ >
348
+ <OptionListSearch placeholder='Search' />
349
+ <OptionListContent
350
+ renderItem={(item) => (
351
+ <OptionListItem value={item.value}>
352
+ <OptionListItemContent>
353
+ <OptionListItemText>{item.label}</OptionListItemText>
354
+ </OptionListItemContent>
355
+ </OptionListItem>
356
+ )}
357
+ />
358
+ <OptionListEmptyState title='No results' />
359
+ </OptionList>
360
+ </TestWrapper>,
361
+ );
362
+
363
+ it('renders the search input', () => {
364
+ const { getByPlaceholderText } = renderSearchable();
365
+
366
+ expect(getByPlaceholderText('Search')).toBeTruthy();
367
+ });
368
+
369
+ it('filters items with the default label filter', () => {
370
+ const { getByPlaceholderText, getByText, queryByText } =
371
+ renderSearchable();
372
+
373
+ fireEvent.changeText(getByPlaceholderText('Search'), 'alp');
374
+
375
+ expect(getByText('Alpha')).toBeTruthy();
376
+ expect(queryByText('Beta')).toBeNull();
377
+ expect(queryByText('Gamma')).toBeNull();
378
+ });
379
+
380
+ it('default filter is case-insensitive', () => {
381
+ const { getByPlaceholderText, getByText, queryByText } =
382
+ renderSearchable();
383
+
384
+ fireEvent.changeText(getByPlaceholderText('Search'), 'BETA');
385
+
386
+ expect(getByText('Beta')).toBeTruthy();
387
+ expect(queryByText('Alpha')).toBeNull();
388
+ });
389
+
390
+ it('uses a custom filter when provided', () => {
391
+ const filter = (item: OptionListItemData, query: string): boolean =>
392
+ item.value.startsWith(query);
393
+
394
+ const { getByPlaceholderText, getByText, queryByText } = renderSearchable(
395
+ { filter },
396
+ );
397
+
398
+ fireEvent.changeText(getByPlaceholderText('Search'), 'b');
399
+
400
+ expect(getByText('Beta')).toBeTruthy();
401
+ expect(queryByText('Alpha')).toBeNull();
402
+ expect(queryByText('Gamma')).toBeNull();
403
+ });
404
+
405
+ it('disables filtering when filter is null', () => {
406
+ const { getByPlaceholderText, getByText } = renderSearchable({
407
+ filter: null,
408
+ });
409
+
410
+ fireEvent.changeText(getByPlaceholderText('Search'), 'alpha');
411
+
412
+ expect(getByText('Alpha')).toBeTruthy();
413
+ expect(getByText('Beta')).toBeTruthy();
414
+ expect(getByText('Gamma')).toBeTruthy();
415
+ });
416
+
417
+ it('filters within groups and hides empty groups', () => {
418
+ const { getByPlaceholderText, getByText, queryByText } = renderSearchable(
419
+ { items: GROUPED_ITEMS },
420
+ );
421
+
422
+ fireEvent.changeText(getByPlaceholderText('Search'), 'apple');
423
+
424
+ expect(getByText('Fruits')).toBeTruthy();
425
+ expect(getByText('Apple')).toBeTruthy();
426
+ expect(queryByText('Banana')).toBeNull();
427
+ expect(queryByText('Vegetables')).toBeNull();
428
+ expect(queryByText('Carrot')).toBeNull();
429
+ });
430
+
431
+ it('renders empty state when no item matches', () => {
432
+ const { getByPlaceholderText, getByText, queryByText } =
433
+ renderSearchable();
434
+
435
+ fireEvent.changeText(getByPlaceholderText('Search'), 'zzz');
436
+
437
+ expect(getByText('No results')).toBeTruthy();
438
+ expect(queryByText('Alpha')).toBeNull();
439
+ });
440
+
441
+ it('fires onSearchValueChange with the typed query', () => {
442
+ const onSearchValueChange = jest.fn();
443
+ const { getByPlaceholderText } = renderSearchable({
444
+ onSearchValueChange,
445
+ });
446
+
447
+ fireEvent.changeText(getByPlaceholderText('Search'), 'be');
448
+
449
+ expect(onSearchValueChange).toHaveBeenCalledWith('be');
450
+ });
451
+
452
+ it('uses filteredItems instead of the internal filter when provided', () => {
453
+ const { getByPlaceholderText, getByText, queryByText } = renderSearchable(
454
+ {
455
+ filteredItems: [{ value: 'b', label: 'Beta' }],
456
+ },
457
+ );
458
+
459
+ fireEvent.changeText(getByPlaceholderText('Search'), 'alpha');
460
+
461
+ expect(getByText('Beta')).toBeTruthy();
462
+ expect(queryByText('Alpha')).toBeNull();
463
+ expect(queryByText('Gamma')).toBeNull();
464
+ });
465
+ });
466
+
324
467
  describe('OptionListTrigger', () => {
325
468
  it('calls onPress when pressed', () => {
326
469
  const onPress = jest.fn();
@@ -9,6 +9,7 @@ import { useStyleSheet } from '../../../styles';
9
9
  import { Check, ChevronDown } from '../../Symbols';
10
10
  import { useControllableState } from '../../utils/useControllableState';
11
11
  import { Divider } from '../Divider';
12
+ import { SearchInput } from '../SearchInput';
12
13
  import { Box, Pressable, Text } from '../Utility';
13
14
  import type {
14
15
  MetaShape,
@@ -23,6 +24,7 @@ import type {
23
24
  OptionListItemContentProps,
24
25
  OptionListItemContentRowProps,
25
26
  OptionListEmptyStateProps,
27
+ OptionListSearchProps,
26
28
  OptionListTriggerProps,
27
29
  OptionListLabelProps,
28
30
  } from './types';
@@ -37,6 +39,11 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
37
39
  defaultValue,
38
40
  onValueChange,
39
41
  disabled: disabledProp,
42
+ filter,
43
+ filteredItems,
44
+ searchValue,
45
+ defaultSearchValue,
46
+ onSearchValueChange,
40
47
  children,
41
48
  }: OptionListProps<TMeta>) => {
42
49
  const disabled = useDisabledContext({
@@ -52,7 +59,20 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
52
59
  },
53
60
  );
54
61
 
55
- const { isGrouped, groups, flatItems } = useOptionListItems<TMeta>({ items });
62
+ const {
63
+ isGrouped,
64
+ groups,
65
+ flatItems,
66
+ resolvedSearchValue,
67
+ handleSearchValueChange,
68
+ } = useOptionListItems<TMeta>({
69
+ items,
70
+ filter,
71
+ filteredItems,
72
+ searchValue,
73
+ defaultSearchValue,
74
+ onSearchValueChange,
75
+ });
56
76
 
57
77
  return (
58
78
  <DisabledProvider value={{ disabled }}>
@@ -63,6 +83,8 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
63
83
  isGrouped,
64
84
  groups,
65
85
  flatItems,
86
+ resolvedSearchValue,
87
+ handleSearchValueChange,
66
88
  }}
67
89
  >
68
90
  {children}
@@ -381,6 +403,25 @@ const OptionListLabel = ({ children }: OptionListLabelProps) => (
381
403
  </Text>
382
404
  );
383
405
 
406
+ export const OptionListSearch = ({ ref, ...props }: OptionListSearchProps) => {
407
+ const { resolvedSearchValue, handleSearchValueChange } = useOptionListContext(
408
+ {
409
+ consumerName: 'OptionListSearch',
410
+ contextRequired: true,
411
+ },
412
+ );
413
+
414
+ return (
415
+ <SearchInput
416
+ ref={ref}
417
+ value={resolvedSearchValue}
418
+ onChangeText={handleSearchValueChange}
419
+ lx={{ paddingBottom: 's8' }}
420
+ {...props}
421
+ />
422
+ );
423
+ };
424
+
384
425
  export const OptionListEmptyState = ({
385
426
  title,
386
427
  description,
@@ -389,10 +430,13 @@ export const OptionListEmptyState = ({
389
430
  ref,
390
431
  ...props
391
432
  }: OptionListEmptyStateProps) => {
392
- const { flatItems } = useOptionListContext({
433
+ const { isGrouped, groups, flatItems } = useOptionListContext({
393
434
  consumerName: 'OptionListEmptyState',
394
435
  contextRequired: true,
395
436
  });
437
+ const visibleCount = isGrouped
438
+ ? groups.reduce((acc, g) => acc + g.items.length, 0)
439
+ : flatItems.length;
396
440
 
397
441
  const styles = useStyleSheet(
398
442
  (t) => ({
@@ -414,7 +458,9 @@ export const OptionListEmptyState = ({
414
458
  [],
415
459
  );
416
460
 
417
- if (flatItems.length > 0) return null;
461
+ if (visibleCount > 0) {
462
+ return null;
463
+ }
418
464
 
419
465
  return (
420
466
  <Box
@@ -4,6 +4,7 @@ import type {
4
4
  StyledTextProps,
5
5
  StyledViewProps,
6
6
  } from '../../../styles';
7
+ import type { SearchInputProps } from '../SearchInput';
7
8
 
8
9
  export type MetaShape = Record<string, unknown>;
9
10
 
@@ -37,6 +38,8 @@ export type OptionListContextValue = {
37
38
  isGrouped: boolean;
38
39
  groups: OptionListItemGroup[];
39
40
  flatItems: OptionListItemData[];
41
+ resolvedSearchValue: string;
42
+ handleSearchValueChange: (value: string) => void;
40
43
  };
41
44
 
42
45
  /** Internal type -- consumers never construct this directly. */
@@ -46,16 +49,49 @@ export type OptionListItemGroup<TMeta extends MetaShape = MetaShape> = {
46
49
  };
47
50
 
48
51
  export type OptionListProps<TMeta extends MetaShape = MetaShape> = {
49
- /** Flat array of items. Use the `group` field on each item for automatic grouping. */
52
+ /**
53
+ * Flat array of items.
54
+ * Use the `group` field on each item for automatic grouping.
55
+ */
50
56
  items: OptionListItemData<TMeta>[];
51
- /** The controlled selected value. */
57
+ /**
58
+ * The controlled selected value.
59
+ */
52
60
  value?: string | null;
53
- /** The default selected value (uncontrolled). */
61
+ /**
62
+ * The default selected value (uncontrolled)
63
+ */
54
64
  defaultValue?: string | null;
55
- /** Called when the selected value changes. */
65
+ /**
66
+ * Called when the selected value changes.
67
+ */
56
68
  onValueChange?: (value: string | null) => void;
57
- /** When true, prevents interaction with the entire list. */
69
+ /**
70
+ * When true, prevents interaction with the entire list.
71
+ */
58
72
  disabled?: boolean;
73
+ /**
74
+ * Custom item/query matcher.
75
+ * Defaults to case-insensitive label match; `null` disables filtering.
76
+ */
77
+ filter?: null | ((item: OptionListItemData<TMeta>, query: string) => boolean);
78
+ /**
79
+ * Pre-filtered items for async/remote search.
80
+ * Bypasses internal filtering.
81
+ */
82
+ filteredItems?: OptionListItemData<TMeta>[];
83
+ /**
84
+ * Controlled search input value.
85
+ */
86
+ searchValue?: string;
87
+ /**
88
+ * Initial uncontrolled search value.
89
+ */
90
+ defaultSearchValue?: string;
91
+ /**
92
+ * Fired when search input changes.
93
+ */
94
+ onSearchValueChange?: (value: string) => void;
59
95
  children: ReactNode;
60
96
  };
61
97
 
@@ -103,6 +139,11 @@ export type OptionListEmptyStateProps = {
103
139
  description?: string;
104
140
  } & Omit<StyledViewProps, 'children'>;
105
141
 
142
+ export type OptionListSearchProps = Omit<
143
+ SearchInputProps,
144
+ 'value' | 'onChangeText' | 'defaultValue'
145
+ >;
146
+
106
147
  export type OptionListTriggerProps = {
107
148
  /** Floating label shown above the selected value. */
108
149
  label?: string;