@ledgerhq/lumen-ui-rnative 0.1.39 → 0.1.41

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 (68) hide show
  1. package/dist/module/lib/Components/AmountDisplay/AmountDisplay.js +28 -11
  2. package/dist/module/lib/Components/AmountDisplay/AmountDisplay.js.map +1 -1
  3. package/dist/module/lib/Components/AmountDisplay/AmountDisplay.test.js +71 -0
  4. package/dist/module/lib/Components/AmountDisplay/AmountDisplay.test.js.map +1 -1
  5. package/dist/module/lib/Components/AmountInput/AmountInput.js +49 -154
  6. package/dist/module/lib/Components/AmountInput/AmountInput.js.map +1 -1
  7. package/dist/module/lib/Components/AmountInput/AmountInput.test.js +152 -0
  8. package/dist/module/lib/Components/AmountInput/AmountInput.test.js.map +1 -0
  9. package/dist/module/lib/Components/AmountInput/useAmountInputAnimations/useAmountInputAnimations.js +149 -0
  10. package/dist/module/lib/Components/AmountInput/useAmountInputAnimations/useAmountInputAnimations.js.map +1 -0
  11. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.js +22 -0
  12. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.js.map +1 -0
  13. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.test.js +59 -0
  14. package/dist/module/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.test.js.map +1 -0
  15. package/dist/module/lib/Components/Avatar/Avatar.figma.js +5 -0
  16. package/dist/module/lib/Components/Avatar/Avatar.figma.js.map +1 -1
  17. package/dist/module/lib/Components/Avatar/Avatar.js +9 -2
  18. package/dist/module/lib/Components/Avatar/Avatar.js.map +1 -1
  19. package/dist/module/lib/Components/Avatar/Avatar.mdx +9 -0
  20. package/dist/module/lib/Components/Avatar/Avatar.stories.js +47 -0
  21. package/dist/module/lib/Components/Avatar/Avatar.stories.js.map +1 -1
  22. package/dist/module/lib/Components/Avatar/Avatar.test.js +23 -1
  23. package/dist/module/lib/Components/Avatar/Avatar.test.js.map +1 -1
  24. package/dist/module/lib/Components/BottomSheet/BottomSheetHeader.js +0 -3
  25. package/dist/module/lib/Components/BottomSheet/BottomSheetHeader.js.map +1 -1
  26. package/dist/module/lib/Components/Icon/Icon.test.js +1 -1
  27. package/dist/module/lib/Components/Switch/Switch.js +144 -8
  28. package/dist/module/lib/Components/Switch/Switch.js.map +1 -1
  29. package/dist/typescript/src/lib/Components/AmountDisplay/AmountDisplay.d.ts.map +1 -1
  30. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts +1 -1
  31. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts.map +1 -1
  32. package/dist/typescript/src/lib/Components/AmountInput/useAmountInputAnimations/useAmountInputAnimations.d.ts +16 -0
  33. package/dist/typescript/src/lib/Components/AmountInput/useAmountInputAnimations/useAmountInputAnimations.d.ts.map +1 -0
  34. package/dist/typescript/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.d.ts +18 -0
  35. package/dist/typescript/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.d.ts.map +1 -0
  36. package/dist/typescript/src/lib/Components/Avatar/Avatar.d.ts +1 -1
  37. package/dist/typescript/src/lib/Components/Avatar/Avatar.d.ts.map +1 -1
  38. package/dist/typescript/src/lib/Components/Avatar/types.d.ts +6 -0
  39. package/dist/typescript/src/lib/Components/Avatar/types.d.ts.map +1 -1
  40. package/dist/typescript/src/lib/Components/BottomSheet/BottomSheetHeader.d.ts +1 -1
  41. package/dist/typescript/src/lib/Components/BottomSheet/BottomSheetHeader.d.ts.map +1 -1
  42. package/dist/typescript/src/lib/Components/Switch/Switch.d.ts +1 -1
  43. package/dist/typescript/src/lib/Components/Switch/Switch.d.ts.map +1 -1
  44. package/dist/typescript/src/lib/Components/Switch/types.d.ts +2 -1
  45. package/dist/typescript/src/lib/Components/Switch/types.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/lib/Components/AmountDisplay/AmountDisplay.test.tsx +92 -0
  48. package/src/lib/Components/AmountDisplay/AmountDisplay.tsx +37 -15
  49. package/src/lib/Components/AmountInput/AmountInput.test.tsx +166 -0
  50. package/src/lib/Components/AmountInput/AmountInput.tsx +55 -110
  51. package/src/lib/Components/AmountInput/useAmountInputAnimations/useAmountInputAnimations.ts +100 -0
  52. package/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.test.ts +62 -0
  53. package/src/lib/Components/AmountInput/useAmountInputFormatting/useAmountInputFormatting.ts +48 -0
  54. package/src/lib/Components/Avatar/Avatar.figma.tsx +5 -0
  55. package/src/lib/Components/Avatar/Avatar.mdx +9 -0
  56. package/src/lib/Components/Avatar/Avatar.stories.tsx +41 -0
  57. package/src/lib/Components/Avatar/Avatar.test.tsx +31 -1
  58. package/src/lib/Components/Avatar/Avatar.tsx +17 -4
  59. package/src/lib/Components/Avatar/types.ts +6 -0
  60. package/src/lib/Components/BottomSheet/BottomSheetHeader.tsx +0 -4
  61. package/src/lib/Components/Icon/Icon.test.tsx +1 -1
  62. package/src/lib/Components/Switch/Switch.tsx +132 -11
  63. package/src/lib/Components/Switch/types.ts +3 -1
  64. package/dist/module/lib/Components/Switch/BaseSwitch.js +0 -221
  65. package/dist/module/lib/Components/Switch/BaseSwitch.js.map +0 -1
  66. package/dist/typescript/src/lib/Components/Switch/BaseSwitch.d.ts +0 -13
  67. package/dist/typescript/src/lib/Components/Switch/BaseSwitch.d.ts.map +0 -1
  68. package/src/lib/Components/Switch/BaseSwitch.tsx +0 -249
