@ledgerhq/lumen-ui-rnative 0.1.34 → 0.1.35

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 (89) 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 +17 -7
  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/ListItem/ListItem.js.map +1 -1
  17. package/dist/module/lib/Components/MediaImage/MediaImage.js +5 -1
  18. package/dist/module/lib/Components/MediaImage/MediaImage.js.map +1 -1
  19. package/dist/module/lib/Components/OptionList/OptionList.js +45 -4
  20. package/dist/module/lib/Components/OptionList/OptionList.js.map +1 -1
  21. package/dist/module/lib/Components/OptionList/OptionList.mdx +19 -0
  22. package/dist/module/lib/Components/OptionList/OptionList.stories.js +254 -1
  23. package/dist/module/lib/Components/OptionList/OptionList.stories.js.map +1 -1
  24. package/dist/module/lib/Components/OptionList/OptionList.test.js +136 -1
  25. package/dist/module/lib/Components/OptionList/OptionList.test.js.map +1 -1
  26. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js +39 -13
  27. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js.map +1 -1
  28. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.test.js +117 -2
  29. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.test.js.map +1 -1
  30. package/dist/module/lib/Components/PageIndicator/PageIndicator.test.js.map +1 -1
  31. package/dist/module/lib/Components/Skeleton/Skeleton.js +10 -3
  32. package/dist/module/lib/Components/Skeleton/Skeleton.js.map +1 -1
  33. package/dist/module/lib/Components/TabBar/TabBar.js +7 -6
  34. package/dist/module/lib/Components/TabBar/TabBar.js.map +1 -1
  35. package/dist/module/styles/lx/resolveStyle.js.map +1 -1
  36. package/dist/typescript/src/index.d.ts +1 -0
  37. package/dist/typescript/src/index.d.ts.map +1 -1
  38. package/dist/typescript/src/lib/Animations/Pulse/Pulse.d.ts +1 -1
  39. package/dist/typescript/src/lib/Animations/Pulse/Pulse.d.ts.map +1 -1
  40. package/dist/typescript/src/lib/Animations/Pulse/types.d.ts +2 -1
  41. package/dist/typescript/src/lib/Animations/Pulse/types.d.ts.map +1 -1
  42. package/dist/typescript/src/lib/Components/BottomSheet/BottomSheet.d.ts +1 -1
  43. package/dist/typescript/src/lib/Components/BottomSheet/BottomSheet.d.ts.map +1 -1
  44. package/dist/typescript/src/lib/Components/BottomSheet/CustomHandle.d.ts +5 -2
  45. package/dist/typescript/src/lib/Components/BottomSheet/CustomHandle.d.ts.map +1 -1
  46. package/dist/typescript/src/lib/Components/BottomSheet/types.d.ts +16 -3
  47. package/dist/typescript/src/lib/Components/BottomSheet/types.d.ts.map +1 -1
  48. package/dist/typescript/src/lib/Components/Card/Card.d.ts.map +1 -1
  49. package/dist/typescript/src/lib/Components/ListItem/ListItem.d.ts +3 -3
  50. package/dist/typescript/src/lib/Components/ListItem/ListItem.d.ts.map +1 -1
  51. package/dist/typescript/src/lib/Components/MediaImage/MediaImage.d.ts.map +1 -1
  52. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts +3 -2
  53. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts.map +1 -1
  54. package/dist/typescript/src/lib/Components/OptionList/types.d.ts +42 -5
  55. package/dist/typescript/src/lib/Components/OptionList/types.d.ts.map +1 -1
  56. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts +9 -1
  57. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts.map +1 -1
  58. package/dist/typescript/src/lib/Components/Skeleton/Skeleton.d.ts +1 -1
  59. package/dist/typescript/src/lib/Components/Skeleton/Skeleton.d.ts.map +1 -1
  60. package/dist/typescript/src/lib/Components/TabBar/TabBar.d.ts.map +1 -1
  61. package/dist/typescript/src/lib/types/index.d.ts +3 -3
  62. package/dist/typescript/src/lib/types/index.d.ts.map +1 -1
  63. package/dist/typescript/src/styles/lx/resolveStyle.d.ts +3 -3
  64. package/dist/typescript/src/styles/lx/resolveStyle.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/index.ts +1 -0
  67. package/src/lib/Animations/Pulse/Pulse.tsx +34 -29
  68. package/src/lib/Animations/Pulse/types.ts +2 -1
  69. package/src/lib/Components/BottomSheet/BottomSheet.stories.tsx +174 -1
  70. package/src/lib/Components/BottomSheet/BottomSheet.test.tsx +59 -0
  71. package/src/lib/Components/BottomSheet/BottomSheet.tsx +19 -7
  72. package/src/lib/Components/BottomSheet/BottomSheetHeader.tsx +1 -1
  73. package/src/lib/Components/BottomSheet/CustomHandle.tsx +26 -5
  74. package/src/lib/Components/BottomSheet/types.ts +24 -3
  75. package/src/lib/Components/Card/Card.tsx +3 -3
  76. package/src/lib/Components/ListItem/ListItem.tsx +3 -3
  77. package/src/lib/Components/MediaImage/MediaImage.tsx +5 -1
  78. package/src/lib/Components/OptionList/OptionList.mdx +19 -0
  79. package/src/lib/Components/OptionList/OptionList.stories.tsx +254 -0
  80. package/src/lib/Components/OptionList/OptionList.test.tsx +143 -0
  81. package/src/lib/Components/OptionList/OptionList.tsx +49 -3
  82. package/src/lib/Components/OptionList/types.ts +46 -5
  83. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.test.ts +124 -2
  84. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.ts +53 -10
  85. package/src/lib/Components/PageIndicator/PageIndicator.test.tsx +2 -1
  86. package/src/lib/Components/Skeleton/Skeleton.tsx +9 -5
  87. package/src/lib/Components/TabBar/TabBar.tsx +3 -2
  88. package/src/lib/types/index.ts +3 -3
  89. package/src/styles/lx/resolveStyle.ts +4 -3
