@utilitywarehouse/hearth-react-native 0.27.0 → 0.27.2-test

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 (39) hide show
  1. package/.storybook/vitest.setup.ts +35 -3
  2. package/.turbo/turbo-build.log +5 -4
  3. package/CHANGELOG.md +42 -0
  4. package/build/components/Carousel/Carousel.js +6 -1
  5. package/build/components/List/List.js +2 -2
  6. package/build/components/Modal/Modal.js +1 -1
  7. package/build/components/SegmentedControl/SegmentedControl.js +4 -1
  8. package/build/components/SegmentedControl/SegmentedControlOption.js +24 -2
  9. package/build/components/SegmentedControl/SegmentedControlOption.props.d.ts +3 -1
  10. package/build/components/TimePicker/TimePickerWheel.js +9 -1
  11. package/build/components/Toast/Toast.context.js +1 -1
  12. package/build/components/VerificationInput/VerificationInput.js +10 -21
  13. package/build/components/VerificationInput/VerificationInput.utils.d.ts +8 -0
  14. package/build/components/VerificationInput/VerificationInput.utils.js +17 -0
  15. package/build/components/VerificationInput/VerificationInput.utils.test.d.ts +1 -0
  16. package/build/components/VerificationInput/VerificationInput.utils.test.js +36 -0
  17. package/docs/changelog.mdx +113 -0
  18. package/package.json +6 -5
  19. package/src/components/Carousel/Carousel.tsx +6 -2
  20. package/src/components/IconContainer/IconContainer.stories.tsx +35 -30
  21. package/src/components/List/List.tsx +5 -4
  22. package/src/components/Modal/Modal.tsx +1 -1
  23. package/src/components/SegmentedControl/SegmentedControl.docs.mdx +42 -15
  24. package/src/components/SegmentedControl/SegmentedControl.figma.tsx +8 -9
  25. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +21 -0
  26. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -1
  27. package/src/components/SegmentedControl/SegmentedControlOption.props.ts +3 -1
  28. package/src/components/SegmentedControl/SegmentedControlOption.tsx +58 -34
  29. package/src/components/Select/SelectOption.figma.tsx +2 -2
  30. package/src/components/TimePicker/TimePickerWheel.tsx +11 -4
  31. package/src/components/Toast/Toast.context.tsx +1 -1
  32. package/src/components/VerificationInput/VerificationInput.stories.tsx +33 -0
  33. package/src/components/VerificationInput/VerificationInput.tsx +16 -29
  34. package/src/components/VerificationInput/VerificationInput.utils.test.ts +48 -0
  35. package/src/components/VerificationInput/VerificationInput.utils.ts +32 -0
  36. package/tsconfig.eslint.json +2 -1
  37. package/vitest.config.js +11 -13
  38. package/vitest.unit.config.ts +9 -0
  39. package/.turbo/turbo-lint.log +0 -72
@@ -1,5 +1,6 @@
1
- import { Meta, StoryObj } from '@storybook/react-vite';
1
+ import { Meta, StoryObj } from '@storybook/react-native';
2
2
  import * as Icons from '@utilitywarehouse/hearth-react-native-icons';
3
+ import { ComponentType } from 'react';
3
4
  import { IconContainer } from '.';
4
5
  import { VariantTitle } from '../../../docs/components';
5
6
  import { Box } from '../Box';
@@ -53,7 +54,9 @@ export default meta;
53
54
 
54
55
  type Story = StoryObj<typeof meta>;
55
56
 
56
- export const Playground: Story = { render: args => <IconContainer {...args} /> };
57
+ export const Playground: Story = {
58
+ render: (args: typeof meta.args) => <IconContainer {...args} />,
59
+ };
57
60
 