@@ -164,6 +164,12 @@ const useAnimatedDigitStrip = ({
164
164
  return { animatedStyle };
165
165
  };
166
166
 
167
+ // Horizontal breathing room for the clip box so glyphs that are slightly wider
168
+ // than the measured `targetWidth` are not cut on their left/right edges. RN has
169
+ // no per-axis overflow, so we extend the clip box horizontally while keeping the
170
+ // vertical clip tight to `lineHeight`
171
+ const HORIZONTAL_CLIP_PADDING = 8;
172
+
167
173
  const DigitStrip = memo(
168
174
  ({ value, textStyle, animate, widths }: DigitStripProps) => {
169
175
  const targetWidth = widths[value];
@@ -179,23 +185,39 @@ const DigitStrip = memo(
179
185
 
180
186
  return (
181
187
  <Animated.View
182
- style={{ height: lineHeight, overflow: 'hidden', width: width }}
188
+ style={{
189
+ height: lineHeight,
190
+ width: animate ? width : targetWidth,
191
+ }}
183
192
  accessibilityValue={{ text: String(value) }}
184
193
  >
185
- <Animated.View style={[animatedStyle, { alignItems: 'center' }]}>
186
- {DIGITS.map((d) => (
187
- <Text
188
- allowFontScaling={false}
189
- key={d}
190
- style={[
191
- textStyle,
192
- RuntimeConstants.isAndroid && { height: lineHeight },
193
- ]}
194
- >
195
- {d}
196
- </Text>
197
- ))}
198
- </Animated.View>
194
+ <View
195
+ pointerEvents='none'
196
+ style={{
197
+ position: 'absolute',
198
+ top: 0,
199
+ bottom: 0,
200
+ left: -HORIZONTAL_CLIP_PADDING,
201
+ right: -HORIZONTAL_CLIP_PADDING,
202
+ overflow: 'hidden',
203
+ alignItems: 'center',
204
+ }}
205
+ >
206
+ <Animated.View style={[animatedStyle, { alignItems: 'center' }]}>
207
+ {DIGITS.map((d) => (
208
+ <Text
209
+ allowFontScaling={false}
210
+ key={d}
211
+ style={[
212
+ textStyle,
213
+ RuntimeConstants.isAndroid && { height: lineHeight },
214
+ ]}
215
+ >
216
+ {d}
217
+ </Text>
218
+ ))}
219
+ </Animated.View>
220
+ </View>
199
221
  </Animated.View>
200
222
  );
201
223
  },
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
3
+ import { fireEvent, render, screen } from '@testing-library/react-native';
4
+ import { ThemeProvider } from '../ThemeProvider/ThemeProvider';
5
+ import { AmountInput } from './AmountInput';
6
+
7
+ const hidden = { includeHiddenElements: true } as const;
8
+
9
+ const renderWithProvider = (component: React.ReactElement) =>
10
+ render(
11
+ <ThemeProvider themes={ledgerLiveThemes} colorScheme='dark' locale='en'>
12
+ {component}
13
+ </ThemeProvider>,
14
+ );
15
+
16
+ describe('AmountInput', () => {
17
+ describe('Rendering', () => {
18
+ it('renders with an empty value', () => {
19
+ renderWithProvider(
20
+ <AmountInput value='' onChangeText={jest.fn()} testID='input' />,
21
+ );
22
+ expect(screen.getByTestId('input')).toBeTruthy();
23
+ });
24
+
25
+ it('renders the currency text', () => {
26
+ renderWithProvider(
27
+ <AmountInput value='' onChangeText={jest.fn()} currencyText='USD' />,
28
+ );
29
+ expect(screen.getByText('USD', hidden)).toBeTruthy();
30
+ });
31
+
32
+ it('displays 0 when value is empty', () => {
33
+ renderWithProvider(<AmountInput value='' onChangeText={jest.fn()} />);
34
+ expect(screen.getByText('0', hidden)).toBeTruthy();
35
+ });
36
+ });
37
+
38
+ describe('Interactions', () => {
39
+ it('calls onChangeText with formatted text on change', () => {
40
+ const onChangeText = jest.fn();
41
+ renderWithProvider(
42
+ <AmountInput
43
+ value=''
44
+ onChangeText={onChangeText}
45
+ testID='input'
46
+ thousandsSeparator={false}
47
+ />,
48
+ );
49
+ fireEvent.changeText(screen.getByTestId('input'), '123.45');
50
+ expect(onChangeText).toHaveBeenCalledWith('123.45');
51
+ });
52
+
53
+ it('formats with thousands separator on user input', () => {
54
+ const onChangeText = jest.fn();
55
+ renderWithProvider(
56
+ <AmountInput value='' onChangeText={onChangeText} testID='input' />,
57
+ );
58
+ fireEvent.changeText(screen.getByTestId('input'), '1000');
59
+ expect(onChangeText).toHaveBeenCalledWith('1 000');
60
+ });
61
+
62
+ it('strips decimal input when allowDecimals is false', () => {
63
+ const onChangeText = jest.fn();
64
+ renderWithProvider(
65
+ <AmountInput
66
+ value=''
67
+ onChangeText={onChangeText}
68
+ allowDecimals={false}
69
+ thousandsSeparator={false}
70
+ testID='input'
71
+ />,
72
+ );
73
+ fireEvent.changeText(screen.getByTestId('input'), '12.34');
74
+ expect(onChangeText).toHaveBeenCalledWith('1234');
75
+ });
76
+
77
+ it('truncates decimal part with maxDecimalLength on user input', () => {
78
+ const onChangeText = jest.fn();
79
+ renderWithProvider(
80
+ <AmountInput
81
+ value=''
82
+ onChangeText={onChangeText}
83
+ maxDecimalLength={2}
84
+ thousandsSeparator={false}
85
+ testID='input'
86
+ />,
87
+ );
88
+ fireEvent.changeText(screen.getByTestId('input'), '1.2345');
89
+ expect(onChangeText).toHaveBeenCalledWith('1.23');
90
+ });
91
+ });
92
+
93
+ describe('programmatic value formatting', () => {
94
+ it('formats a raw unformatted string on initial render', () => {
95
+ renderWithProvider(
96
+ <AmountInput value='2433.123456789' onChangeText={jest.fn()} />,
97
+ );
98
+ expect(screen.getByText('2 433.123456789', hidden)).toBeTruthy();
99
+ });
100
+
101
+ it('formats a number value on initial render', () => {
102
+ renderWithProvider(
103
+ <AmountInput value={2433.123456789} onChangeText={jest.fn()} />,
104
+ );
105
+ expect(screen.getByText('2 433.123456789', hidden)).toBeTruthy();
106
+ });
107
+
108
+ it('adds thousands grouping for a large number value', () => {
109
+ renderWithProvider(
110
+ <AmountInput value={1000000} onChangeText={jest.fn()} />,
111
+ );
112
+ expect(screen.getByText('1 000 000', hidden)).toBeTruthy();
113
+ });
114
+
115
+ it('applies maxDecimalLength to a programmatically set value', () => {
116
+ renderWithProvider(
117
+ <AmountInput
118
+ value='2433.123456789'
119
+ maxDecimalLength={2}
120
+ onChangeText={jest.fn()}
121
+ />,
122
+ );
123
+ expect(screen.getByText('2 433.12', hidden)).toBeTruthy();
124
+ });
125
+
126
+ it('applies maxIntegerLength to a programmatically set value', () => {
127
+ renderWithProvider(
128
+ <AmountInput
129
+ value='1234567890'
130
+ maxIntegerLength={3}
131
+ allowDecimals={false}
132
+ thousandsSeparator={false}
133
+ onChangeText={jest.fn()}
134
+ />,
135
+ );
136
+ expect(screen.getByText('123', hidden)).toBeTruthy();
137
+ });
138
+
139
+ it('omits thousands separator when thousandsSeparator is false', () => {
140
+ renderWithProvider(
141
+ <AmountInput
142
+ value='2433.12'
143
+ thousandsSeparator={false}
144
+ onChangeText={jest.fn()}
145
+ />,
146
+ );
147
+ expect(screen.getByText('2433.12', hidden)).toBeTruthy();
148
+ });
149
+
150
+ it('formats consistently with what typing the same digits produces', () => {
151
+ const onChangeText = jest.fn();
152
+ renderWithProvider(
153
+ <AmountInput
154
+ value='2433.123456789'
155
+ onChangeText={onChangeText}
156
+ testID='input'
157
+ />,
158
+ );
159
+
160
+ expect(screen.getByText('2 433.123456789', hidden)).toBeTruthy();
161
+
162
+ fireEvent.changeText(screen.getByTestId('input'), '2433.123456789');
163
+ expect(onChangeText).toHaveBeenCalledWith('2 433.123456789');
164
+ });
165
+ });
166
+ });
@@ -1,25 +1,28 @@
1
- import {
2
- getFontSize,
3
- textFormatter,
4
- useDisabledContext,
5
- } from '@ledgerhq/lumen-utils-shared';
6
- import { useEffect, useImperativeHandle, useRef, useState } from 'react';
1
+ import { useDisabledContext } from '@ledgerhq/lumen-utils-shared';
2
+ import { useImperativeHandle, useRef, useState } from 'react';
3
+ import type { StyleProp, TextStyle } from 'react-native';
7
4
  import { Pressable, StyleSheet, TextInput, View } from 'react-native';
8
- import Animated, {
9
- Easing,
10
- useAnimatedStyle,
11
- useSharedValue,
12
- withRepeat,
13
- withSequence,
14
- withTiming,
15
- } from 'react-native-reanimated';
16
- import { useStyleSheet, useTheme } from '../../../styles';
5
+ import Animated from 'react-native-reanimated';
6
+ import { useStyleSheet } from '../../../styles';
17
7
  import { Box } from '../Utility';
18
- import {
19
- type AmountInputAlign,
20
- type AmountInputProps,
21
- type AmountInputSize,
8
+ import type {
9
+ AmountInputAlign,
10
+ AmountInputProps,
11
+ AmountInputSize,
22
12
  } from './types';
13
+ import { useAmountInputAnimations } from './useAmountInputAnimations/useAmountInputAnimations';
14
+ import { useAmountInputFormatting } from './useAmountInputFormatting/useAmountInputFormatting';
15
+
16
+ type CurrencyProps = {
17
+ style: StyleProp<TextStyle>;
18
+ children: string;
19
+ };
20
+
21
+ const Currency = ({ style, children }: CurrencyProps) => (
22
+ <Animated.Text style={style} allowFontScaling={false}>
23
+ {children}
24
+ </Animated.Text>
25
+ );
23
26
 
24
27
  /**
25
28
  * AmountInput component for handling numeric input with currency display.
@@ -46,109 +49,43 @@ export const AmountInput = ({
46
49
  ...props
47
50
  }: AmountInputProps) => {
48
51
  const inputRef = useRef<TextInput>(null);
49
- const inputValue = String(value);
50
52
  const [isFocused, setIsFocused] = useState(false);
53
+
51
54
  const disabled = useDisabledContext({
52
55
  consumerName: 'AmountInput',
53
56
  mergeWith: { disabled: disabledProp },
54
57
  });
55
58
 
56
- const translateX = useSharedValue(0);
57
- const animatedFontSize = useSharedValue(getFontSize(inputValue, size));
58
- const caretOpacity = useSharedValue(0);
59
+ const { formattedValue, handleChangeText } = useAmountInputFormatting({
60
+ value,
61
+ onChangeText,
62
+ formatOptions: {
63
+ allowDecimals,
64
+ thousandsSeparator,
65
+ maxIntegerLength,
66
+ maxDecimalLength,
67
+ },
68
+ });
59
69
 
60
- useImperativeHandle(ref, () => inputRef.current as TextInput, []);
70
+ const { animatedInputStyle, animatedCurrencyStyle, animatedCaretStyle } =
71
+ useAmountInputAnimations({
72
+ formattedValue,
73
+ size,
74
+ isFocused,
75
+ disabled,
76
+ });
61
77
 
62
- const { theme } = useTheme();
63
78
  const styles = useStyles({
64
79
  size,
65
80
  align,
66
- hasValue: !!inputValue,
81
+ hasValue: !!formattedValue,
67
82
  isEditable: !disabled,
68
83
  isInvalid,
69
84
  });
70
- const caretFixedHeight = size === 'sm' ? theme.sizes.s28 : 0;
71
-
72
- const animatedInputStyle = useAnimatedStyle(
73
- () => ({
74
- transform: [{ translateX: translateX.value }],
75
- fontSize: animatedFontSize.value,
76
- letterSpacing: 0,
77
- }),
78
- [translateX, animatedFontSize],
79
- );
80
-
81
- const animatedCurrencyStyle = useAnimatedStyle(
82
- () => ({
83
- fontSize: animatedFontSize.value,
84
- letterSpacing: 0,
85
- }),
86
- [animatedFontSize],
87
- );
88
85
 
89
- const animatedCaretStyle = useAnimatedStyle(
90
- () => ({
91
- opacity: caretOpacity.value,
92
- height: size === 'sm' ? caretFixedHeight : animatedFontSize.value,
93
- }),
94
- [caretOpacity, animatedFontSize, size, caretFixedHeight],
95
- );
96
-
97
- useEffect(() => {
98
- const newSize = getFontSize(inputValue, size);
99
-
100
- translateX.value = withSequence(
101
- withTiming(4, { duration: 0 }),
102
- withTiming(0, {
103
- duration: 250,
104
- easing: Easing.bezier(0.4, 0, 0.2, 1),
105
- }),
106
- );
107
-
108
- animatedFontSize.value = withTiming(newSize, {
109
- duration: 250,
110
- easing: Easing.bezier(0.4, 0, 0.2, 1),
111
- });
112
- }, [inputValue, size, animatedFontSize, translateX]);
113
-
114
- useEffect(() => {
115
- if (isFocused && !disabled) {
116
- caretOpacity.value = withRepeat(
117
- withSequence(
118
- withTiming(1, { duration: 150, easing: Easing.ease }),
119
- withTiming(1, { duration: 500 }),
120
- withTiming(0, { duration: 150, easing: Easing.ease }),
121
- withTiming(0, { duration: 500 }),
122
- ),
123
- -1,
124
- false,
125
- );
126
- } else {
127
- caretOpacity.value = 0;
128
- }
129
- }, [isFocused, disabled, caretOpacity]);
130
-
131
- const handleChangeText = (text: string) => {
132
- const formatted = textFormatter(text, {
133
- allowDecimals,
134
- thousandsSeparator,
135
- maxIntegerLength,
136
- maxDecimalLength,
137
- });
138
-
139
- onChangeText(formatted);
140
- };
141
-
142
- const CurrencyText = currencyText ? (
143
- <Animated.Text
144
- style={[styles.currency, animatedCurrencyStyle]}
145
- allowFontScaling={false}
146
- >
147
- {currencyText}
148
- </Animated.Text>
149
- ) : null;
86
+ useImperativeHandle(ref, () => inputRef.current as TextInput, []);
150
87
 
151
- const handlePress = () => {
88
+ const handlePress = (): void => {
152
89
  if (!disabled) {
153
90
  inputRef.current?.focus();
154
91
  }
@@ -161,7 +98,7 @@ export const AmountInput = ({
161
98
  ref={inputRef}
162
99
  keyboardType='decimal-pad'
163
100
  editable={editable !== false && !disabled}
164
- value={inputValue}
101
+ value={formattedValue}
165
102
  onChangeText={handleChangeText}
166
103
  onFocus={(e) => {
167
104
  setIsFocused(true);
@@ -180,20 +117,28 @@ export const AmountInput = ({
180
117
  style={styles.pressable}
181
118
  accessibilityLabel={props.accessibilityLabel || 'Amount input'}
182
119
  >
183
- {currencyPosition === 'left' && CurrencyText}
120
+ {currencyText && currencyPosition === 'left' && (
121
+ <Currency style={[styles.currency, animatedCurrencyStyle]}>
122
+ {currencyText}
123
+ </Currency>
124
+ )}
184
125
 
185
126
  {/** display text that mirrors the hidden input's value */}
186
127
  <Animated.Text
187
128
  style={[styles.displayText, animatedInputStyle, style]}
188
129
  allowFontScaling={false}
189
130
  >
190
- {inputValue || '0'}
131
+ {formattedValue || '0'}
191
132
  </Animated.Text>
192
133
 
193
134
  {/** custom caret */}
194
135
  <Animated.View style={[styles.caret, animatedCaretStyle]} />
195
136
 
196
- {currencyPosition === 'right' && CurrencyText}
137
+ {currencyText && currencyPosition === 'right' && (
138
+ <Currency style={[styles.currency, animatedCurrencyStyle]}>
139
+ {currencyText}
140
+ </Currency>
141
+ )}
197
142
  </Pressable>
198
143
  </View>
199
144
  </Box>
@@ -0,0 +1,100 @@
1
+ import { getFontSize } from '@ledgerhq/lumen-utils-shared';
2
+ import { useEffect } from 'react';
3
+ import {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withRepeat,
8
+ withSequence,
9
+ withTiming,
10
+ } from 'react-native-reanimated';
11
+ import { useTheme } from '../../../../styles';
12
+ import type { AmountInputSize } from '../types';
13
+
14
+ type UseAmountInputAnimationsArgs = {
15
+ formattedValue: string;
16
+ size: AmountInputSize;
17
+ isFocused: boolean;
18
+ disabled: boolean;
19
+ };
20
+
21
+ type UseAmountInputAnimationsReturn = {
22
+ animatedInputStyle: ReturnType<typeof useAnimatedStyle>;
23
+ animatedCurrencyStyle: ReturnType<typeof useAnimatedStyle>;
24
+ animatedCaretStyle: ReturnType<typeof useAnimatedStyle>;
25
+ };
26
+
27
+ export const useAmountInputAnimations = ({
28
+ formattedValue,
29
+ size,
30
+ isFocused,
31
+ disabled,
32
+ }: UseAmountInputAnimationsArgs): UseAmountInputAnimationsReturn => {
33
+ const { theme } = useTheme();
34
+ const caretFixedHeight = size === 'sm' ? theme.sizes.s28 : 0;
35
+
36
+ const translateX = useSharedValue(0);
37
+ const animatedFontSize = useSharedValue(getFontSize(formattedValue, size));
38
+ const caretOpacity = useSharedValue(0);
39
+
40
+ const animatedInputStyle = useAnimatedStyle(
41
+ () => ({
42
+ transform: [{ translateX: translateX.value }],
43
+ fontSize: animatedFontSize.value,
44
+ letterSpacing: 0,
45
+ }),
46
+ [translateX, animatedFontSize],
47
+ );
48
+
49
+ const animatedCurrencyStyle = useAnimatedStyle(
50
+ () => ({
51
+ fontSize: animatedFontSize.value,
52
+ letterSpacing: 0,
53
+ }),
54
+ [animatedFontSize],
55
+ );
56
+
57
+ const animatedCaretStyle = useAnimatedStyle(
58
+ () => ({
59
+ opacity: caretOpacity.value,
60
+ height: size === 'sm' ? caretFixedHeight : animatedFontSize.value,
61
+ }),
62
+ [caretOpacity, animatedFontSize, size, caretFixedHeight],
63
+ );
64
+
65
+ useEffect(() => {
66
+ const newSize = getFontSize(formattedValue, size);
67
+
68
+ translateX.value = withSequence(
69
+ withTiming(4, { duration: 0 }),
70
+ withTiming(0, {
71
+ duration: 250,
72
+ easing: Easing.bezier(0.4, 0, 0.2, 1),
73
+ }),
74
+ );
75
+
76
+ animatedFontSize.value = withTiming(newSize, {
77
+ duration: 250,
78
+ easing: Easing.bezier(0.4, 0, 0.2, 1),
79
+ });
80
+ }, [formattedValue, size, animatedFontSize, translateX]);
81
+
82
+ useEffect(() => {
83
+ if (isFocused && !disabled) {
84
+ caretOpacity.value = withRepeat(
85
+ withSequence(
86
+ withTiming(1, { duration: 150, easing: Easing.ease }),
87
+ withTiming(1, { duration: 500 }),
88
+ withTiming(0, { duration: 150, easing: Easing.ease }),
89
+ withTiming(0, { duration: 500 }),
90
+ ),
91
+ -1,
92
+ false,
93
+ );
94
+ } else {
95
+ caretOpacity.value = 0;
96
+ }
97
+ }, [isFocused, disabled, caretOpacity]);
98
+
99
+ return { animatedInputStyle, animatedCurrencyStyle, animatedCaretStyle };
100
+ };
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
+ import { renderHook, act } from '@testing-library/react-native';
3
+ import { useAmountInputFormatting } from './useAmountInputFormatting';
4
+
5
+ const defaultFormatOptions = {
6
+ allowDecimals: true,
7
+ thousandsSeparator: true,
8
+ maxIntegerLength: 9,
9
+ maxDecimalLength: 9,
10
+ };
11
+
12
+ describe('useAmountInputFormatting', () => {
13
+ it('formats the value prop', () => {
14
+ const { result } = renderHook(() =>
15
+ useAmountInputFormatting({
16
+ value: '1000',
17
+ onChangeText: jest.fn(),
18
+ formatOptions: defaultFormatOptions,
19
+ }),
20
+ );
21
+
22
+ expect(result.current.formattedValue).toBe('1 000');
23
+ });
24
+
25
+ it('updates formattedValue when the value prop changes', () => {
26
+ const { result, rerender } = renderHook<
27
+ ReturnType<typeof useAmountInputFormatting>,
28
+ { value: string }
29
+ >(
30
+ ({ value }) =>
31
+ useAmountInputFormatting({
32
+ value,
33
+ onChangeText: jest.fn(),
34
+ formatOptions: defaultFormatOptions,
35
+ }),
36
+ { initialProps: { value: '100' } },
37
+ );
38
+
39
+ expect(result.current.formattedValue).toBe('100');
40
+
41
+ rerender({ value: '2000' });
42
+
43
+ expect(result.current.formattedValue).toBe('2 000');
44
+ });
45
+
46
+ it('formats user input and calls onChangeText with the cleaned value', () => {
47
+ const onChangeText = jest.fn();
48
+ const { result } = renderHook(() =>
49
+ useAmountInputFormatting({
50
+ value: '',
51
+ onChangeText,
52
+ formatOptions: defaultFormatOptions,
53
+ }),
54
+ );
55
+
56
+ act(() => {
57
+ result.current.handleChangeText('1000');
58
+ });
59
+
60
+ expect(onChangeText).toHaveBeenCalledWith('1 000');
61
+ });
62
+ });
@@ -0,0 +1,48 @@
1
+ import { textFormatter } from '@ledgerhq/lumen-utils-shared';
2
+ import { useCallback, useMemo } from 'react';
3
+
4
+ type FormatOptions = {
5
+ allowDecimals: boolean;
6
+ thousandsSeparator: boolean;
7
+ maxIntegerLength: number;
8
+ maxDecimalLength: number;
9
+ };
10
+
11
+ type UseAmountInputFormattingArgs = {
12
+ value: string | number;
13
+ onChangeText: (text: string) => void;
14
+ formatOptions: FormatOptions;
15
+ };
16
+
17
+ type UseAmountInputFormattingReturn = {
18
+ formattedValue: string;
19
+ handleChangeText: (text: string) => void;
20
+ };
21
+
22
+ export const useAmountInputFormatting = ({
23
+ value,
24
+ onChangeText,
25
+ formatOptions,
26
+ }: UseAmountInputFormattingArgs): UseAmountInputFormattingReturn => {
27
+ const format = useCallback(
28
+ (v: string | number): string => textFormatter(String(v), formatOptions),
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
30
+ [
31
+ formatOptions.allowDecimals,
32
+ formatOptions.thousandsSeparator,
33
+ formatOptions.maxIntegerLength,
34
+ formatOptions.maxDecimalLength,
35
+ ],
36
+ );
37
+
38
+ const formattedValue = useMemo(() => format(value), [value, format]);
39
+
40
+ const handleChangeText = useCallback(
41
+ (text: string): void => {
42
+ onChangeText(format(text));
43
+ },
44
+ [format, onChangeText],
45
+ );
46
+
47
+ return { formattedValue, handleChangeText };
48
+ };
@@ -7,6 +7,10 @@ figma.connect(
7
7
  {
8
8
  imports: ["import { Avatar } from '@ledgerhq/lumen-ui-rnative'"],
9
9
  props: {
10
+ appearance: figma.enum('appearance', {
11
+ gray: 'gray',
12
+ transparent: 'transparent',
13
+ }),
10
14
  size: figma.enum('size', {
11
15
  sm: 'sm',
12
16
  md: 'md',
@@ -21,6 +25,7 @@ figma.connect(
21
25
  example: (props) => (
22
26
  <Avatar
23
27
  src='https://example-image.com'
28
+ appearance={props.appearance}
24
29
  size={props.size}
25
30
  alt="John Doe's Avatar"
26
31
  showNotification={props.showNotification}
@@ -40,6 +40,15 @@ Avatars come in four different sizes:
40
40
 
41
41
  <Canvas of={AvatarStories.SizeShowcase} />
42
42
 
43
+ ### Appearance
44
+
45
+ The `appearance` prop controls the background color of the avatar container.
46
+
47
+ - **`gray`**: Uses a muted gray background
48
+ - **`transparent`**: Uses a semi-transparent muted background — default
49
+
50
+ <Canvas of={AvatarStories.AppearanceShowcase} />
51
+
43
52
  ### States
44
53
 
45
54
  The Avatar component handles two primary states automatically: