@ledgerhq/lumen-ui-rnative 0.1.47 → 0.1.48

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 (77) hide show
  1. package/dist/module/lib/Components/AmountInput/AmountInput.js +3 -1
  2. package/dist/module/lib/Components/AmountInput/AmountInput.js.map +1 -1
  3. package/dist/module/lib/Components/AmountInput/AmountInput.stories.js +15 -0
  4. package/dist/module/lib/Components/AmountInput/AmountInput.stories.js.map +1 -1
  5. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.js +1 -1
  6. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.js.map +1 -1
  7. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.test.js +20 -1
  8. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.test.js.map +1 -1
  9. package/dist/module/lib/Components/OptionList/OptionList.js +16 -1
  10. package/dist/module/lib/Components/OptionList/OptionList.js.map +1 -1
  11. package/dist/module/lib/Components/OptionList/OptionList.mdx +45 -0
  12. package/dist/module/lib/Components/OptionList/OptionList.stories.js +61 -1
  13. package/dist/module/lib/Components/OptionList/OptionList.stories.js.map +1 -1
  14. package/dist/module/lib/Components/OptionList/OptionList.test.js +43 -1
  15. package/dist/module/lib/Components/OptionList/OptionList.test.js.map +1 -1
  16. package/dist/module/lib/Components/OptionList/useOptionList/useOptionListItems.js.map +1 -1
  17. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.js +7 -1
  18. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.js.map +1 -1
  19. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.mdx +28 -0
  20. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.stories.js +24 -1
  21. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.stories.js.map +1 -1
  22. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.test.js +28 -1
  23. package/dist/module/lib/Components/SegmentedControl/SegmentedControl.test.js.map +1 -1
  24. package/dist/module/lib/Components/TabBar/TabBar.js +7 -4
  25. package/dist/module/lib/Components/TabBar/TabBar.js.map +1 -1
  26. package/dist/module/lib/Components/TabBar/TabBar.mdx +29 -0
  27. package/dist/module/lib/Components/TabBar/TabBar.stories.js +35 -1
  28. package/dist/module/lib/Components/TabBar/TabBar.stories.js.map +1 -1
  29. package/dist/module/lib/Components/TabBar/TabBar.test.js +28 -1
  30. package/dist/module/lib/Components/TabBar/TabBar.test.js.map +1 -1
  31. package/dist/module/lib/Components/TabBar/index.js +2 -1
  32. package/dist/module/lib/Components/TabBar/index.js.map +1 -1
  33. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts +1 -1
  34. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts.map +1 -1
  35. package/dist/typescript/src/lib/Components/AmountInput/types.d.ts +8 -0
  36. package/dist/typescript/src/lib/Components/AmountInput/types.d.ts.map +1 -1
  37. package/dist/typescript/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.d.ts +1 -0
  38. package/dist/typescript/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.d.ts.map +1 -1
  39. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts +18 -4
  40. package/dist/typescript/src/lib/Components/OptionList/OptionList.d.ts.map +1 -1
  41. package/dist/typescript/src/lib/Components/OptionList/types.d.ts +16 -15
  42. package/dist/typescript/src/lib/Components/OptionList/types.d.ts.map +1 -1
  43. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts +9 -9
  44. package/dist/typescript/src/lib/Components/OptionList/useOptionList/useOptionListItems.d.ts.map +1 -1
  45. package/dist/typescript/src/lib/Components/SegmentedControl/SegmentedControl.d.ts +8 -3
  46. package/dist/typescript/src/lib/Components/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  47. package/dist/typescript/src/lib/Components/SegmentedControl/types.d.ts +6 -5
  48. package/dist/typescript/src/lib/Components/SegmentedControl/types.d.ts.map +1 -1
  49. package/dist/typescript/src/lib/Components/TabBar/TabBar.d.ts +7 -2
  50. package/dist/typescript/src/lib/Components/TabBar/TabBar.d.ts.map +1 -1
  51. package/dist/typescript/src/lib/Components/TabBar/index.d.ts +2 -2
  52. package/dist/typescript/src/lib/Components/TabBar/index.d.ts.map +1 -1
  53. package/dist/typescript/src/lib/Components/TabBar/types.d.ts +17 -16
  54. package/dist/typescript/src/lib/Components/TabBar/types.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/lib/Components/AmountInput/AmountInput.stories.tsx +14 -0
  57. package/src/lib/Components/AmountInput/AmountInput.tsx +2 -0
  58. package/src/lib/Components/AmountInput/types.ts +8 -0
  59. package/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.test.ts +20 -0
  60. package/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.ts +2 -0
  61. package/src/lib/Components/OptionList/OptionList.mdx +45 -0
  62. package/src/lib/Components/OptionList/OptionList.stories.tsx +63 -0
  63. package/src/lib/Components/OptionList/OptionList.test.tsx +40 -0
  64. package/src/lib/Components/OptionList/OptionList.tsx +57 -10
  65. package/src/lib/Components/OptionList/types.ts +34 -15
  66. package/src/lib/Components/OptionList/useOptionList/useOptionListItems.ts +28 -13
  67. package/src/lib/Components/SegmentedControl/SegmentedControl.mdx +28 -0
  68. package/src/lib/Components/SegmentedControl/SegmentedControl.stories.tsx +34 -1
  69. package/src/lib/Components/SegmentedControl/SegmentedControl.test.tsx +32 -1
  70. package/src/lib/Components/SegmentedControl/SegmentedControl.tsx +22 -5
  71. package/src/lib/Components/SegmentedControl/types.ts +11 -5
  72. package/src/lib/Components/TabBar/TabBar.mdx +29 -0
  73. package/src/lib/Components/TabBar/TabBar.stories.tsx +34 -1
  74. package/src/lib/Components/TabBar/TabBar.test.tsx +23 -1
  75. package/src/lib/Components/TabBar/TabBar.tsx +17 -9
  76. package/src/lib/Components/TabBar/index.ts +2 -2
  77. package/src/lib/Components/TabBar/types.ts +19 -17