58
61
  export const Subtle: Story = {
59
62
  parameters: {
@@ -86,7 +89,7 @@ export const KitchenSink: Story = {
86
89
  parameters: {
87
90
  controls: { exclude: ['radiusNone', 'variant', 'color', 'size'] },
88
91
  },
89
- render: ({ icon }) => {
92
+ render: ({ icon }: { icon: ComponentType }) => {
90
93
  const sizes: Array<'sm' | 'md' | 'lg'> = ['sm', 'md', 'lg'];
91
94
  const colors: Array<
92
95
  'pig' | 'energy' | 'broadband' | 'mobile' | 'insurance' | 'cashback' | 'highlight'
@@ -95,33 +98,35 @@ export const KitchenSink: Story = {
95
98
  <Flex direction="column" spacing="lg">
96
99
  {sizes.map(size => (
97
100
  <Box key={size} gap="300">
98
- <VariantTitle title={`Size: ${size.toUpperCase()} / Subtle`}> </VariantTitle>
99
- <Flex direction="row" wrap="wrap" spacing="md">
100
- {colors.map(color => (
101
- <IconContainer
102
- key={`${size}-subtle-${color}`}
103
- icon={icon}
104
- size={size}
105
- variant="subtle"
106
- color={color}
107
- />
108
- ))}
109
- </Flex>
110
- <VariantTitle title={`Size: ${size.toUpperCase()} / Emphasis`}> </VariantTitle>
111
- <Flex direction="row" wrap="wrap" spacing="md">
112
- {colors.map(
113
- color =>
114
- color !== 'highlight' && (
115
- <IconContainer
116
- key={`${size}-emphasis-${color}`}
117
- icon={icon}
118
- size={size}
119
- variant="emphasis"
120
- color={color}
121
- />
122
- )
123
- )}
124
- </Flex>
101
+ <VariantTitle title={`Size: ${size.toUpperCase()} / Subtle`}>
102
+ <Flex direction="row" wrap="wrap" spacing="md">
103
+ {colors.map(color => (
104
+ <IconContainer
105
+ key={`${size}-subtle-${color}`}
106
+ icon={icon}
107
+ size={size}
108
+ variant="subtle"
109
+ color={color}
110
+ />
111
+ ))}
112
+ </Flex>
113
+ </VariantTitle>
114
+ <VariantTitle title={`Size: ${size.toUpperCase()} / Emphasis`}>
115
+ <Flex direction="row" wrap="wrap" spacing="md">
116
+ {colors.map(
117
+ color =>
118
+ color !== 'highlight' && (
119
+ <IconContainer
120
+ key={`${size}-emphasis-${color}`}
121
+ icon={icon}
122
+ size={size}
123
+ variant="emphasis"
124
+ color={color}
125
+ />
126
+ )
127
+ )}
128
+ </Flex>
129
+ </VariantTitle>
125
130
  </Box>
126
131
  ))}
127
132
  </Flex>
@@ -14,7 +14,8 @@ const List = ({
14
14
  invalidText,
15
15
  ...props
16
16
  }: ListProps) => {
17
- const { loading, disabled, container = 'none' } = props;
17
+ const { loading, disabled, container = 'none', testID, style, ...rest } = props;
18
+
18
19
  const orderRef = useRef<string[]>([]);
19
20
  const [firstItemId, setFirstItemId] = useState<string | undefined>(undefined);
20
21
  const containerToCard: {
@@ -51,7 +52,7 @@ const List = ({
51
52
  styles.useVariants({ disabled });
52
53
  return (
53
54
  <ListContext.Provider value={value}>
54
- <View {...props} style={[styles.container, props.style]}>
55
+ <View {...rest} style={[styles.container, style]}>
55
56
  {heading ? (
56
57
  <SectionHeader
57
58
  heading={heading}
@@ -61,10 +62,10 @@ const List = ({
61
62
  />
62
63
  ) : null}
63
64
  {container === 'none' ? (
64
- <View>{children}</View>
65
+ <View testID={testID}>{children}</View>
65
66
  ) : (
66
67
  React.Children.count(children) > 0 && (
67
- <Card {...containerToCard} noPadding style={styles.card}>
68
+ <Card {...containerToCard} noPadding style={styles.card} testID={testID}>
68
69
  <>{children}</>
69
70
  </Card>
70
71
  )
@@ -292,7 +292,7 @@ const Modal = ({
292
292
  </View>
293
293
  ) : null}
294
294
  {inNavModal && (
295
- <InNavModalContainer style={{ flexGrow: stickyFooter ? 1 : 0 }}>
295
+ <InNavModalContainer style={{ flex: stickyFooter ? 1 : 0 }}>
296
296
  {children}
297
297
  {!stickyFooter ? <View style={styles.inNavModalFooterContainer}>{footer}</View> : null}
298
298
  </InNavModalContainer>
@@ -16,8 +16,10 @@ Each option is presented as an equal-priority segment in a single horizontal gro
16
16
 
17
17
  - [Playground](#playground)
18
18
  - [Usage](#usage)
19
- - [Sizes](#sizes)
20
19
  - [Props](#props)
20
+ - [Examples](#examples)
21
+ - [Sizes](#sizes)
22
+ - [Icons](#icons)
21
23
  - [Accessibility](#accessibility)
22
24
 
23
25
  ## Playground
@@ -50,15 +52,6 @@ const Example = () => {
50
52
  };
51
53
  ```
52
54
 
53
- ## Sizes
54
-
55
- Figma defines two size variants for Segmented Control:
56
-
57
- - `sm` maps to `SM-32`
58
- - `md` maps to `MD-48`
59
-
60
- <Canvas of={Stories.Sizes} />
61
-
62
55
  ## Props
63
56
 
64
57
  ### SegmentedControl
@@ -74,11 +67,45 @@ Figma defines two size variants for Segmented Control:
74
67
 
75
68
  ### SegmentedControlOption
76
69
 
77
- | Property | Type | Description | Default |
78
- | ---------- | ----------- | ------------------------------ | -------- |
79
- | `value` | `string` | Unique value for this segment. | required |
80
- | `children` | `ReactNode` | Option label/content. | required |
81
- | `disabled` | `boolean` | Disables only this option. | `false` |
70
+ | Property | Type | Description | Default |
71
+ | ---------- | --------------- | ------------------------------ | -------- |
72
+ | `value` | `string` | Unique value for this segment. | required |
73
+ | `children` | `ReactNode` | Option label/content. | required |
74
+ | `icon` | `ComponentType` | Optional leading icon. | - |
75
+ | `disabled` | `boolean` | Disables only this option. | `false` |
76
+
77
+ ## Examples
78
+
79
+ ### Sizes
80
+
81
+ Figma defines two size variants for Segmented Control:
82
+
83
+ - `sm` maps to `SM-32`
84
+ - `md` maps to `MD-48`
85
+
86
+ <Canvas of={Stories.Sizes} />
87
+
88
+ ```tsx
89
+ <SegmentedControl size="sm">...</SegmentedControl>
90
+ <SegmentedControl size="md">...</SegmentedControl>
91
+ ```
92
+
93
+ ### Icons
94
+
95
+ SegmentedControlOption supports an optional leading icon.
96
+
97
+ <Canvas of={Stories.WithIcons} />
98
+
99
+ ```tsx
100
+ import { MobileSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
101
+
102
+ <SegmentedControl>
103
+ <SegmentedControlOption value="mobile" icon={MobileSmallIcon}>
104
+ Mobile
105
+ </SegmentedControlOption>
106
+ ...
107
+ </SegmentedControl>;
108
+ ```
82
109
 
83
110
  ## Accessibility
84
111
 
@@ -3,20 +3,17 @@ import { SegmentedControl, SegmentedControlOption } from '../';
3
3
 
4
4
  figma.connect(
5
5
  SegmentedControl,
6
- 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=6185-1021&t=c7xg5X0N2EL0t87h-4',
6
+ 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=6185-1021&m=dev',
7
7
  {
8
8
  props: {
9
9
  size: figma.enum('Size', {
10
10
  'SM-32': 'sm',
11
11
  'MD-48': 'md',
12
12
  }),
13
- disabled: figma.enum('State', {
14
- Disabled: true,
15
- }),
16
13
  options: figma.children('Option'),
17
14
  },
18
15
  example: props => (
19
- <SegmentedControl defaultValue="option-1" size={props.size} disabled={props.disabled}>
16
+ <SegmentedControl defaultValue="option-1" size={props.size}>
20
17
  {props.options}
21
18
  </SegmentedControl>
22
19
  ),
@@ -25,15 +22,17 @@ figma.connect(
25
22
 
26
23
  figma.connect(
27
24
  SegmentedControlOption,
28
- 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=4340-1252&t=c7xg5X0N2EL0t87h-4',
25
+ 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=4340-1252&m=dev',
29
26
  {
30
27
  props: {
31
28
  label: figma.string('Label'),
32
- value: figma.string('Value'),
29
+ icon: figma.boolean('Icon?', {
30
+ true: figma.instance('Icon-20'),
31
+ }),
33
32
  },
34
33
  example: props => (
35
- <SegmentedControlOption value={props.value ?? 'option'}>
36
- {props.label ?? 'Option'}
34
+ <SegmentedControlOption value={'option'} icon={props.icon}>
35
+ {props.label}
37
36
  </SegmentedControlOption>
38
37
  ),
39
38
  }
@@ -1,3 +1,8 @@
1
+ import {
2
+ BroadbandSmallIcon,
3
+ ElectricitySmallIcon,
4
+ MobileSmallIcon,
5
+ } from '@utilitywarehouse/hearth-react-native-icons';
1
6
  import { useState } from 'react';
2
7
  import { BodyText, Flex, SegmentedControl, SegmentedControlOption } from '../';
3
8
 
@@ -75,3 +80,19 @@ export const Disabled = {
75
80
  </SegmentedControl>
76
81
  ),
77
82
  };
83
+
84
+ export const WithIcons = {
85
+ render: () => (
86
+ <SegmentedControl defaultValue="energy" size="md">
87
+ <SegmentedControlOption value="energy" icon={ElectricitySmallIcon}>
88
+ Energy
89
+ </SegmentedControlOption>
90
+ <SegmentedControlOption value="broadband" icon={BroadbandSmallIcon}>
91
+ Broadband
92
+ </SegmentedControlOption>
93
+ <SegmentedControlOption value="mobile" icon={MobileSmallIcon}>
94
+ Mobile
95
+ </SegmentedControlOption>
96
+ </SegmentedControl>
97
+ ),
98
+ };
@@ -206,7 +206,7 @@ const SegmentedControl = ({
206
206
  {...remainingProps}
207
207
  >
208
208
  {hasIndicator ? (
209
- <Indicator pointerEvents="none" style={[styles.indicator, indicatorStyle]} />
209
+ <Indicator style={[styles.indicator, styles.pointerEventsNone, indicatorStyle]} />
210
210
  ) : null}
211
211
  {children}
212
212
  </View>
@@ -252,6 +252,9 @@ const styles = StyleSheet.create(theme => ({
252
252
  borderRadius: theme.components.segmentedControl.borderRadius,
253
253
  backgroundColor: theme.color.interactive.brand.surface.strong.default,
254
254
  },
255
+ pointerEventsNone: {
256
+ pointerEvents: 'none',
257
+ },
255
258
  }));
256
259
 
257
260
  export default SegmentedControl;
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ComponentType, ReactNode } from 'react';
2
2
  import type { PressableProps, ViewProps } from 'react-native';
3
3
 
4
4
  export interface SegmentedControlOptionProps extends Omit<PressableProps, 'children'> {
@@ -6,6 +6,8 @@ export interface SegmentedControlOptionProps extends Omit<PressableProps, 'child
6
6
  value: string;
7
7
  /** Option label/content. */
8
8
  children: ReactNode;
9
+ /** Optional leading icon. */
10
+ icon?: ComponentType<any>;
9
11
  /** Disables only this option. */
10
12
  disabled?: boolean;
11
13
  style?: ViewProps['style'];
@@ -10,6 +10,7 @@ import Animated, {
10
10
  } from 'react-native-reanimated';
11
11
  import { StyleSheet } from 'react-native-unistyles';
12
12
  import { BodyText } from '../BodyText';
13
+ import { Icon } from '../Icon';
13
14
  import { useSegmentedControlContext } from './SegmentedControl.context';
14
15
  import type SegmentedControlOptionProps from './SegmentedControlOption.props';
15
16
 
@@ -18,6 +19,7 @@ const AnimatedView = Animated.createAnimatedComponent(View);
18
19
  const SegmentedControlOptionRoot = ({
19
20
  value,
20
21
  children,
22
+ icon,
21
23
  accessibilityLabel,
22
24
  disabled = false,
23
25
  style,
@@ -79,47 +81,48 @@ const SegmentedControlOptionRoot = ({
79
81
  : null)}
80
82
  >
81
83
  <View
82
- style={styles.labelWrap}
84
+ style={styles.contentWrap}
83
85
  accessible={false}
84
86
  accessibilityElementsHidden
85
87
  importantForAccessibility="no-hide-descendants"
86
88
  {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
87
89
  >
88
- <BodyText
89
- size="md"
90
- weight="semibold"
91
- style={styles.labelSizer}
92
- accessible={false}
93
- accessibilityElementsHidden
94
- importantForAccessibility="no-hide-descendants"
95
- {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
96
- >
97
- {children}
98
- </BodyText>
99
- <AnimatedView
100
- pointerEvents="none"
101
- style={[styles.textLayer, regularLabelStyle]}
102
- accessible={false}
103
- accessibilityElementsHidden
104
- importantForAccessibility="no-hide-descendants"
105
- {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
106
- >
107
- <BodyText size="md" weight="regular" style={styles.textRegular}>
90
+ {icon ? <Icon as={icon} size="sm" style={styles.icon} /> : null}
91
+ <View style={styles.labelWrap}>
92
+ <BodyText
93
+ size="md"
94
+ weight="semibold"
95
+ style={styles.labelSizer}
96
+ accessible={false}
97
+ accessibilityElementsHidden
98
+ importantForAccessibility="no-hide-descendants"
99
+ {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
100
+ >
108
101
  {children}
109
102
  </BodyText>
110
- </AnimatedView>
111
- <AnimatedView
112
- pointerEvents="none"
113
- style={[styles.textLayer, selectedLabelStyle]}
114
- accessible={false}
115
- accessibilityElementsHidden
116
- importantForAccessibility="no-hide-descendants"
117
- {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
118
- >
119
- <BodyText size="md" weight="semibold" style={styles.textSelected}>
120
- {children}
121
- </BodyText>
122
- </AnimatedView>
103
+ <AnimatedView
104
+ style={[styles.textLayer, styles.pointerEventsNone, regularLabelStyle]}
105
+ accessible={false}
106
+ accessibilityElementsHidden
107
+ importantForAccessibility="no-hide-descendants"
108
+ {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
109
+ >
110
+ <BodyText size="md" weight="regular" style={styles.textRegular}>
111
+ {children}
112
+ </BodyText>
113
+ </AnimatedView>
114
+ <AnimatedView
115
+ style={[styles.textLayer, styles.pointerEventsNone, selectedLabelStyle]}
116
+ accessible={false}
117
+ accessibilityElementsHidden
118
+ importantForAccessibility="no-hide-descendants"
119
+ {...(Platform.OS === 'web' ? ({ 'aria-hidden': true } as any) : null)}
120
+ >
121
+ <BodyText size="md" weight="semibold" style={styles.textSelected}>
122
+ {children}
123
+ </BodyText>
124
+ </AnimatedView>
125
+ </View>
123
126
  </View>
124
127
  </Pressable>
125
128
  );
@@ -180,6 +183,12 @@ const styles = StyleSheet.create(theme => ({
180
183
  },
181
184
  },
182
185
  },
186
+ contentWrap: {
187
+ flexDirection: 'row',
188
+ alignItems: 'center',
189
+ justifyContent: 'center',
190
+ gap: theme.components.segmentedControl.gap,
191
+ },
183
192
  labelWrap: {
184
193
  position: 'relative',
185
194
  alignItems: 'center',
@@ -195,6 +204,21 @@ const styles = StyleSheet.create(theme => ({
195
204
  alignItems: 'center',
196
205
  justifyContent: 'center',
197
206
  },
207
+ pointerEventsNone: {
208
+ pointerEvents: 'none',
209
+ },
210
+ icon: {
211
+ variants: {
212
+ selected: {
213
+ true: {
214
+ color: theme.color.icon.inverted,
215
+ },
216
+ false: {
217
+ color: theme.color.icon.primary,
218
+ },
219
+ },
220
+ },
221
+ },
198
222
  textRegular: {
199
223
  color: theme.color.text.primary,
200
224
  },
@@ -3,10 +3,10 @@ import { SelectOption } from '../';
3
3
 
4
4
  figma.connect(
5
5
  SelectOption,
6
- 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR?node-id=4340%3A1252',
6
+ 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=3394-3663&m=dev',
7
7
  {
8
8
  props: {
9
- label: figma.string('Label'),
9
+ label: figma.string('Text'),
10
10
  disabled: figma.enum('State', {
11
11
  Disabled: true,
12
12
  }),
@@ -46,8 +46,8 @@ const TimePickerWheel = ({ value, setValue = () => {}, items }: TimePickerWheelP
46
46
 
47
47
  const renderOverlay = useCallback(
48
48
  () => (
49
- <View style={[styles.overlayContainer]} pointerEvents="none">
50
- <View pointerEvents="none" style={[styles.fadeOverlay, { height: fadeHeight }]}>
49
+ <View style={[styles.overlayContainer, styles.pointerEventsNone]}>
50
+ <View style={[styles.fadeOverlay, styles.pointerEventsNone, { height: fadeHeight }]}>
51
51
  <Svg width="100%" height="100%" preserveAspectRatio="none">
52
52
  <Defs>
53
53
  <LinearGradient id={`${gradientId}-top`} x1="0" y1="0" x2="0" y2="1">
@@ -59,8 +59,12 @@ const TimePickerWheel = ({ value, setValue = () => {}, items }: TimePickerWheelP
59
59
  </Svg>
60
60
  </View>
61
61
  <View
62
- pointerEvents="none"
63
- style={[styles.fadeOverlay, styles.fadeOverlayBottom, { height: fadeHeight }]}
62
+ style={[
63
+ styles.fadeOverlay,
64
+ styles.fadeOverlayBottom,
65
+ styles.pointerEventsNone,
66
+ { height: fadeHeight },
67
+ ]}
64
68
  >
65
69
  <Svg width="100%" height="100%" preserveAspectRatio="none">
66
70
  <Defs>
@@ -149,6 +153,9 @@ const styles = StyleSheet.create(theme => ({
149
153
  top: undefined,
150
154
  bottom: 0,
151
155
  },
156
+ pointerEventsNone: {
157
+ pointerEvents: 'none',
158
+ },
152
159
  }));
153
160
 
154
161
  export default TimePickerWheel;
@@ -85,7 +85,7 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({
85
85
  return (
86
86
  <ToastContext.Provider value={{ addToast, removeToast }}>
87
87
  {children}
88
- <View pointerEvents="box-none" style={styles.container as any}>
88
+ <View style={styles.container as any}>
89
89
  <View style={styles.stack as any}>
90
90
  {toasts.map(t => (
91
91
  <ToastItem
@@ -1,6 +1,7 @@
1
1
  import { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { InfoMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
3
3
  import { useRef, useState } from 'react';
4
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
4
5
  import { VerificationInput, type VerificationInputHandle } from '.';
5
6
  import { VariantTitle } from '../../../docs/components';
6
7
  import { BodyText } from '../BodyText';
@@ -214,3 +215,35 @@ export const RefMethods: Story = {
214
215
  );
215
216
  },
216
217
  };
218
+
219
+ export const FocusProgressionAfterEmptySlotSelection: Story = {
220
+ parameters: {
221
+ controls: { include: [] },
222
+ },
223
+ render: () => {
224
+ const [value, setValue] = useState('12');
225
+
226
+ return (
227
+ <Flex direction="column" spacing="sm" style={{ width: 400 }}>
228
+ <VerificationInput label="Verification Code" value={value} onChangeText={setValue} />
229
+ </Flex>
230
+ );
231
+ },
232
+ play: async ({ canvasElement }) => {
233
+ const canvas = within(canvasElement);
234
+ const input = canvas.getByLabelText('Verification Code') as HTMLInputElement;
235
+
236
+ input.focus();
237
+
238
+ input.setSelectionRange(4, 4);
239
+ input.dispatchEvent(new Event('select', { bubbles: true }));
240
+
241
+ await userEvent.keyboard('3');
242
+
243
+ await waitFor(() => {
244
+ expect(input.value).toBe('123');
245
+ expect(input.selectionStart).toBe(3);
246
+ expect(input.selectionEnd).toBe(3);
247
+ });
248
+ },
249
+ };
@@ -1,8 +1,9 @@
1
- import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
2
- import { Platform, TextInput, View } from 'react-native';
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
2
+ import { TextInput, View } from 'react-native';
3
3
  import { StyleSheet } from 'react-native-unistyles';
4
4
  import { FormField } from '../FormField';
5
5
  import type { VerificationInputHandle, VerificationInputProps } from './VerificationInput.props';
6
+ import { getNextIndexFromValueChange } from './VerificationInput.utils';
6
7
  import { VerificationInputSlot } from './VerificationInputSlot';
7
8
 
8
9
  const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputProps>(
@@ -49,12 +50,15 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
49
50
  }
50
51
  }, [length, value]);
51
52
 
52
- const updateValue = (nextValue: string) => {
53
- const trimmedValue = nextValue.slice(0, length);
54
- latestValueRef.current = trimmedValue;
55
- setDisplayValue(trimmedValue);
56
- onChangeText?.(trimmedValue);
57
- };
53
+ const updateValue = useCallback(
54
+ (nextValue: string) => {
55
+ const trimmedValue = nextValue.slice(0, length);
56
+ latestValueRef.current = trimmedValue;
57
+ setDisplayValue(trimmedValue);
58
+ onChangeText?.(trimmedValue);
59
+ },
60
+ [length, onChangeText]
61
+ );
58
62
 
59
63
  const setSelectionIndex = (index: number) => {
60
64
  const clampedIndex = Math.max(0, Math.min(index, length));
@@ -76,16 +80,6 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
76
80
  setFocusedIndex(Math.min(clampedIndex, length - 1));
77
81
  };
78
82
 
79
- const findDiffIndex = (prevValue: string, nextValue: string) => {
80
- const minLength = Math.min(prevValue.length, nextValue.length);
81
- for (let i = 0; i < minLength; i += 1) {
82
- if (prevValue[i] !== nextValue[i]) {
83
- return i;
84
- }
85
- }
86
- return minLength;
87
- };
88
-
89
83
  const handleChangeText = (text: string) => {
90
84
  const prevValue = latestValueRef.current;
91
85
  const nextValue = text.slice(0, length);
@@ -94,14 +88,7 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
94
88
  const diff = nextLength - prevLength;
95
89
  const isBulkInsert = text.length > 1 && diff > 1;
96
90
  const shouldBlur = nextLength >= length;
97
- let nextIndex = Math.max(
98
- 0,
99
- Math.min(latestSelectionRef.current.start + (diff >= 0 ? 1 : diff), length)
100
- );
101
- if (Platform.OS === 'android') {
102
- const editedIndex = findDiffIndex(prevValue, nextValue);
103
- nextIndex = diff >= 0 ? Math.min(editedIndex + 1, length) : Math.max(editedIndex, 0);
104
- }
91
+ const nextIndex = getNextIndexFromValueChange({ prevValue, nextValue, length });
105
92
  updateValue(nextValue);
106
93
  if (isBulkInsert) {
107
94
  setCaretIndex(Math.min(nextLength, length));
@@ -171,7 +158,7 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
171
158
  }
172
159
  },
173
160
  }),
174
- [length, onChangeText]
161
+ [length, updateValue]
175
162
  );
176
163
 
177
164
  const slots = Array.from({ length }, (_, index) => index);
@@ -252,7 +239,6 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
252
239
  maxLength={length}
253
240
  caretHidden
254
241
  style={styles.hiddenInput}
255
- pointerEvents="none"
256
242
  />
257
243
  {slots.map(index => {
258
244
  const char = displayValue[index] || '';
@@ -299,11 +285,12 @@ const styles = StyleSheet.create(theme => ({
299
285
  position: 'absolute',
300
286
  width: '100%',
301
287
  height: '100%',
288
+ pointerEvents: 'none',
302
289
  left: 0,
303
290
  top: 0,
304
291
  color: 'transparent',
305
292
  fontSize: 1,
306
- opacity: 0.1,
293
+ opacity: 0.01,
307
294
  },
308
295
  }));
309
296