@ledgerhq/lumen-ui-rnative 0.1.39 → 0.1.40

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 (47) 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 +9 -4
  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/Avatar/Avatar.figma.js +5 -0
  10. package/dist/module/lib/Components/Avatar/Avatar.figma.js.map +1 -1
  11. package/dist/module/lib/Components/Avatar/Avatar.js +9 -2
  12. package/dist/module/lib/Components/Avatar/Avatar.js.map +1 -1
  13. package/dist/module/lib/Components/Avatar/Avatar.mdx +9 -0
  14. package/dist/module/lib/Components/Avatar/Avatar.stories.js +47 -0
  15. package/dist/module/lib/Components/Avatar/Avatar.stories.js.map +1 -1
  16. package/dist/module/lib/Components/Avatar/Avatar.test.js +23 -1
  17. package/dist/module/lib/Components/Avatar/Avatar.test.js.map +1 -1
  18. package/dist/module/lib/Components/Switch/Switch.js +144 -8
  19. package/dist/module/lib/Components/Switch/Switch.js.map +1 -1
  20. package/dist/typescript/src/lib/Components/AmountDisplay/AmountDisplay.d.ts.map +1 -1
  21. package/dist/typescript/src/lib/Components/AmountInput/AmountInput.d.ts.map +1 -1
  22. package/dist/typescript/src/lib/Components/Avatar/Avatar.d.ts +1 -1
  23. package/dist/typescript/src/lib/Components/Avatar/Avatar.d.ts.map +1 -1
  24. package/dist/typescript/src/lib/Components/Avatar/types.d.ts +6 -0
  25. package/dist/typescript/src/lib/Components/Avatar/types.d.ts.map +1 -1
  26. package/dist/typescript/src/lib/Components/Switch/Switch.d.ts +1 -1
  27. package/dist/typescript/src/lib/Components/Switch/Switch.d.ts.map +1 -1
  28. package/dist/typescript/src/lib/Components/Switch/types.d.ts +2 -1
  29. package/dist/typescript/src/lib/Components/Switch/types.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/lib/Components/AmountDisplay/AmountDisplay.test.tsx +92 -0
  32. package/src/lib/Components/AmountDisplay/AmountDisplay.tsx +37 -15
  33. package/src/lib/Components/AmountInput/AmountInput.test.tsx +166 -0
  34. package/src/lib/Components/AmountInput/AmountInput.tsx +6 -1
  35. package/src/lib/Components/Avatar/Avatar.figma.tsx +5 -0
  36. package/src/lib/Components/Avatar/Avatar.mdx +9 -0
  37. package/src/lib/Components/Avatar/Avatar.stories.tsx +41 -0
  38. package/src/lib/Components/Avatar/Avatar.test.tsx +31 -1
  39. package/src/lib/Components/Avatar/Avatar.tsx +17 -4
  40. package/src/lib/Components/Avatar/types.ts +6 -0
  41. package/src/lib/Components/Switch/Switch.tsx +132 -11
  42. package/src/lib/Components/Switch/types.ts +3 -1
  43. package/dist/module/lib/Components/Switch/BaseSwitch.js +0 -221
  44. package/dist/module/lib/Components/Switch/BaseSwitch.js.map +0 -1
  45. package/dist/typescript/src/lib/Components/Switch/BaseSwitch.d.ts +0 -13
  46. package/dist/typescript/src/lib/Components/Switch/BaseSwitch.d.ts.map +0 -1
  47. 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