@@ -97,6 +97,8 @@ Install and set up the library with our [Setup Guide →](?path=/docs/getting-st
97
97
 
98
98
  ## Basic usage
99
99
 
100
+ > **Recommended:** prefer the [type-safe variant](#type-safe-variant) below. It ties `value`, `items[].value`, `OptionListItem.value`, and the `renderItem(item).meta` shape to one literal union plus an optional `TMeta`, dropping the `as Meta` casts in `renderItem`.
101
+
100
102
  ```tsx
101
103
  import {
102
104
  OptionList,
@@ -130,6 +132,49 @@ function MyList() {
130
132
  }
131
133
  ```
132
134
 
135
+ ## Type-safe variant
136
+
137
+ `OptionList` and its subcomponents communicate via context, so TypeScript cannot connect `value` / `items[].value` / `OptionListItem.value` on its own. A typo compiles. `createOptionList<T, TMeta>()` ties them together via one literal union (`T`) and an optional meta shape (`TMeta`) that flows into `renderItem(item)`, removing the `as` cast on `item.meta`.
138
+
139
+ ```tsx
140
+ import { createOptionList } from '@ledgerhq/lumen-ui-rnative';
141
+ import type { OptionListItemData } from '@ledgerhq/lumen-ui-rnative';
142
+
143
+ type Network = 'eth' | 'sol' | 'btc';
144
+ type NetworkMeta = { ticker: string; ledgerId: string };
145
+
146
+ const NetworkList = createOptionList<Network, NetworkMeta>();
147
+
148
+ const NETWORKS: OptionListItemData<Network, NetworkMeta>[] = [
149
+ { value: 'eth', label: 'Ethereum', meta: { ticker: 'ETH', ledgerId: 'ethereum' } },
150
+ { value: 'sol', label: 'Solana', meta: { ticker: 'SOL', ledgerId: 'solana' } },
151
+ { value: 'btc', label: 'Bitcoin', meta: { ticker: 'BTC', ledgerId: 'bitcoin' } },
152
+ ];
153
+
154
+ function Example() {
155
+ const [value, setValue] = React.useState<Network | null>('eth');
156
+
157
+ return (
158
+ <NetworkList.OptionList items={NETWORKS} value={value} onValueChange={setValue}>
159
+ <NetworkList.OptionListContent
160
+ renderItem={({ value, label, meta }) =>
161
+ meta ? (
162
+ <NetworkList.OptionListItem value={value}>
163
+ <NetworkList.OptionListItemContent>
164
+ <NetworkList.OptionListItemText>{label}</NetworkList.OptionListItemText>
165
+ <NetworkList.OptionListItemDescription>
166
+ {meta.ticker}
167
+ </NetworkList.OptionListItemDescription>
168
+ </NetworkList.OptionListItemContent>
169
+ </NetworkList.OptionListItem>
170
+ ) : null
171
+ }
172
+ />
173
+ </NetworkList.OptionList>
174
+ );
175
+ }
176
+ ```
177
+
133
178
  ## Inside a BottomSheet
134
179
 
135
180
  Use `OptionListTrigger` as the input-style trigger — it provides a floating label that animates up when a value is selected, and a chevron indicator. Pass children only when a value is selected so the label stays centered when empty.
@@ -14,6 +14,7 @@ import { Spot } from '../Spot';
14
14
  import { Tag } from '../Tag/Tag';
15
15
  import { Box, Text } from '../Utility';
16
16
  import {
17
+ createOptionList,
17
18
  OptionList,
18
19
  OptionListContent,
19
20
  OptionListEmptyState,
@@ -1008,3 +1009,65 @@ export const WithDefaultValue: Story = {
1008
1009
  </Box>
1009
1010
  ),
1010
1011
  };
1012
+
1013
+ type TypedNetwork = 'eth' | 'sol' | 'btc';
1014
+ type TypedNetworkMeta = { ticker: string; ledgerId: string };
1015
+ const NetworkList = createOptionList<TypedNetwork, TypedNetworkMeta>();
1016
+
1017
+ const TYPED_NETWORKS: OptionListItemData<TypedNetwork, TypedNetworkMeta>[] = [
1018
+ {
1019
+ value: 'eth',
1020
+ label: 'Ethereum',
1021
+ meta: { ticker: 'ETH', ledgerId: 'ethereum' },
1022
+ },
1023
+ {
1024
+ value: 'sol',
1025
+ label: 'Solana',
1026
+ meta: { ticker: 'SOL', ledgerId: 'solana' },
1027
+ },
1028
+ {
1029
+ value: 'btc',
1030
+ label: 'Bitcoin',
1031
+ meta: { ticker: 'BTC', ledgerId: 'bitcoin' },
1032
+ },
1033
+ ];
1034
+
1035
+ export const TypesafeFactory: Story = {
1036
+ render: () => {
1037
+ const [value, setValue] = useState<TypedNetwork | null>('eth');
1038
+
1039
+ return (
1040
+ <Box lx={{ width: 's320' }}>
1041
+ <NetworkList.OptionList
1042
+ items={TYPED_NETWORKS}
1043
+ value={value}
1044
+ onValueChange={setValue}
1045
+ >
1046
+ <NetworkList.OptionListContent
1047
+ renderItem={({ value, label, meta }) =>
1048
+ meta ? (
1049
+ <NetworkList.OptionListItem value={value}>
1050
+ <NetworkList.OptionListItemLeading>
1051
+ <CryptoIcon
1052
+ ledgerId={meta.ledgerId}
1053
+ ticker={meta.ticker}
1054
+ size={32}
1055
+ />
1056
+ </NetworkList.OptionListItemLeading>
1057
+ <NetworkList.OptionListItemContent>
1058
+ <NetworkList.OptionListItemText>
1059
+ {label}
1060
+ </NetworkList.OptionListItemText>
1061
+ <NetworkList.OptionListItemDescription>
1062
+ {meta.ticker}
1063
+ </NetworkList.OptionListItemDescription>
1064
+ </NetworkList.OptionListItemContent>
1065
+ </NetworkList.OptionListItem>
1066
+ ) : null
1067
+ }
1068
+ />
1069
+ </NetworkList.OptionList>
1070
+ </Box>
1071
+ );
1072
+ },
1073
+ };
@@ -15,6 +15,7 @@ import {
15
15
  OptionListEmptyState,
16
16
  OptionListSearch,
17
17
  OptionListTrigger,
18
+ createOptionList,
18
19
  } from './OptionList';
19
20
  import type { OptionListItemData } from './types';
20
21
 
@@ -552,4 +553,43 @@ describe('OptionList', () => {
552
553
  expect(queryByText('Selected value')).toBeTruthy();
553
554
  });
554
555
  });
556
+
557
+ describe('createOptionList', () => {
558
+ it('returns typed components that render and select', () => {
559
+ type Value = 'a' | 'b' | 'c';
560
+ const typedItems: OptionListItemData<Value>[] = [
561
+ { value: 'a', label: 'Alpha' },
562
+ { value: 'b', label: 'Beta' },
563
+ { value: 'c', label: 'Gamma' },
564
+ ];
565
+ const {
566
+ OptionList: TypedList,
567
+ OptionListContent: TypedContent,
568
+ OptionListItem: TypedItem,
569
+ OptionListItemContent: TypedItemContent,
570
+ OptionListItemText: TypedItemText,
571
+ } = createOptionList<Value>();
572
+ const onValueChange = jest.fn();
573
+
574
+ const { getByText } = render(
575
+ <TestWrapper>
576
+ <TypedList items={typedItems} onValueChange={onValueChange}>
577
+ <TypedContent
578
+ renderItem={(item) => (
579
+ <TypedItem value={item.value}>
580
+ <TypedItemContent>
581
+ <TypedItemText>{item.label}</TypedItemText>
582
+ </TypedItemContent>
583
+ </TypedItem>
584
+ )}
585
+ />
586
+ </TypedList>
587
+ </TestWrapper>,
588
+ );
589
+
590
+ fireEvent.press(getByText('Beta'));
591
+
592
+ expect(onValueChange).toHaveBeenCalledWith('b');
593
+ });
594
+ });
555
595
  });
