@ledgerhq/lumen-ui-rnative 0.1.21 → 0.1.23

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 (99) hide show
  1. package/dist/module/lib/Components/ListItem/ListItem.js +57 -27
  2. package/dist/module/lib/Components/ListItem/ListItem.js.map +1 -1
  3. package/dist/module/lib/Components/ListItem/ListItem.mdx +15 -7
  4. package/dist/module/lib/Components/ListItem/ListItem.stories.js +497 -283
  5. package/dist/module/lib/Components/ListItem/ListItem.stories.js.map +1 -1
  6. package/dist/module/lib/Components/ListItem/ListItem.test.js +153 -0
  7. package/dist/module/lib/Components/ListItem/ListItem.test.js.map +1 -0
  8. package/dist/module/lib/Components/{TriggerButton/TriggerButton.js → MediaButton/MediaButton.js} +13 -10
  9. package/dist/module/lib/Components/MediaButton/MediaButton.js.map +1 -0
  10. package/{src/lib/Components/TriggerButton/TriggerButton.mdx → dist/module/lib/Components/MediaButton/MediaButton.mdx} +10 -10
  11. package/dist/module/lib/Components/{TriggerButton/TriggerButton.stories.js → MediaButton/MediaButton.stories.js} +18 -18
  12. package/dist/module/lib/Components/MediaButton/MediaButton.stories.js.map +1 -0
  13. package/dist/module/lib/Components/{TriggerButton/TriggerButton.test.js → MediaButton/MediaButton.test.js} +14 -14
  14. package/dist/module/lib/Components/MediaButton/MediaButton.test.js.map +1 -0
  15. package/dist/module/lib/Components/MediaButton/index.js +5 -0
  16. package/dist/module/lib/Components/MediaButton/index.js.map +1 -0
  17. package/dist/module/lib/Components/MediaButton/types.js.map +1 -0
  18. package/dist/module/lib/Components/NavBar/NavBar.js +0 -2
  19. package/dist/module/lib/Components/NavBar/NavBar.js.map +1 -1
  20. package/dist/module/lib/Components/OptionList/OptionList.figma.js +28 -0
  21. package/dist/module/lib/Components/OptionList/OptionList.figma.js.map +1 -0
  22. package/dist/module/lib/Components/OptionList/OptionList.js +452 -0
  23. package/dist/module/lib/Components/OptionList/OptionList.js.map +1 -0
  24. package/dist/module/lib/Components/OptionList/OptionList.mdx +304 -0
  25. package/dist/module/lib/Components/OptionList/OptionList.stories.js +735 -0
  26. package/dist/module/lib/Components/OptionList/OptionList.stories.js.map +1 -0
  27. package/dist/module/lib/Components/OptionList/OptionList.test.js +443 -0
  28. package/dist/module/lib/Components/OptionList/OptionList.test.js.map +1 -0
  29. package/dist/module/lib/Components/OptionList/index.js +5 -0
  30. package/dist/module/lib/Components/OptionList/index.js.map +1 -0
  31. package/dist/module/lib/Components/OptionList/types.js +4 -0
  32. package/dist/module/lib/Components/OptionList/types.js.map +1 -0
  33. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js +36 -0
  34. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js.map +1 -0
  35. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.test.js +84 -0
  36. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.test.js.map +1 -0
  37. package/dist/module/lib/Components/index.js +2 -1
  38. package/dist/module/lib/Components/index.js.map +1 -1
  39. package/dist/typescript/src/lib/Components/ListItem/ListItem.d.ts +8 -8
  40. package/dist/typescript/src/lib/Components/ListItem/ListItem.d.ts.map +1 -1
  41. package/dist/typescript/src/lib/Components/ListItem/types.d.ts +11 -7
  42. package/dist/typescript/src/lib/Components/ListItem/types.d.ts.map +1 -1
  43. package/dist/typescript/src/lib/Components/MediaButton/MediaButton.d.ts +23 -0
  44. package/dist/typescript/src/lib/Components/MediaButton/MediaButton.d.ts.map +1 -0
  45. package/dist/typescript/src/lib/Components/MediaButton/index.d.ts +3 -0
  46. package/dist/typescript/src/lib/Components/MediaButton/index.d.ts.map +1 -0
  47. package/dist/typescript/src/lib/Components/{TriggerButton → MediaButton}/types.d.ts +10 -5
  48. package/dist/typescript/src/lib/Components/MediaButton/types.d.ts.map +1 -0
  49. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts +12 -0
  50. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts.map +1 -0
  51. package/dist/typescript/src/lib/Components/OptionList/OptionList.figma.d.ts +2 -0
  52. package/dist/typescript/src/lib/Components/OptionList/OptionList.figma.d.ts.map +1 -0
  53. package/dist/typescript/src/lib/Components/OptionList/index.d.ts +3 -0
  54. package/dist/typescript/src/lib/Components/OptionList/index.d.ts.map +1 -0
  55. package/dist/typescript/src/lib/Components/OptionList/types.d.ts +97 -0
  56. package/dist/typescript/src/lib/Components/OptionList/types.d.ts.map +1 -0
  57. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts +12 -0
  58. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts.map +1 -0
  59. package/dist/typescript/src/lib/Components/index.d.ts +2 -1
  60. package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
  61. package/dist/typescript/src/styles/types/theme.types.d.ts +7 -6
  62. package/dist/typescript/src/styles/types/theme.types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/lib/Components/ListItem/ListItem.mdx +15 -7
  65. package/src/lib/Components/ListItem/ListItem.stories.tsx +354 -220
  66. package/src/lib/Components/ListItem/ListItem.test.tsx +152 -0
  67. package/src/lib/Components/ListItem/ListItem.tsx +63 -28
  68. package/src/lib/Components/ListItem/types.ts +11 -8
  69. package/{dist/module/lib/Components/TriggerButton/TriggerButton.mdx → src/lib/Components/MediaButton/MediaButton.mdx} +10 -10
  70. package/src/lib/Components/{TriggerButton/TriggerButton.stories.tsx → MediaButton/MediaButton.stories.tsx} +28 -28
  71. package/src/lib/Components/{TriggerButton/TriggerButton.test.tsx → MediaButton/MediaButton.test.tsx} +22 -22
  72. package/src/lib/Components/{TriggerButton/TriggerButton.tsx → MediaButton/MediaButton.tsx} +27 -21
  73. package/src/lib/Components/MediaButton/index.ts +2 -0
  74. package/src/lib/Components/{TriggerButton → MediaButton}/types.ts +10 -5
  75. package/src/lib/Components/NavBar/NavBar.tsx +0 -3
  76. package/src/lib/Components/OptionList/OptionList.figma.tsx +37 -0
  77. package/src/lib/Components/OptionList/OptionList.mdx +304 -0
  78. package/src/lib/Components/OptionList/OptionList.stories.tsx +755 -0
  79. package/src/lib/Components/OptionList/OptionList.test.tsx +412 -0
  80. package/src/lib/Components/OptionList/OptionList.tsx +532 -0
  81. package/src/lib/Components/OptionList/index.ts +2 -0
  82. package/src/lib/Components/OptionList/types.ts +115 -0
  83. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.test.ts +73 -0
  84. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.ts +49 -0
  85. package/src/lib/Components/index.ts +2 -1
  86. package/src/styles/types/theme.types.ts +8 -6
  87. package/dist/module/lib/Components/TriggerButton/TriggerButton.js.map +0 -1
  88. package/dist/module/lib/Components/TriggerButton/TriggerButton.stories.js.map +0 -1
  89. package/dist/module/lib/Components/TriggerButton/TriggerButton.test.js.map +0 -1
  90. package/dist/module/lib/Components/TriggerButton/index.js +0 -5
  91. package/dist/module/lib/Components/TriggerButton/index.js.map +0 -1
  92. package/dist/module/lib/Components/TriggerButton/types.js.map +0 -1
  93. package/dist/typescript/src/lib/Components/TriggerButton/TriggerButton.d.ts +0 -23
  94. package/dist/typescript/src/lib/Components/TriggerButton/TriggerButton.d.ts.map +0 -1
  95. package/dist/typescript/src/lib/Components/TriggerButton/index.d.ts +0 -3
  96. package/dist/typescript/src/lib/Components/TriggerButton/index.d.ts.map +0 -1
  97. package/dist/typescript/src/lib/Components/TriggerButton/types.d.ts.map +0 -1
  98. package/src/lib/Components/TriggerButton/index.ts +0 -2
  99. /package/dist/module/lib/Components/{TriggerButton → MediaButton}/types.js +0 -0
@@ -0,0 +1,532 @@
1
+ import {
2
+ createSafeContext,
3
+ useDisabledContext,
4
+ DisabledProvider,
5
+ } from '@ledgerhq/lumen-utils-shared';
6
+ import { Fragment, type ReactNode } from 'react';
7
+ import { StyleSheet, View } from 'react-native';
8
+ import { useStyleSheet } from '../../../styles';
9
+ import { Check, ChevronDown } from '../../Symbols';
10
+ import { useControllableState } from '../../utils/useControllableState';
11
+ import { Divider } from '../Divider';
12
+ import { Box, Pressable, Text } from '../Utility';
13
+ import type {
14
+ MetaShape,
15
+ OptionListContextValue,
16
+ OptionListItemData,
17
+ OptionListProps,
18
+ OptionListContentProps,
19
+ OptionListItemProps,
20
+ OptionListItemLeadingProps,
21
+ OptionListItemTextProps,
22
+ OptionListItemDescriptionProps,
23
+ OptionListItemContentProps,
24
+ OptionListItemContentRowProps,
25
+ OptionListEmptyStateProps,
26
+ OptionListTriggerProps,
27
+ OptionListLabelProps,
28
+ } from './types';
29
+ import { useOptionListItems } from './useOptionList/useOptionListItems';
30
+
31
+ const [OptionListProvider, useOptionListContext] =
32
+ createSafeContext<OptionListContextValue>('OptionList');
33
+
34
+ export const OptionList = <TMeta extends MetaShape = MetaShape>({
35
+ items,
36
+ value,
37
+ defaultValue,
38
+ onValueChange,
39
+ disabled: disabledProp,
40
+ children,
41
+ }: OptionListProps<TMeta>) => {
42
+ const disabled = useDisabledContext({
43
+ consumerName: 'OptionList',
44
+ mergeWith: { disabled: disabledProp },
45
+ });
46
+
47
+ const [selectedValue, setSelectedValue] = useControllableState<string | null>(
48
+ {
49
+ prop: value,
50
+ defaultProp: defaultValue ?? null,
51
+ onChange: onValueChange,
52
+ },
53
+ );
54
+
55
+ const { isGrouped, groups, flatItems } = useOptionListItems<TMeta>({ items });
56
+
57
+ return (
58
+ <DisabledProvider value={{ disabled }}>
59
+ <OptionListProvider
60
+ value={{
61
+ selectedValue,
62
+ onValueChange: setSelectedValue,
63
+ isGrouped,
64
+ groups,
65
+ flatItems,
66
+ }}
67
+ >
68
+ {children}
69
+ </OptionListProvider>
70
+ </DisabledProvider>
71
+ );
72
+ };
73
+
74
+ export const OptionListContent = <TMeta extends MetaShape = MetaShape>({
75
+ renderItem,
76
+ lx,
77
+ style,
78
+ ref,
79
+ ...props
80
+ }: OptionListContentProps<TMeta>) => {
81
+ const { selectedValue, isGrouped, groups, flatItems } = useOptionListContext({
82
+ consumerName: 'OptionListContent',
83
+ contextRequired: true,
84
+ });
85
+
86
+ const renderItemWithState = (item: OptionListItemData) =>
87
+ renderItem(item as OptionListItemData<TMeta>, selectedValue === item.value);
88
+
89
+ if (isGrouped) {
90
+ return (
91
+ <Box lx={lx} style={style} ref={ref} {...props}>
92
+ {groups.map((group, groupIndex) => (
93
+ <Fragment key={group.label}>
94
+ {groupIndex > 0 && (
95
+ <Divider lx={{ marginVertical: 's4', marginHorizontal: 's8' }} />
96
+ )}
97
+ {group.label && <OptionListLabel>{group.label}</OptionListLabel>}
98
+ {group.items.map((item) => (
99
+ <Fragment key={item.value}>{renderItemWithState(item)}</Fragment>
100
+ ))}
101
+ </Fragment>
102
+ ))}
103
+ </Box>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <Box lx={lx} style={style} ref={ref} {...props}>
109
+ {flatItems.map((item) => (
110
+ <Fragment key={item.value}>{renderItemWithState(item)}</Fragment>
111
+ ))}
112
+ </Box>
113
+ );
114
+ };
115
+
116
+ const useItemStyles = ({
117
+ disabled,
118
+ pressed,
119
+ }: {
120
+ disabled: boolean;
121
+ pressed: boolean;
122
+ }) => {
123
+ return useStyleSheet(
124
+ (t) => ({
125
+ container: {
126
+ flexDirection: 'row',
127
+ alignItems: 'center',
128
+ minHeight: t.sizes.s40,
129
+ padding: t.spacings.s8,
130
+ gap: t.spacings.s12,
131
+ borderRadius: t.borderRadius.md,
132
+ backgroundColor: pressed
133
+ ? t.colors.bg.baseTransparentPressed
134
+ : t.colors.bg.baseTransparent,
135
+ opacity: disabled ? 0.5 : 1,
136
+ },
137
+ }),
138
+ [disabled, pressed],
139
+ );
140
+ };
141
+
142
+ export const OptionListItem = ({
143
+ value,
144
+ disabled: disabledProp = false,
145
+ children,
146
+ lx,
147
+ style,
148
+ ref,
149
+ ...props
150
+ }: OptionListItemProps) => {
151
+ const { selectedValue, onValueChange } = useOptionListContext({
152
+ consumerName: 'OptionListItem',
153
+ contextRequired: true,
154
+ });
155
+ const disabled = useDisabledContext({
156
+ consumerName: 'OptionListItem',
157
+ mergeWith: { disabled: disabledProp },
158
+ });
159
+ const selected = selectedValue === value;
160
+
161
+ return (
162
+ <DisabledProvider value={{ disabled }}>
163
+ <Pressable
164
+ ref={ref}
165
+ lx={lx}
166
+ style={style}
167
+ onPress={() => onValueChange(value)}
168
+ disabled={disabled}
169
+ accessibilityRole='radio'
170
+ accessibilityState={{ disabled, selected }}
171
+ {...props}
172
+ >
173
+ {({ pressed }) => (
174
+ <OptionListItemInner pressed={pressed} selected={selected}>
175
+ {children}
176
+ </OptionListItemInner>
177
+ )}
178
+ </Pressable>
179
+ </DisabledProvider>
180
+ );
181
+ };
182
+
183
+ const OptionListItemInner = ({
184
+ pressed,
185
+ selected,
186
+ children,
187
+ }: {
188
+ pressed: boolean;
189
+ selected: boolean;
190
+ children: ReactNode;
191
+ }) => {
192
+ const disabled = useDisabledContext({
193
+ consumerName: 'OptionListItemInner',
194
+ contextRequired: false,
195
+ });
196
+ const styles = useItemStyles({ disabled, pressed });
197
+
198
+ return (
199
+ <View style={styles.container}>
200
+ {children}
201
+ {selected && <Check size={24} color='active' />}
202
+ </View>
203
+ );
204
+ };
205
+
206
+ export const OptionListItemText = ({
207
+ children,
208
+ lx,
209
+ style,
210
+ ref,
211
+ ...props
212
+ }: OptionListItemTextProps) => {
213
+ const disabled = useDisabledContext({
214
+ consumerName: 'OptionListItemText',
215
+ contextRequired: false,
216
+ });
217
+
218
+ const styles = useStyleSheet(
219
+ (t) => ({
220
+ text: StyleSheet.flatten([
221
+ t.typographies.body2SemiBold,
222
+ {
223
+ color: disabled ? t.colors.text.disabled : t.colors.text.base,
224
+ },
225
+ ]),
226
+ }),
227
+ [disabled],
228
+ );
229
+
230
+ return (
231
+ <Text
232
+ ref={ref}
233
+ lx={lx}
234
+ style={StyleSheet.flatten([styles.text, style])}
235
+ numberOfLines={1}
236
+ {...props}
237
+ >
238
+ {children}
239
+ </Text>
240
+ );
241
+ };
242
+
243
+ export const OptionListItemDescription = ({
244
+ children,
245
+ lx,
246
+ style,
247
+ ref,
248
+ ...props
249
+ }: OptionListItemDescriptionProps) => {
250
+ const disabled = useDisabledContext({
251
+ consumerName: 'OptionListItemDescription',
252
+ contextRequired: false,
253
+ });
254
+
255
+ const styles = useStyleSheet(
256
+ (t) => ({
257
+ description: StyleSheet.flatten([
258
+ t.typographies.body3,
259
+ {
260
+ color: disabled ? t.colors.text.disabled : t.colors.text.muted,
261
+ },
262
+ ]),
263
+ }),
264
+ [disabled],
265
+ );
266
+
267
+ return (
268
+ <Text
269
+ ref={ref}
270
+ lx={lx}
271
+ style={StyleSheet.flatten([styles.description, style])}
272
+ numberOfLines={1}
273
+ {...props}
274
+ >
275
+ {children}
276
+ </Text>
277
+ );
278
+ };
279
+
280
+ export const OptionListItemContent = ({
281
+ children,
282
+ lx,
283
+ style,
284
+ ref,
285
+ ...props
286
+ }: OptionListItemContentProps) => {
287
+ const styles = useStyleSheet(
288
+ (t) => ({
289
+ content: {
290
+ flex: 1,
291
+ minWidth: 0,
292
+ gap: t.spacings.s4,
293
+ },
294
+ }),
295
+ [],
296
+ );
297
+
298
+ return (
299
+ <Box
300
+ ref={ref}
301
+ lx={lx}
302
+ style={StyleSheet.flatten([styles.content, style])}
303
+ {...props}
304
+ >
305
+ {children}
306
+ </Box>
307
+ );
308
+ };
309
+
310
+ export const OptionListItemContentRow = ({
311
+ children,
312
+ lx,
313
+ style,
314
+ ref,
315
+ ...props
316
+ }: OptionListItemContentRowProps) => {
317
+ const styles = useStyleSheet(
318
+ (t) => ({
319
+ row: {
320
+ flexDirection: 'row',
321
+ alignItems: 'center',
322
+ minWidth: 0,
323
+ gap: t.spacings.s8,
324
+ },
325
+ }),
326
+ [],
327
+ );
328
+
329
+ return (
330
+ <Box
331
+ ref={ref}
332
+ lx={lx}
333
+ style={StyleSheet.flatten([styles.row, style])}
334
+ {...props}
335
+ >
336
+ {children}
337
+ </Box>
338
+ );
339
+ };
340
+
341
+ export const OptionListItemLeading = ({
342
+ children,
343
+ lx,
344
+ style,
345
+ ref,
346
+ ...props
347
+ }: OptionListItemLeadingProps) => {
348
+ const styles = useStyleSheet(
349
+ () => ({
350
+ leading: {
351
+ flexShrink: 0,
352
+ alignItems: 'center',
353
+ justifyContent: 'center',
354
+ },
355
+ }),
356
+ [],
357
+ );
358
+
359
+ return (
360
+ <Box
361
+ ref={ref}
362
+ lx={lx}
363
+ style={StyleSheet.flatten([styles.leading, style])}
364
+ {...props}
365
+ >
366
+ {children}
367
+ </Box>
368
+ );
369
+ };
370
+
371
+ const OptionListLabel = ({ children }: OptionListLabelProps) => (
372
+ <Text
373
+ lx={{
374
+ color: 'muted',
375
+ paddingHorizontal: 's8',
376
+ paddingTop: 's8',
377
+ marginBottom: 's4',
378
+ }}
379
+ >
380
+ {children}
381
+ </Text>
382
+ );
383
+
384
+ export const OptionListEmptyState = ({
385
+ title,
386
+ description,
387
+ lx,
388
+ style,
389
+ ref,
390
+ ...props
391
+ }: OptionListEmptyStateProps) => {
392
+ const { flatItems } = useOptionListContext({
393
+ consumerName: 'OptionListEmptyState',
394
+ contextRequired: true,
395
+ });
396
+
397
+ const styles = useStyleSheet(
398
+ (t) => ({
399
+ container: {
400
+ width: '100%',
401
+ alignItems: 'center',
402
+ gap: t.spacings.s8,
403
+ paddingVertical: t.spacings.s24,
404
+ },
405
+ title: StyleSheet.flatten([
406
+ t.typographies.heading4SemiBold,
407
+ { color: t.colors.text.base },
408
+ ]),
409
+ description: StyleSheet.flatten([
410
+ t.typographies.body2,
411
+ { color: t.colors.text.muted },
412
+ ]),
413
+ }),
414
+ [],
415
+ );
416
+
417
+ if (flatItems.length > 0) return null;
418
+
419
+ return (
420
+ <Box
421
+ ref={ref}
422
+ lx={lx}
423
+ style={StyleSheet.flatten([styles.container, style])}
424
+ {...props}
425
+ >
426
+ <Text style={styles.title}>{title}</Text>
427
+ {description && <Text style={styles.description}>{description}</Text>}
428
+ </Box>
429
+ );
430
+ };
431
+
432
+ const useTriggerStyles = ({
433
+ disabled,
434
+ hasValue,
435
+ hasLabel,
436
+ }: {
437
+ disabled: boolean;
438
+ hasValue: boolean;
439
+ hasLabel: boolean;
440
+ }) =>
441
+ useStyleSheet(
442
+ (t) => ({
443
+ trigger: StyleSheet.flatten([
444
+ {
445
+ position: 'relative',
446
+ width: t.sizes.full,
447
+ height: t.sizes.s48,
448
+ backgroundColor: t.colors.bg.muted,
449
+ borderRadius: t.borderRadius.sm,
450
+ paddingHorizontal: t.spacings.s16,
451
+ flexDirection: 'row',
452
+ alignItems: 'center',
453
+ justifyContent: 'space-between',
454
+ },
455
+ disabled && { opacity: 0.5 },
456
+ ]),
457
+ label: StyleSheet.flatten([
458
+ t.typographies.body2,
459
+ {
460
+ position: 'absolute',
461
+ left: t.spacings.s16,
462
+ color: t.colors.text.muted,
463
+ width: '100%',
464
+ },
465
+ hasValue
466
+ ? { top: t.spacings.s6, ...t.typographies.body4 }
467
+ : { top: t.spacings.s14, ...t.typographies.body2 },
468
+ disabled && { color: t.colors.text.disabled },
469
+ ]),
470
+ contentWrapper: StyleSheet.flatten([
471
+ { flex: 1 },
472
+ hasLabel &&
473
+ hasValue && {
474
+ paddingTop: t.spacings.s16,
475
+ paddingBottom: t.spacings.s2,
476
+ },
477
+ hasLabel && !hasValue && { paddingVertical: 0 },
478
+ ]),
479
+ chevron: StyleSheet.flatten([
480
+ {
481
+ flexShrink: 0,
482
+ color: t.colors.text.muted,
483
+ marginLeft: t.spacings.s8,
484
+ },
485
+ disabled && { color: t.colors.text.disabled },
486
+ ]),
487
+ }),
488
+ [disabled, hasValue, hasLabel],
489
+ );
490
+
491
+ export const OptionListTrigger = ({
492
+ label,
493
+ onPress,
494
+ disabled: disabledProp,
495
+ children,
496
+ lx,
497
+ style,
498
+ ref,
499
+ ...props
500
+ }: OptionListTriggerProps) => {
501
+ const disabled = useDisabledContext({
502
+ consumerName: 'OptionListTrigger',
503
+ mergeWith: { disabled: disabledProp },
504
+ });
505
+
506
+ const hasValue = children != null && children !== false;
507
+ const styles = useTriggerStyles({
508
+ disabled,
509
+ hasValue,
510
+ hasLabel: !!label,
511
+ });
512
+
513
+ return (
514
+ <Pressable
515
+ ref={ref}
516
+ lx={lx}
517
+ style={[styles.trigger, style]}
518
+ disabled={disabled}
519
+ onPress={onPress}
520
+ accessibilityRole='button'
521
+ {...props}
522
+ >
523
+ {label && (
524
+ <Text style={styles.label} numberOfLines={1}>
525
+ {label}
526
+ </Text>
527
+ )}
528
+ <View style={styles.contentWrapper}>{children}</View>
529
+ <ChevronDown size={20} style={styles.chevron} />
530
+ </Pressable>
531
+ );
532
+ };
@@ -0,0 +1,2 @@
1
+ export * from './OptionList';
2
+ export * from './types';
@@ -0,0 +1,115 @@
1
+ import { ReactNode } from 'react';
2
+ import {
3
+ StyledPressableProps,
4
+ StyledTextProps,
5
+ StyledViewProps,
6
+ } from '../../../styles';
7
+
8
+ export type MetaShape = Record<string, unknown>;
9
+
10
+ export type OptionListItemData<TMeta extends MetaShape = MetaShape> = {
11
+ /** Unique string identifier for this item, used for selection tracking. */
12
+ value: string;
13
+ /** Display text. */
14
+ label: string;
15
+ /** Secondary text displayed below the label inside the item. */
16
+ description?: string;
17
+ /** When true, the item cannot be selected or focused. */
18
+ disabled?: boolean;
19
+ /**
20
+ * Optional group name. Items sharing the same `group` value are grouped together
21
+ * with automatic headers and separators.
22
+ * Groups are ordered by first occurrence in the `items` array.
23
+ */
24
+ group?: string;
25
+ /**
26
+ * Arbitrary data attached to this item.
27
+ * Use it to carry extra fields (icons, tickers, IDs, etc.)
28
+ * that your render function needs.
29
+ */
30
+ meta?: TMeta;
31
+ };
32
+
33
+ /** Internal type -- used by sub-components to read shared state from context. */
34
+ export type OptionListContextValue = {
35
+ selectedValue: string | null;
36
+ onValueChange: (value: string | null) => void;
37
+ isGrouped: boolean;
38
+ groups: OptionListItemGroup[];
39
+ flatItems: OptionListItemData[];
40
+ };
41
+
42
+ /** Internal type -- consumers never construct this directly. */
43
+ export type OptionListItemGroup<TMeta extends MetaShape = MetaShape> = {
44
+ label: string;
45
+ items: OptionListItemData<TMeta>[];
46
+ };
47
+
48
+ export type OptionListProps<TMeta extends MetaShape = MetaShape> = {
49
+ /** Flat array of items. Use the `group` field on each item for automatic grouping. */
50
+ items: OptionListItemData<TMeta>[];
51
+ /** The controlled selected value. */
52
+ value?: string | null;
53
+ /** The default selected value (uncontrolled). */
54
+ defaultValue?: string | null;
55
+ /** Called when the selected value changes. */
56
+ onValueChange?: (value: string | null) => void;
57
+ /** When true, prevents interaction with the entire list. */
58
+ disabled?: boolean;
59
+ children: ReactNode;
60
+ };
61
+
62
+ export type OptionListContentProps<TMeta extends MetaShape = MetaShape> = {
63
+ /** Render function called for each item. Receives the item data and selection/disabled state. */
64
+ renderItem: (item: OptionListItemData<TMeta>, selected: boolean) => ReactNode;
65
+ } & Omit<StyledViewProps, 'children'>;
66
+
67
+ export type OptionListItemProps = {
68
+ /** The value associated with this item, used for selection matching. */
69
+ value: string;
70
+ /** Whether the item is disabled. */
71
+ disabled?: boolean;
72
+ children: ReactNode;
73
+ } & Omit<StyledPressableProps, 'children' | 'disabled'>;
74
+
75
+ export type OptionListItemTextProps = {
76
+ children: ReactNode;
77
+ } & Omit<StyledTextProps, 'children'>;
78
+
79
+ export type OptionListItemDescriptionProps = {
80
+ children: ReactNode;
81
+ } & Omit<StyledTextProps, 'children'>;
82
+
83
+ export type OptionListItemContentRowProps = {
84
+ children: ReactNode;
85
+ } & Omit<StyledViewProps, 'children'>;
86
+
87
+ export type OptionListItemContentProps = {
88
+ children: ReactNode;
89
+ } & Omit<StyledViewProps, 'children'>;
90
+
91
+ export type OptionListItemLeadingProps = {
92
+ children: ReactNode;
93
+ } & Omit<StyledViewProps, 'children'>;
94
+
95
+ export type OptionListLabelProps = {
96
+ children: ReactNode;
97
+ } & Omit<StyledTextProps, 'children'>;
98
+
99
+ export type OptionListEmptyStateProps = {
100
+ /** Heading displayed when the list is empty. */
101
+ title: string;
102
+ /** Optional secondary text displayed below the title. */
103
+ description?: string;
104
+ } & Omit<StyledViewProps, 'children'>;
105
+
106
+ export type OptionListTriggerProps = {
107
+ /** Floating label shown above the selected value. */
108
+ label?: string;
109
+ /** Called when the trigger is pressed. Use to open a BottomSheet or navigate. */
110
+ onPress: () => void;
111
+ /** Content to display as the selected value. */
112
+ children?: ReactNode;
113
+ /** Whether the trigger is disabled. Merges with OptionList disabled context. */
114
+ disabled?: boolean;
115
+ } & Omit<StyledPressableProps, 'children' | 'disabled' | 'onPress'>;
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { renderHook } from '@testing-library/react-native';
3
+ import type { OptionListItemData } from '../types';
4
+ import { useOptionListItems } from './useOptionListItems';
5
+
6
+ const apple: OptionListItemData = {
7
+ value: 'apple',
8
+ label: 'Apple',
9
+ group: 'Fruits',
10
+ };
11
+ const banana: OptionListItemData = {
12
+ value: 'banana',
13
+ label: 'Banana',
14
+ group: 'Fruits',
15
+ };
16
+ const carrot: OptionListItemData = {
17
+ value: 'carrot',
18
+ label: 'Carrot',
19
+ group: 'Vegetables',
20
+ };
21
+ const spinach: OptionListItemData = {
22
+ value: 'spinach',
23
+ label: 'Spinach',
24
+ group: 'Vegetables',
25
+ };
26
+
27
+ const btc: OptionListItemData = { value: 'btc', label: 'Bitcoin' };
28
+ const eth: OptionListItemData = { value: 'eth', label: 'Ethereum' };
29
+
30
+ describe('useOptionListItems', () => {
31
+ describe('flat items (no group field)', () => {
32
+ it('returns isGrouped false and flatItems as-is', () => {
33
+ const { result } = renderHook(() =>
34
+ useOptionListItems({ items: [btc, eth] }),
35
+ );
36
+
37
+ expect(result.current.isGrouped).toBe(false);
38
+ expect(result.current.flatItems).toEqual([btc, eth]);
39
+ expect(result.current.groups).toEqual([]);
40
+ });
41
+
42
+ it('returns empty flatItems for empty array', () => {
43
+ const { result } = renderHook(() => useOptionListItems({ items: [] }));
44
+
45
+ expect(result.current.isGrouped).toBe(false);
46
+ expect(result.current.flatItems).toEqual([]);
47
+ });
48
+ });
49
+
50
+ describe('grouped items (group field present)', () => {
51
+ it('detects groups and builds OptionListItemGroup[]', () => {
52
+ const { result } = renderHook(() =>
53
+ useOptionListItems({ items: [apple, banana, carrot, spinach] }),
54
+ );
55
+
56
+ expect(result.current.isGrouped).toBe(true);
57
+ expect(result.current.flatItems).toEqual([]);
58
+ expect(result.current.groups).toEqual([
59
+ { label: 'Fruits', items: [apple, banana] },
60
+ { label: 'Vegetables', items: [carrot, spinach] },
61
+ ]);
62
+ });
63
+
64
+ it('preserves group order by first occurrence', () => {
65
+ const { result } = renderHook(() =>
66
+ useOptionListItems({ items: [carrot, apple, spinach, banana] }),
67
+ );
68
+
69
+ expect(result.current.groups[0].label).toBe('Vegetables');
70
+ expect(result.current.groups[1].label).toBe('Fruits');
71
+ });
72
+ });
73
+ });