+ });
@@ -46,7 +46,12 @@ export const AmountInput = ({
46
46
  ...props
47
47
  }: AmountInputProps) => {
48
48
  const inputRef = useRef<TextInput>(null);
49
- const inputValue = String(value);
49
+ const inputValue = textFormatter(String(value), {
50
+ allowDecimals,
51
+ thousandsSeparator,
52
+ maxIntegerLength,
53
+ maxDecimalLength,
54
+ });
50
55
  const [isFocused, setIsFocused] = useState(false);
51
56
  const disabled = useDisabledContext({
52
57
  consumerName: 'AmountInput',
@@ -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:
@@ -90,6 +90,47 @@ export const SizeShowcase: Story = {
90
90
  ),
91
91
  };
92
92
 
93
+ const AppearanceShowcaseRender = () => {
94
+ return (
95
+ <Box lx={{ gap: 's8' }}>
96
+ <Box lx={{ flexDirection: 'row', gap: 's16', padding: 's8' }}>
97
+ <Avatar
98
+ alt='gray fallback'
99
+ size='md'
100
+ appearance='gray'
101
+ showNotification={false}
102
+ />
103
+ <Avatar
104
+ alt='transparent fallback'
105
+ size='md'
106
+ appearance='transparent'
107
+ showNotification={false}
108
+ />
109
+ </Box>
110
+ <Box lx={{ flexDirection: 'row', gap: 's16', padding: 's8' }}>
111
+ <Avatar
112
+ src={exampleSrc}
113
+ alt='gray with image'
114
+ size='md'
115
+ appearance='gray'
116
+ showNotification={false}
117
+ />
118
+ <Avatar
119
+ src={exampleSrc}
120
+ alt='transparent with image'
121
+ size='md'
122
+ appearance='transparent'
123
+ showNotification={false}
124
+ />
125
+ </Box>
126
+ </Box>
127
+ );
128
+ };
129
+
130
+ export const AppearanceShowcase: Story = {
131
+ render: () => <AppearanceShowcaseRender />,
132
+ };
133
+
93
134
  export const FallbackShowcase: Story = {
94
135
  args: {
95
136
  src: 'https://brokenLink.random',
@@ -4,7 +4,7 @@ import { render, waitFor } from '@testing-library/react-native';
4
4
  import { ThemeProvider } from '../ThemeProvider/ThemeProvider';
5
5
  import { Avatar } from './Avatar';
6
6
 
7
- const { sizes } = ledgerLiveThemes.dark;
7
+ const { sizes, colors } = ledgerLiveThemes.dark;
8
8
 
9
9
  const TestWrapper = ({ children }: { children: React.ReactNode }) => (
10
10
  <ThemeProvider themes={ledgerLiveThemes} colorScheme='dark' locale='en'>
@@ -33,6 +33,36 @@ describe('Avatar Component', () => {
33
33
  getByLabelText('user profile');
34
34
  });
35
35
 
36
+ it('should render with transparent appearance by default', () => {
37
+ const { getByTestId } = render(
38
+ <TestWrapper>
39
+ <Avatar testID='avatar-id' />
40
+ </TestWrapper>,
41
+ );
42
+
43
+ expect(getByTestId('avatar-id').props.style.backgroundColor).toBe(
44
+ colors.bg.mutedTransparent,
45
+ );
46
+ });
47
+
48
+ it.each<['transparent' | 'gray', string]>([
49
+ ['transparent', colors.bg.mutedTransparent],
50
+ ['gray', colors.bg.muted],
51
+ ])(
52
+ 'should render with %s appearance when specified',
53
+ (appearance, expectedColor) => {
54
+ const { getByTestId } = render(
55
+ <TestWrapper>
56
+ <Avatar testID='avatar-id' appearance={appearance} />
57
+ </TestWrapper>,
58
+ );
59
+
60
+ expect(getByTestId('avatar-id').props.style.backgroundColor).toBe(
61
+ expectedColor,
62
+ );
63
+ },
64
+ );
65
+
36
66
  it('should render with different sizes', () => {
37
67
  const { getByTestId, rerender } = render(
38
68
  <TestWrapper>
@@ -7,6 +7,7 @@ import { DotIndicator } from '../DotIndicator';
7
7
  import { Box } from '../Utility';
8
8
  import type { AvatarProps } from './types';
9
9
 
10
+ type Appearance = NonNullable<AvatarProps['appearance']>;
10
11
  type Size = NonNullable<AvatarProps['size']>;
11
12
 
12
13
  const fallbackSizes = {
@@ -23,9 +24,20 @@ const dotSizeMap: Partial<
23
24
  md: 'xl',
24
25
  };
25
26
 
26
- const useStyles = ({ size }: { size: Size }) => {
27
+ const useStyles = ({
28
+ appearance,
29
+ size,
30
+ }: {
31
+ appearance: Appearance;
32
+ size: Size;
33
+ }) => {
27
34
  return useStyleSheet(
28
35
  (t) => {
36
+ const backgroundColors: Record<Appearance, string> = {
37
+ gray: t.colors.bg.muted,
38
+ transparent: t.colors.bg.mutedTransparent,
39
+ };
40
+
29
41
  const sizeMap = {
30
42
  sm: { size: t.sizes.s40, padding: t.spacings.s4 },
31
43
  md: { size: t.sizes.s48, padding: t.spacings.s4 },
@@ -39,7 +51,7 @@ const useStyles = ({ size }: { size: Size }) => {
39
51
  width: sizeMap[size].size,
40
52
  height: sizeMap[size].size,
41
53
  borderRadius: 9999,
42
- backgroundColor: t.colors.bg.muted,
54
+ backgroundColor: backgroundColors[appearance],
43
55
  alignItems: 'center',
44
56
  justifyContent: 'center',
45
57
  padding: sizeMap[size].padding,
@@ -52,7 +64,7 @@ const useStyles = ({ size }: { size: Size }) => {
52
64
  },
53
65
  };
54
66
  },
55
- [size],
67
+ [appearance, size],
56
68
  );
57
69
  };
58
70
 
@@ -77,6 +89,7 @@ export const Avatar = ({
77
89
  style,
78
90
  src,
79
91
  alt = 'avatar',
92
+ appearance = 'transparent',
80
93
  size = 'md',
81
94
  showNotification: showNotificationProp = false,
82
95
  testID,
@@ -86,7 +99,7 @@ export const Avatar = ({
86
99
  const { t } = useCommonTranslation();
87
100
  const [error, setError] = useState<boolean>(false);
88
101
  const shouldFallback = !src || error;
89
- const styles = useStyles({ size });
102
+ const styles = useStyles({ appearance, size });
90
103
 
91
104
  const resolvedAlt = alt || t('components.avatar.defaultAlt');
92
105
 
@@ -11,6 +11,12 @@ export type AvatarProps = {
11
11
  * @optional
12
12
  */
13
13
  alt?: string;
14
+ /**
15
+ * The visual appearance of the avatar background: `gray` or `transparent`.
16
+ * @optional
17
+ * @default transparent
18
+ */
19
+ appearance?: 'gray' | 'transparent';
14
20
  /**
15
21
  * The size variant of the avatar.
16
22
  * @optional
@@ -1,9 +1,107 @@
1
1
  import { useDisabledContext } from '@ledgerhq/lumen-utils-shared';
2
+ import { useCallback } from 'react';
3
+ import { View, type GestureResponderEvent, StyleSheet } from 'react-native';
4
+ import Animated, {
5
+ useAnimatedStyle,
6
+ withTiming,
7
+ } from 'react-native-reanimated';
8
+ import { useStyleSheet, useTheme } from '../../../styles';
9
+ import { useTimingConfig } from '../../Animations/useTimingConfig';
2
10
  import { useControllableState } from '../../utils';
3
11
  import { Pressable } from '../Utility';
4
- import { BaseSwitchThumb, BaseSwitchRoot } from './BaseSwitch';
5
12
 
6
- import type { SwitchProps } from './types';
13
+ import type { SwitchProps, SwitchSize } from './types';
14
+
15
+ const useSwitchStyles = ({
16
+ checked,
17
+ disabled,
18
+ size,
19
+ }: {
20
+ checked: boolean;
21
+ disabled: boolean;
22
+ size: SwitchSize;
23
+ }) => {
24
+ const { theme } = useTheme();
25
+
26
+ const styles = useStyleSheet(
27
+ (t) => {
28
+ const sizes: Record<SwitchSize, { width: number; height: number }> = {
29
+ sm: { width: t.sizes.s24, height: t.sizes.s16 },
30
+ md: { width: t.sizes.s40, height: t.sizes.s24 },
31
+ };
32
+
33
+ const thumbSizes: Record<SwitchSize, number> = {
34
+ sm: t.sizes.s12,
35
+ md: t.sizes.s20,
36
+ };
37
+
38
+ return {
39
+ root: StyleSheet.flatten([
40
+ {
41
+ borderRadius: t.borderRadius.full,
42
+ padding: t.spacings.s2,
43
+ overflow: 'hidden',
44
+ ...sizes[size],
45
+ minWidth: sizes[size].width,
46
+ maxWidth: sizes[size].width,
47
+ minHeight: sizes[size].height,
48
+ maxHeight: sizes[size].height,
49
+ },
50
+ !checked &&
51
+ !disabled && {
52
+ backgroundColor: t.colors.bg.mutedStrong,
53
+ },
54
+ checked &&
55
+ !disabled && {
56
+ backgroundColor: t.colors.bg.active,
57
+ },
58
+ disabled && {
59
+ backgroundColor: t.colors.bg.disabled,
60
+ },
61
+ ]),
62
+ thumbWrapper: {
63
+ position: 'absolute',
64
+ top: t.spacings.s2,
65
+ left: t.spacings.s2,
66
+ },
67
+ thumb: StyleSheet.flatten([
68
+ {
69
+ borderRadius: t.borderRadius.full,
70
+ backgroundColor: 'white',
71
+ width: thumbSizes[size],
72
+ height: thumbSizes[size],
73
+ },
74
+ disabled && {
75
+ backgroundColor: t.colors.bg.base,
76
+ },
77
+ ]),
78
+ };
79
+ },
80
+ [checked, disabled, size],
81
+ );
82
+
83
+ const thumbTranslations: Record<SwitchSize, number> = {
84
+ sm: theme.spacings.s8,
85
+ md: theme.spacings.s16,
86
+ };
87
+
88
+ return { ...styles, thumbTranslate: thumbTranslations[size] };
89
+ };
90
+
91
+ const useAnimatedThumb = (checked: boolean, translate: number) => {
92
+ const timing = useTimingConfig({ duration: 200, easing: 'easeInOut' });
93
+
94
+ // Reanimated best practice: derive the animation reactively by calling
95
+ // `withTiming` inside `useAnimatedStyle`. When `checked` (or `translate`)
96
+ // changes the worklet re-runs and animates from the current value to the
97
+ // new target -- no shared value or effect required.
98
+ return useAnimatedStyle(
99
+ () => ({
100
+ transform: [{ translateX: withTiming(checked ? translate : 0, timing) }],
101
+ }),
102
+ [checked, translate, timing],
103
+ );
104
+ };
7
105
 
8
106
  /**
9
107
  * The switch follows the design system tokens and supports checked, unchecked,
@@ -36,6 +134,7 @@ export const Switch = ({
36
134
  defaultChecked = false,
37
135
  disabled: disabledProp,
38
136
  size = 'md',
137
+ onPress: onPressProp,
39
138
  ref,
40
139
  ...props
41
140
  }: SwitchProps) => {
@@ -49,16 +148,38 @@ export const Switch = ({
49
148
  defaultProp: defaultChecked,
50
149
  });
51
150
 
151
+ const styles = useSwitchStyles({
152
+ checked: !!checked,
153
+ disabled: !!disabled,
154
+ size,
155
+ });
156
+ const animatedStyle = useAnimatedThumb(!!checked, styles.thumbTranslate);
157
+
158
+ const onPress = useCallback(
159
+ (ev: GestureResponderEvent) => {
160
+ if (disabled) return;
161
+ onCheckedChange(!checked);
162
+ onPressProp?.(ev);
163
+ },
164
+ [disabled, checked, onCheckedChange, onPressProp],
165
+ );
166
+
52
167
  return (
53
- <Pressable ref={ref} lx={lx} style={style} {...props}>
54
- <BaseSwitchRoot
55
- disabled={disabled}
56
- checked={checked}
57
- onCheckedChange={onCheckedChange}
58
- size={size}
59
- >
60
- <BaseSwitchThumb />
61
- </BaseSwitchRoot>
168
+ <Pressable
169
+ ref={ref}
170
+ lx={lx}
171
+ role='switch'
172
+ aria-checked={checked}
173
+ aria-disabled={disabled}
174
+ accessibilityState={{ checked, disabled }}
175
+ disabled={disabled}
176
+ onPress={onPress}
177
+ style={[styles.root, style]}
178
+ {...props}
179
+ >
180
+ <Animated.View style={[styles.thumbWrapper, animatedStyle]}>
181
+ <View style={styles.thumb} />
182
+ </Animated.View>
62
183
  </Pressable>
63
184
  );
64
185
  };
@@ -1,5 +1,7 @@
1
1
  import type { StyledPressableProps } from '../../../styles';
2
2
 
3
+ export type SwitchSize = 'sm' | 'md';
4
+
3
5
  export type SwitchProps = {
4
6
  /**
5
7
  * The disabled state of the switch.
@@ -15,7 +17,7 @@ export type SwitchProps = {
15
17
  * The size of the switch.
16
18
  * @default 'md'
17
19
  */
18
- size?: 'sm' | 'md';
20
+ size?: SwitchSize;
19
21
  /**
20
22
  * The callback function called when the checked state changes.
21
23
  */