@@ -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;
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from '@jest/globals';
2
- import { renderHook } from '@testing-library/react-native';
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
+ import { renderHook, act } from '@testing-library/react-native';
3
3
  import type { OptionListItemData } from '../types';
4
4
  import { useOptionListItems } from './useOptionListItems';
5
5
 
@@ -70,4 +70,126 @@ describe('useOptionListItems', () => {
70
70
  expect(result.current.groups[1].label).toBe('Fruits');
71
71
  });
72
72
  });
73
+
74
+ describe('filtering', () => {
75
+ it('applies the default case-insensitive label filter', () => {
76
+ const { result } = renderHook(() =>
77
+ useOptionListItems({ items: [btc, eth] }),
78
+ );
79
+
80
+ act(() => {
81
+ result.current.handleSearchValueChange('BITCOIN');
82
+ });
83
+
84
+ expect(result.current.flatItems).toEqual([btc]);
85
+ });
86
+
87
+ it('filters within groups and removes empty groups', () => {
88
+ const { result } = renderHook(() =>
89
+ useOptionListItems({ items: [apple, banana, carrot, spinach] }),
90
+ );
91
+
92
+ act(() => {
93
+ result.current.handleSearchValueChange('apple');
94
+ });
95
+
96
+ expect(result.current.groups).toEqual([
97
+ { label: 'Fruits', items: [apple] },
98
+ ]);
99
+ });
100
+
101
+ it('uses a custom filter when provided', () => {
102
+ const customFilter = (item: OptionListItemData, query: string): boolean =>
103
+ item.value.startsWith(query);
104
+
105
+ const { result } = renderHook(() =>
106
+ useOptionListItems({ items: [btc, eth], filter: customFilter }),
107
+ );
108
+
109
+ act(() => {
110
+ result.current.handleSearchValueChange('et');
111
+ });
112
+
113
+ expect(result.current.flatItems).toEqual([eth]);
114
+ });
115
+
116
+ it('disables filtering when filter is null', () => {
117
+ const { result } = renderHook(() =>
118
+ useOptionListItems({ items: [btc, eth], filter: null }),
119
+ );
120
+
121
+ act(() => {
122
+ result.current.handleSearchValueChange('bitcoin');
123
+ });
124
+
125
+ expect(result.current.flatItems).toEqual([btc, eth]);
126
+ });
127
+
128
+ it('returns all items when the query is whitespace-only', () => {
129
+ const { result } = renderHook(() =>
130
+ useOptionListItems({ items: [btc, eth] }),
131
+ );
132
+
133
+ act(() => {
134
+ result.current.handleSearchValueChange(' ');
135
+ });
136
+
137
+ expect(result.current.flatItems).toEqual([btc, eth]);
138
+ });
139
+ });
140
+
141
+ describe('external filteredItems', () => {
142
+ it('uses filteredItems instead of internal filtering', () => {
143
+ const { result } = renderHook(() =>
144
+ useOptionListItems({
145
+ items: [btc, eth],
146
+ filteredItems: [eth],
147
+ }),
148
+ );
149
+
150
+ expect(result.current.flatItems).toEqual([eth]);
151
+ });
152
+
153
+ it('groups filteredItems when items are grouped', () => {
154
+ const { result } = renderHook(() =>
155
+ useOptionListItems({
156
+ items: [apple, banana, carrot],
157
+ filteredItems: [apple, carrot],
158
+ }),
159
+ );
160
+
161
+ expect(result.current.isGrouped).toBe(true);
162
+ expect(result.current.groups).toEqual([
163
+ { label: 'Fruits', items: [apple] },
164
+ { label: 'Vegetables', items: [carrot] },
165
+ ]);
166
+ });
167
+ });
168
+
169
+ describe('searchValue', () => {
170
+ it('calls onSearchValueChange with the new value', () => {
171
+ const onSearchValueChange = jest.fn();
172
+ const { result } = renderHook(() =>
173
+ useOptionListItems({ items: [btc, eth], onSearchValueChange }),
174
+ );
175
+
176
+ act(() => {
177
+ result.current.handleSearchValueChange('test');
178
+ });
179
+
180
+ expect(onSearchValueChange).toHaveBeenCalledWith('test');
181
+ expect(result.current.resolvedSearchValue).toBe('test');
182
+ });
183
+
184
+ it('uses defaultSearchValue as the initial uncontrolled value', () => {
185
+ const { result } = renderHook(() =>
186
+ useOptionListItems({
187
+ items: [btc, eth],
188
+ defaultSearchValue: 'default',
189
+ }),
190
+ );
191
+
192
+ expect(result.current.resolvedSearchValue).toBe('default');
193
+ });
194
+ });
73
195
  });
@@ -1,4 +1,5 @@
1
1
  import { useMemo } from 'react';
2
+ import { useControllableState } from '../../../utils/useControllableState';
2
3
  import type {
3
4
  MetaShape,
4
5
  OptionListItemData,
@@ -24,26 +25,68 @@ const groupByField = <TMeta extends MetaShape = MetaShape>(
24
25
  const hasGroups = (items: OptionListItemData[]): boolean =>
25
26
  items.some((item) => item.group != null);
26
27
 
27
- const toResult = <TMeta extends MetaShape = MetaShape>(
28
- items: OptionListItemData<TMeta>[],
29
- ): UseOptionListItemsResult<TMeta> => {
30
- const isGrouped = hasGroups(items);
31
- return isGrouped
32
- ? { isGrouped: true, groups: groupByField(items), flatItems: [] }
33
- : { isGrouped: false, groups: [], flatItems: items };
34
- };
28
+ export const defaultLabelFilter = (
29
+ item: OptionListItemData,
30
+ query: string,
31
+ ): boolean => item.label.toLowerCase().includes(query.toLowerCase());
35
32
 
36
33
  type UseOptionListItemsParams<TMeta extends MetaShape = MetaShape> = {
37
34
  items: OptionListItemData<TMeta>[];
35
+ filter?: null | ((item: OptionListItemData<TMeta>, query: string) => boolean);
36
+ filteredItems?: OptionListItemData<TMeta>[];
37
+ searchValue?: string;
38
+ defaultSearchValue?: string;
39
+ onSearchValueChange?: (value: string) => void;
38
40
  };
39
41
 
40
42
  type UseOptionListItemsResult<TMeta extends MetaShape = MetaShape> = {
41
43
  isGrouped: boolean;
42
44
  groups: OptionListItemGroup<TMeta>[];
43
45
  flatItems: OptionListItemData<TMeta>[];
46
+ resolvedSearchValue: string;
47
+ handleSearchValueChange: (val: string) => void;
44
48
  };
45
49
 
46
50
  export const useOptionListItems = <TMeta extends MetaShape = MetaShape>({
47
51
  items,
48
- }: UseOptionListItemsParams<TMeta>): UseOptionListItemsResult<TMeta> =>
49
- useMemo(() => toResult(items), [items]);
52
+ filter,
53
+ filteredItems,
54
+ searchValue: searchValueProp,
55
+ defaultSearchValue,
56
+ onSearchValueChange,
57
+ }: UseOptionListItemsParams<TMeta>): UseOptionListItemsResult<TMeta> => {
58
+ const [searchValue, handleSearchValueChange] = useControllableState<string>({
59
+ prop: searchValueProp,
60
+ defaultProp: defaultSearchValue ?? '',
61
+ onChange: onSearchValueChange,
62
+ });
63
+
64
+ const isGrouped = useMemo(() => hasGroups(items), [items]);
65
+
66
+ const visibleItems = useMemo(() => {
67
+ if (filteredItems) {
68
+ return filteredItems;
69
+ }
70
+ const query = searchValue.trim();
71
+ const activeFilter = filter === undefined ? defaultLabelFilter : filter;
72
+ if (!activeFilter || !query) {
73
+ return items;
74
+ }
75
+ return items.filter((item) => activeFilter(item, query));
76
+ }, [items, filteredItems, filter, searchValue]);
77
+
78
+ const groups = useMemo(() => {
79
+ if (!isGrouped) {
80
+ return [];
81
+ }
82
+ return groupByField(visibleItems);
83
+ }, [isGrouped, visibleItems]);
84
+
85
+ return {
86
+ isGrouped,
87
+ groups,
88
+ flatItems: isGrouped ? [] : visibleItems,
89
+ resolvedSearchValue: searchValue,
90
+ handleSearchValueChange,
91
+ };
92
+ };
@@ -2,6 +2,7 @@ import { describe, expect, it } from '@jest/globals';
2
2
  import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
3
3
  import { render, screen } from '@testing-library/react-native';
4
4
 
5
+ import type { ComponentRef } from 'react';
5
6
  import { createRef } from 'react';
6
7
  import { ThemeProvider } from '../ThemeProvider/ThemeProvider';
7
8
  import { PageIndicator } from './PageIndicator';
@@ -162,7 +163,7 @@ describe('PageIndicator Component', () => {
162
163
 
163
164
  describe('Props', () => {
164
165
  it('should accept ref prop', () => {
165
- const ref = createRef<React.ElementRef<typeof PageIndicator>>();
166
+ const ref = createRef<ComponentRef<typeof PageIndicator>>();
166
167
  renderWithProvider(
167
168
  <PageIndicator
168
169
  ref={ref}
@@ -110,22 +110,26 @@ const componentsMap = {
110
110
  * // Tile variant
111
111
  * <Skeleton component='tile' />
112
112
  */
113
- const Skeleton = ({ lx, component, ...props }: SkeletonProps) => {
113
+ const Skeleton = ({ lx, component, style, ...props }: SkeletonProps) => {
114
114
  /**
115
115
  * Check if the component is a valid pre-built variant and return the corresponding component.
116
116
  */
117
117
  if (component && componentsMap[component]) {
118
118
  const Component = componentsMap[component];
119
119
  return (
120
- <Pulse animate>
121
- <Component {...props} lx={lx} />
120
+ <Pulse animate style={style} lx={lx}>
121
+ <Component {...props} />
122
122
  </Pulse>
123
123
  );
124
124
  }
125
125
 
126
126
  return (
127
- <Pulse animate>
128
- <BaseSkeleton testID='skeleton' lx={lx} {...props} />
127
+ <Pulse animate style={style} lx={lx}>
128
+ <BaseSkeleton
129
+ testID='skeleton'
130
+ lx={{ width: 'full', height: 'full' }}
131
+ {...props}
132
+ />
129
133
  </Pulse>
130
134
  );
131
135
  };
@@ -2,7 +2,7 @@ import { BlurView } from '@sbaiahmed1/react-native-blur';
2
2
  import type { ReactNode } from 'react';
3
3
  import { Children, isValidElement, useEffect, useMemo, useRef } from 'react';
4
4
  import type { LayoutChangeEvent } from 'react-native';
5
- import { Platform, StyleSheet, Text, View } from 'react-native';
5
+ import { StyleSheet, Text, View } from 'react-native';
6
6
  import Animated, {
7
7
  cancelAnimation,
8
8
  useAnimatedStyle,
@@ -15,6 +15,7 @@ import { useStyleSheet, useTheme } from '../../../styles';
15
15
  import { useTimingConfig } from '../../Animations/useTimingConfig';
16
16
  import { triggerHapticFeedback } from '../../Haptics';
17
17
  import { Placeholder } from '../../Symbols';
18
+ import { RuntimeConstants } from '../../utils';
18
19
  import { Box, Pressable } from '../Utility';
19
20
  import { TabBarContextProvider, useTabBarContext } from './TabBarContext';
20
21
  import type { TabBarItemProps, TabBarProps } from './types';
@@ -268,7 +269,7 @@ export function TabBar({
268
269
  {...props}
269
270
  >
270
271
  {children}
271
- {Platform.OS === 'android' ? (
272
+ {RuntimeConstants.isAndroid ? (
272
273
  <View style={styles.fallbackBackground} />
273
274
  ) : (
274
275
  <BlurView
@@ -1,12 +1,12 @@
1
- import type { ElementType, ComponentPropsWithRef, ElementRef } from 'react';
1
+ import type { ElementType, ComponentPropsWithRef, ComponentRef } from 'react';
2
2
  import type { Pressable, View } from 'react-native';
3
3
 
4
4
  type ComponentPropsWithAsChild<T extends ElementType<any>> =
5
5
  ComponentPropsWithRef<T> & { asChild?: boolean };
6
6
 
7
7
  type SlottableViewProps = ComponentPropsWithAsChild<typeof View>;
8
- type ViewRef = ElementRef<typeof View>;
9
- type PressableRef = ElementRef<typeof Pressable>;
8
+ type ViewRef = ComponentRef<typeof View>;
9
+ type PressableRef = ComponentRef<typeof Pressable>;
10
10
 
11
11
  type SlottablePressableProps = ComponentPropsWithAsChild<typeof Pressable> & {
12
12
  /**
@@ -1,5 +1,6 @@
1
1
  import { getObjectPath } from '@ledgerhq/lumen-utils-shared';
2
- import { type ViewStyle, type TextStyle, StyleSheet } from 'react-native';
2
+ import { StyleSheet } from 'react-native';
3
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
3
4
  import { useTheme } from '../hooks/useTheme';
4
5
  import type {
5
6
  LumenStyleSheetTheme,
@@ -46,7 +47,7 @@ const resolveStyle = <T extends ViewStyle | TextStyle>(
46
47
  */
47
48
  export const useResolveViewStyle = (
48
49
  lx: LumenViewStyle,
49
- bareStyle?: ViewStyle,
50
+ bareStyle?: StyleProp<ViewStyle>,
50
51
  ): ViewStyle => {
51
52
  const { theme } = useTheme();
52
53
  const resolvedStyle = resolveStyle<ViewStyle>(theme, lx, VIEW_PROP_CONFIG);
@@ -58,7 +59,7 @@ export const useResolveViewStyle = (
58
59
  */
59
60
  export const useResolveTextStyle = (
60
61
  lx: LumenTextStyle,
61
- bareStyle?: TextStyle,
62
+ bareStyle?: StyleProp<TextStyle>,
62
63
  ): TextStyle => {
63
64
  const { theme } = useTheme();
64
65
  const resolvedStyle = resolveStyle<TextStyle>(theme, lx, TEXT_PROP_CONFIG);