@@ -3,7 +3,7 @@ import {
3
3
  useDisabledContext,
4
4
  DisabledProvider,
5
5
  } from '@ledgerhq/lumen-utils-shared';
6
- import { Fragment, type ReactNode } from 'react';
6
+ import { Fragment, type ReactElement, type ReactNode } from 'react';
7
7
  import { StyleSheet, View } from 'react-native';
8
8
  import { useStyleSheet } from '../../../styles';
9
9
  import { Check, ChevronDown } from '../../Symbols';
@@ -27,13 +27,17 @@ import type {
27
27
  OptionListSearchProps,
28
28
  OptionListTriggerProps,
29
29
  OptionListLabelProps,
30
+ OptionListValue,
30
31
  } from './types';
31
32
  import { useOptionListItems } from './useOptionList/useOptionListItems';
32
33
 
33
34
  const [OptionListProvider, useOptionListContext] =
34
35
  createSafeContext<OptionListContextValue>('OptionList');
35
36
 
36
- export const OptionList = <TMeta extends MetaShape = MetaShape>({
37
+ export const OptionList = <
38
+ T extends OptionListValue = OptionListValue,
39
+ TMeta extends MetaShape = MetaShape,
40
+ >({
37
41
  items,
38
42
  value,
39
43
  defaultValue,
@@ -45,7 +49,7 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
45
49
  defaultSearchValue,
46
50
  onSearchValueChange,
47
51
  children,
48
- }: OptionListProps<TMeta>) => {
52
+ }: OptionListProps<T, TMeta>) => {
49
53
  const disabled = useDisabledContext({
50
54
  consumerName: 'OptionList',
51
55
  mergeWith: { disabled: disabledProp },
@@ -55,7 +59,7 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
55
59
  {
56
60
  prop: value,
57
61
  defaultProp: defaultValue ?? null,
58
- onChange: onValueChange,
62
+ onChange: (next: string | null) => onValueChange?.(next as T | null),
59
63
  },
60
64
  );
61
65
 
@@ -65,7 +69,7 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
65
69
  flatItems,
66
70
  resolvedSearchValue,
67
71
  handleSearchValueChange,
68
- } = useOptionListItems<TMeta>({
72
+ } = useOptionListItems<T, TMeta>({
69
73
  items,
70
74
  filter,
71
75
  filteredItems,
@@ -93,20 +97,26 @@ export const OptionList = <TMeta extends MetaShape = MetaShape>({
93
97
  );
94
98
  };
95
99
 
96
- export const OptionListContent = <TMeta extends MetaShape = MetaShape>({
100
+ export const OptionListContent = <
101
+ T extends OptionListValue = OptionListValue,
102
+ TMeta extends MetaShape = MetaShape,
103
+ >({
97
104
  renderItem,
98
105
  lx,
99
106
  style,
100
107
  ref,
101
108
  ...props
102
- }: OptionListContentProps<TMeta>) => {
109
+ }: OptionListContentProps<T, TMeta>) => {
103
110
  const { selectedValue, isGrouped, groups, flatItems } = useOptionListContext({
104
111
  consumerName: 'OptionListContent',
105
112
  contextRequired: true,
106
113
  });
107
114
 
108
115
  const renderItemWithState = (item: OptionListItemData) =>
109
- renderItem(item as OptionListItemData<TMeta>, selectedValue === item.value);
116
+ renderItem(
117
+ item as OptionListItemData<T, TMeta>,
118
+ selectedValue === item.value,
119
+ );
110
120
 
111
121
  if (isGrouped) {
112
122
  return (
@@ -161,7 +171,7 @@ const useItemStyles = ({
161
171
  );
162
172
  };
163
173
 
164
- export const OptionListItem = ({
174
+ export const OptionListItem = <T extends OptionListValue = OptionListValue>({
165
175
  value,
166
176
  disabled: disabledProp = false,
167
177
  children,
@@ -169,7 +179,7 @@ export const OptionListItem = ({
169
179
  style,
170
180
  ref,
171
181
  ...props
172
- }: OptionListItemProps) => {
182
+ }: OptionListItemProps<T>) => {
173
183
  const { selectedValue, onValueChange } = useOptionListContext({
174
184
  consumerName: 'OptionListItem',
175
185
  contextRequired: true,
@@ -576,3 +586,40 @@ export const OptionListTrigger = ({
576
586
  </Pressable>
577
587
  );
578
588
  };
589
+
590
+ export function createOptionList<
591
+ T extends OptionListValue = never,
592
+ TMeta extends MetaShape = MetaShape,
593
+ >(): {
594
+ OptionList: (props: OptionListProps<T, TMeta>) => ReactElement;
595
+ OptionListContent: (props: OptionListContentProps<T, TMeta>) => ReactElement;
596
+ OptionListItem: (props: OptionListItemProps<T>) => ReactElement;
597
+ OptionListItemText: (props: OptionListItemTextProps) => ReactElement;
598
+ OptionListItemDescription: (
599
+ props: OptionListItemDescriptionProps,
600
+ ) => ReactElement;
601
+ OptionListItemContent: (props: OptionListItemContentProps) => ReactElement;
602
+ OptionListItemContentRow: (
603
+ props: OptionListItemContentRowProps,
604
+ ) => ReactElement;
605
+ OptionListItemLeading: (props: OptionListItemLeadingProps) => ReactElement;
606
+ OptionListSearch: (props: OptionListSearchProps) => ReactElement;
607
+ OptionListEmptyState: (
608
+ props: OptionListEmptyStateProps,
609
+ ) => ReactElement | null;
610
+ OptionListTrigger: (props: OptionListTriggerProps) => ReactElement;
611
+ } {
612
+ return {
613
+ OptionList,
614
+ OptionListContent,
615
+ OptionListItem,
616
+ OptionListItemText,
617
+ OptionListItemDescription,
618
+ OptionListItemContent,
619
+ OptionListItemContentRow,
620
+ OptionListItemLeading,
621
+ OptionListSearch,
622
+ OptionListEmptyState,
623
+ OptionListTrigger,
624
+ };
625
+ }
@@ -8,9 +8,14 @@ import type { SearchInputProps } from '../SearchInput';
8
8
 
9
9
  export type MetaShape = Record<string, unknown>;
10
10
 
11
- export type OptionListItemData<TMeta extends MetaShape = MetaShape> = {
11
+ export type OptionListValue = string;
12
+
13
+ export type OptionListItemData<
14
+ T extends OptionListValue = OptionListValue,
15
+ TMeta extends MetaShape = MetaShape,
16
+ > = {
12
17
  /** Unique string identifier for this item, used for selection tracking. */
13
- value: string;
18
+ value: T;
14
19
  /** Display text. */
15
20
  label: string;
16
21
  /** Secondary text displayed below the label inside the item. */
@@ -43,29 +48,35 @@ export type OptionListContextValue = {
43
48
  };
44
49
 
45
50
  /** Internal type -- consumers never construct this directly. */
46
- export type OptionListItemGroup<TMeta extends MetaShape = MetaShape> = {
51
+ export type OptionListItemGroup<
52
+ T extends OptionListValue = OptionListValue,
53
+ TMeta extends MetaShape = MetaShape,
54
+ > = {
47
55
  label: string;
48
- items: OptionListItemData<TMeta>[];
56
+ items: OptionListItemData<T, TMeta>[];
49
57
  };
50
58
 
51
- export type OptionListProps<TMeta extends MetaShape = MetaShape> = {
59
+ export type OptionListProps<
60
+ T extends OptionListValue = OptionListValue,
61
+ TMeta extends MetaShape = MetaShape,
62
+ > = {
52
63
  /**
53
64
  * Flat array of items.
54
65
  * Use the `group` field on each item for automatic grouping.
55
66
  */
56
- items: OptionListItemData<TMeta>[];
67
+ items: OptionListItemData<T, TMeta>[];
57
68
  /**
58
69
  * The controlled selected value.
59
70
  */
60
- value?: string | null;
71
+ value?: T | null;
61
72
  /**
62
73
  * The default selected value (uncontrolled)
63
74
  */
64
- defaultValue?: string | null;
75
+ defaultValue?: T | null;
65
76
  /**
66
77
  * Called when the selected value changes.
67
78
  */
68
- onValueChange?: (value: string | null) => void;
79
+ onValueChange?: (value: T | null) => void;
69
80
  /**
70
81
  * When true, prevents interaction with the entire list.
71
82
  */
@@ -74,12 +85,14 @@ export type OptionListProps<TMeta extends MetaShape = MetaShape> = {
74
85
  * Custom item/query matcher.
75
86
  * Defaults to case-insensitive label match; `null` disables filtering.
76
87
  */
77
- filter?: null | ((item: OptionListItemData<TMeta>, query: string) => boolean);
88
+ filter?:
89
+ | null
90
+ | ((item: OptionListItemData<T, TMeta>, query: string) => boolean);
78
91
  /**
79
92
  * Pre-filtered items for async/remote search.
80
93
  * Bypasses internal filtering.
81
94
  */
82
- filteredItems?: OptionListItemData<TMeta>[];
95
+ filteredItems?: OptionListItemData<T, TMeta>[];
83
96
  /**
84
97
  * Controlled search input value.
85
98
  */
@@ -95,14 +108,20 @@ export type OptionListProps<TMeta extends MetaShape = MetaShape> = {
95
108
  children: ReactNode;
96
109
  };
97
110
 
98
- export type OptionListContentProps<TMeta extends MetaShape = MetaShape> = {
111
+ export type OptionListContentProps<
112
+ T extends OptionListValue = OptionListValue,
113
+ TMeta extends MetaShape = MetaShape,
114
+ > = {
99
115
  /** Render function called for each item. Receives the item data and selection/disabled state. */
100
- renderItem: (item: OptionListItemData<TMeta>, selected: boolean) => ReactNode;
116
+ renderItem: (
117
+ item: OptionListItemData<T, TMeta>,
118
+ selected: boolean,
119
+ ) => ReactNode;
101
120
  } & Omit<StyledViewProps, 'children'>;
102
121
 
103
- export type OptionListItemProps = {
122
+ export type OptionListItemProps<T extends OptionListValue = OptionListValue> = {
104
123
  /** The value associated with this item, used for selection matching. */
105
- value: string;
124
+ value: T;
106
125
  /** Whether the item is disabled. */
107
126
  disabled?: boolean;
108
127
  children: ReactNode;
@@ -4,13 +4,17 @@ import type {
4
4
  MetaShape,
5
5
  OptionListItemData,
6
6
  OptionListItemGroup,
7
+ OptionListValue,
7
8
  } from '../types';
8
9
 
9
- const groupByField = <TMeta extends MetaShape = MetaShape>(
10
- items: OptionListItemData<TMeta>[],
11
- ): OptionListItemGroup<TMeta>[] => {
10
+ const groupByField = <
11
+ T extends OptionListValue = OptionListValue,
12
+ TMeta extends MetaShape = MetaShape,
13
+ >(
14
+ items: OptionListItemData<T, TMeta>[],
15
+ ): OptionListItemGroup<T, TMeta>[] => {
12
16
  const order: string[] = [];
13
- const map: Record<string, OptionListItemData<TMeta>[]> = {};
17
+ const map: Record<string, OptionListItemData<T, TMeta>[]> = {};
14
18
  for (const item of items) {
15
19
  const key = item.group ?? '';
16
20
  if (!map[key]) {
@@ -30,31 +34,42 @@ export const defaultLabelFilter = (
30
34
  query: string,
31
35
  ): boolean => item.label.toLowerCase().includes(query.toLowerCase());
32
36
 
33
- type UseOptionListItemsParams<TMeta extends MetaShape = MetaShape> = {
34
- items: OptionListItemData<TMeta>[];
35
- filter?: null | ((item: OptionListItemData<TMeta>, query: string) => boolean);
36
- filteredItems?: OptionListItemData<TMeta>[];
37
+ type UseOptionListItemsParams<
38
+ T extends OptionListValue = OptionListValue,
39
+ TMeta extends MetaShape = MetaShape,
40
+ > = {
41
+ items: OptionListItemData<T, TMeta>[];
42
+ filter?:
43
+ | null
44
+ | ((item: OptionListItemData<T, TMeta>, query: string) => boolean);
45
+ filteredItems?: OptionListItemData<T, TMeta>[];
37
46
  searchValue?: string;
38
47
  defaultSearchValue?: string;
39
48
  onSearchValueChange?: (value: string) => void;
40
49
  };
41
50
 
42
- type UseOptionListItemsResult<TMeta extends MetaShape = MetaShape> = {
51
+ type UseOptionListItemsResult<
52
+ T extends OptionListValue = OptionListValue,
53
+ TMeta extends MetaShape = MetaShape,
54
+ > = {
43
55
  isGrouped: boolean;
44
- groups: OptionListItemGroup<TMeta>[];
45
- flatItems: OptionListItemData<TMeta>[];
56
+ groups: OptionListItemGroup<T, TMeta>[];
57
+ flatItems: OptionListItemData<T, TMeta>[];
46
58
  resolvedSearchValue: string;
47
59
  handleSearchValueChange: (val: string) => void;
48
60
  };
49
61
 
50
- export const useOptionListItems = <TMeta extends MetaShape = MetaShape>({
62
+ export const useOptionListItems = <
63
+ T extends OptionListValue = OptionListValue,
64
+ TMeta extends MetaShape = MetaShape,
65
+ >({
51
66
  items,
52
67
  filter,
53
68
  filteredItems,
54
69
  searchValue: searchValueProp,
55
70
  defaultSearchValue,
56
71
  onSearchValueChange,
57
- }: UseOptionListItemsParams<TMeta>): UseOptionListItemsResult<TMeta> => {
72
+ }: UseOptionListItemsParams<T, TMeta>): UseOptionListItemsResult<T, TMeta> => {
58
73
  const [searchValue, handleSearchValueChange] = useControllableState<string>({
59
74
  prop: searchValueProp,
60
75
  defaultProp: defaultSearchValue ?? '',
@@ -67,6 +67,8 @@ Use the library as part of `@ledgerhq/lumen-ui-rnative`. See the [Setup Guide
67
67
 
68
68
  Control selected value in state at the top level and pass it to SegmentedControl. Buttons use value to identify themselves; selected state is derived from context.
69
69
 
70
+ > **Recommended:** prefer the [type-safe variant](#type-safe-variant) below. It shares a single literal union across the root and every button.
71
+
70
72
  ```tsx
71
73
  import { SegmentedControl, SegmentedControlButton } from '@ledgerhq/lumen-ui-rnative';
72
74
 
@@ -87,6 +89,32 @@ export default function Example() {
87
89
  }
88
90
  ```
89
91
 
92
+ ## Type-safe variant
93
+
94
+ `SegmentedControl` and `SegmentedControlButton` communicate via context, so TypeScript cannot connect the root's `selectedValue` to each button's `value` on its own. A typo compiles, and the pill silently never moves to that segment.
95
+
96
+ `createSegmentedControl<T>()` ties both components to one literal union. Call it once at module scope; the returned pair are typed against your `T`.
97
+
98
+ ```tsx
99
+ import { createSegmentedControl } from '@ledgerhq/lumen-ui-rnative';
100
+
101
+ type View = 'preview' | 'raw' | 'blame';
102
+
103
+ const { SegmentedControl, SegmentedControlButton } = createSegmentedControl<View>();
104
+
105
+ function Example() {
106
+ const [view, setView] = React.useState<View>('preview');
107
+
108
+ return (
109
+ <SegmentedControl selectedValue={view} onSelectedChange={setView}>
110
+ <SegmentedControlButton value='preview'>Preview</SegmentedControlButton>
111
+ <SegmentedControlButton value='raw'>Raw</SegmentedControlButton>
112
+ <SegmentedControlButton value='blame'>Blame</SegmentedControlButton>
113
+ </SegmentedControl>
114
+ );
115
+ }
116
+ ```
117
+
90
118
  ## With trailing content
91
119
 
92
120
  Pass any node via `trailingContent` to render it to the right of the label. Use it for badges like `DotCount` to surface counts per segment.
@@ -3,7 +3,11 @@ import { useState } from 'react';
3
3
  import { Coins, Nft, TransferHorizontal } from '../../Symbols';
4
4
  import { DotCount } from '../DotCount';
5
5
  import { Box } from '../Utility';
6
- import { SegmentedControl, SegmentedControlButton } from './SegmentedControl';
6
+ import {
7
+ createSegmentedControl,
8
+ SegmentedControl,
9
+ SegmentedControlButton,
10
+ } from './SegmentedControl';
7
11
 
8
12
  const meta = {
9
13
  id: 'rnative-segmentedcontrol',
@@ -171,3 +175,32 @@ export const WithTrailingContent: Story = {
171
175
  );
172
176
  },
173
177
  };
178
+
179
+ type View = 'preview' | 'raw' | 'blame';
180
+ const TypedSegmentedControl = createSegmentedControl<View>();
181
+
182
+ export const TypesafeFactory: Story = {
183
+ args: {} as React.ComponentProps<typeof SegmentedControl>,
184
+ render: (args) => {
185
+ const [view, setView] = useState<View>('preview');
186
+
187
+ return (
188
+ <TypedSegmentedControl.SegmentedControl
189
+ {...args}
190
+ selectedValue={view}
191
+ onSelectedChange={setView}
192
+ accessibilityLabel='File view'
193
+ >
194
+ <TypedSegmentedControl.SegmentedControlButton value='preview'>
195
+ Preview
196
+ </TypedSegmentedControl.SegmentedControlButton>
197
+ <TypedSegmentedControl.SegmentedControlButton value='raw'>
198
+ Raw
199
+ </TypedSegmentedControl.SegmentedControlButton>
200
+ <TypedSegmentedControl.SegmentedControlButton value='blame'>
201
+ Blame
202
+ </TypedSegmentedControl.SegmentedControlButton>
203
+ </TypedSegmentedControl.SegmentedControl>
204
+ );
205
+ },
206
+ };
@@ -3,7 +3,11 @@ import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
3
3
  import { render, fireEvent } from '@testing-library/react-native';
4
4
  import { DotCount } from '../DotCount';
5
5
  import { ThemeProvider } from '../ThemeProvider/ThemeProvider';
6
- import { SegmentedControl, SegmentedControlButton } from './SegmentedControl';
6
+ import {
7
+ SegmentedControl,
8
+ SegmentedControlButton,
9
+ createSegmentedControl,
10
+ } from './SegmentedControl';
7
11
 
8
12
  const TestWrapper = ({ children }: { children: React.ReactNode }) => (
9
13
  <ThemeProvider themes={ledgerLiveThemes} colorScheme='dark' locale='en'>
@@ -172,4 +176,31 @@ describe('SegmentedControl', () => {
172
176
  expect(onSelectedChange).toHaveBeenCalledWith('preview');
173
177
  });
174
178
  });
179
+
180
+ describe('createSegmentedControl', () => {
181
+ it('returns typed components that render and select like the originals', () => {
182
+ const {
183
+ SegmentedControl: TypedControl,
184
+ SegmentedControlButton: TypedButton,
185
+ } = createSegmentedControl<'send' | 'receive'>();
186
+ const onSelectedChange = jest.fn();
187
+
188
+ const { getByText } = render(
189
+ <TestWrapper>
190
+ <TypedControl
191
+ selectedValue='send'
192
+ onSelectedChange={onSelectedChange}
193
+ accessibilityLabel='Transaction type'
194
+ >
195
+ <TypedButton value='send'>Send</TypedButton>
196
+ <TypedButton value='receive'>Receive</TypedButton>
197
+ </TypedControl>
198
+ </TestWrapper>,
199
+ );
200
+
201
+ fireEvent.press(getByText('Receive'));
202
+
203
+ expect(onSelectedChange).toHaveBeenCalledWith('receive');
204
+ });
205
+ });
175
206
  });