@utilitywarehouse/hearth-react-native 0.27.1 → 0.27.2-testid-fix-1

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 (60) hide show
  1. package/.storybook/vitest.setup.ts +35 -3
  2. package/.turbo/turbo-build.log +5 -4
  3. package/CHANGELOG.md +15 -0
  4. package/build/components/Button/ButtonRoot.js +8 -0
  5. package/build/components/Carousel/Carousel.js +6 -1
  6. package/build/components/DatePicker/TimePicker.d.ts +3 -0
  7. package/build/components/DatePicker/TimePicker.js +84 -0
  8. package/build/components/DatePicker/time-picker/animated-math.d.ts +4 -0
  9. package/build/components/DatePicker/time-picker/animated-math.js +19 -0
  10. package/build/components/DatePicker/time-picker/period-native.d.ts +6 -0
  11. package/build/components/DatePicker/time-picker/period-native.js +17 -0
  12. package/build/components/DatePicker/time-picker/period-picker.d.ts +6 -0
  13. package/build/components/DatePicker/time-picker/period-picker.js +10 -0
  14. package/build/components/DatePicker/time-picker/period-web.d.ts +6 -0
  15. package/build/components/DatePicker/time-picker/period-web.js +21 -0
  16. package/build/components/DatePicker/time-picker/wheel-native.d.ts +8 -0
  17. package/build/components/DatePicker/time-picker/wheel-native.js +19 -0
  18. package/build/components/DatePicker/time-picker/wheel-picker/index.d.ts +2 -0
  19. package/build/components/DatePicker/time-picker/wheel-picker/index.js +2 -0
  20. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.d.ts +16 -0
  21. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.js +97 -0
  22. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.d.ts +21 -0
  23. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.js +88 -0
  24. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.d.ts +23 -0
  25. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.js +21 -0
  26. package/build/components/DatePicker/time-picker/wheel-web.d.ts +8 -0
  27. package/build/components/DatePicker/time-picker/wheel-web.js +146 -0
  28. package/build/components/DatePicker/time-picker/wheel.d.ts +8 -0
  29. package/build/components/DatePicker/time-picker/wheel.js +10 -0
  30. package/build/components/List/List.js +2 -2
  31. package/build/components/Modal/Modal.js +16 -11
  32. package/build/components/SegmentedControl/SegmentedControl.js +4 -1
  33. package/build/components/SegmentedControl/SegmentedControlOption.js +4 -1
  34. package/build/components/TimePicker/TimePickerWheel.js +9 -1
  35. package/build/components/Toast/Toast.context.js +1 -1
  36. package/build/components/VerificationInput/VerificationInput.js +11 -22
  37. package/build/components/VerificationInput/VerificationInput.utils.d.ts +8 -0
  38. package/build/components/VerificationInput/VerificationInput.utils.js +17 -0
  39. package/build/components/VerificationInput/VerificationInput.utils.test.d.ts +1 -0
  40. package/build/components/VerificationInput/VerificationInput.utils.test.js +36 -0
  41. package/docs/changelog.mdx +113 -0
  42. package/package.json +5 -4
  43. package/src/components/Button/Button.stories.tsx +43 -7
  44. package/src/components/Button/ButtonRoot.tsx +8 -0
  45. package/src/components/Carousel/Carousel.tsx +6 -2
  46. package/src/components/IconContainer/IconContainer.stories.tsx +35 -30
  47. package/src/components/List/List.tsx +5 -4
  48. package/src/components/Modal/Modal.tsx +31 -16
  49. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -1
  50. package/src/components/SegmentedControl/SegmentedControlOption.tsx +5 -4
  51. package/src/components/TimePicker/TimePickerWheel.tsx +11 -4
  52. package/src/components/Toast/Toast.context.tsx +1 -1
  53. package/src/components/VerificationInput/VerificationInput.stories.tsx +33 -0
  54. package/src/components/VerificationInput/VerificationInput.tsx +18 -29
  55. package/src/components/VerificationInput/VerificationInput.utils.test.ts +48 -0
  56. package/src/components/VerificationInput/VerificationInput.utils.ts +32 -0
  57. package/tsconfig.eslint.json +2 -1
  58. package/vitest.config.js +11 -13
  59. package/vitest.unit.config.ts +9 -0
  60. package/.turbo/turbo-lint.log +0 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilitywarehouse/hearth-react-native",
3
- "version": "0.27.1",
3
+ "version": "0.27.2-testid-fix-1",
4
4
  "description": "Utility Warehouse React Native UI library",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -56,10 +56,10 @@
56
56
  "vite": "^7.1.3",
57
57
  "vite-plugin-svgr": "^4.5.0",
58
58
  "vitest": "^3.2.4",
59
- "@utilitywarehouse/hearth-react-icons": "^0.8.0",
60
- "@utilitywarehouse/hearth-fonts": "^0.0.4",
61
59
  "@utilitywarehouse/hearth-react-native-icons": "^0.8.0",
62
- "@utilitywarehouse/hearth-tokens": "^0.2.3",
60
+ "@utilitywarehouse/hearth-tokens": "^0.2.4",
61
+ "@utilitywarehouse/hearth-fonts": "^0.0.4",
62
+ "@utilitywarehouse/hearth-react-icons": "^0.8.0",
63
63
  "@utilitywarehouse/hearth-svg-assets": "^0.5.0"
64
64
  },
65
65
  "peerDependencies": {
@@ -85,6 +85,7 @@
85
85
  "figma:create": "figma connect create",
86
86
  "figma:publish": "figma connect publish",
87
87
  "test": "echo \"Error: no test specified\" && exit 1",
88
+ "test:storybook": "vitest run --project storybook",
88
89
  "dev": "npm run copyChangelog && storybook dev -p 6006",
89
90
  "dev:docs": "storybook dev -p 6002 --no-open --docs",
90
91
  "build:storybook": "npm run copyChangelog && storybook build",
@@ -1,4 +1,4 @@
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
3
  import { AddSmallIcon, ChevronRightSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
4
4
  import { Platform } from 'react-native';
@@ -92,7 +92,7 @@ type Story = StoryObj<typeof meta>;
92
92
 
93
93
  export const Playground: Story = {
94
94
  /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
95
- render: ({ icon: _icon, children: _, ...args }) => {
95
+ render: ({ icon: _icon, children: _, ...args }: StoryObj<typeof meta.args>) => {
96
96
  // @ts-expect-error - This is a playground
97
97
  const icon = _icon === 'none' ? undefined : Icons[_icon];
98
98
  return <Button {...args} icon={icon} />;
@@ -104,7 +104,7 @@ export const Variants: Story = {
104
104
  controls: { exclude: ['variant'] },
105
105
  },
106
106
  /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
107
- render: ({ icon: _icon, children: _, ...args }) => {
107
+ render: ({ icon: _icon, children: _, ...args }: StoryObj<typeof meta.args>) => {
108
108
  // @ts-expect-error - This is a playground
109
109
  const icon = _icon === 'none' ? undefined : Icons[_icon];
110
110
  return (
@@ -123,11 +123,9 @@ export const Variants: Story = {
123
123
  {args.colorScheme !== 'highlight' && (
124
124
  <>
125
125
  <VariantTitle title="Outline" invert={args.inverted}>
126
- {/* @ts-expect-error - story loop types don't match */}
127
126
  <Button {...args} variant="outline" icon={icon} />
128
127
  </VariantTitle>
129
128
  <VariantTitle title="Ghost" invert={args.inverted}>
130
- {/* @ts-expect-error - story loop types don't match */}
131
129
  <Button {...args} variant="ghost" icon={icon} />
132
130
  </VariantTitle>
133
131
  </>
@@ -138,6 +136,44 @@ export const Variants: Story = {
138
136
  },
139
137
  };
140
138
 
139
+ export const PaddingNone: Story = {
140
+ parameters: {
141
+ controls: {
142
+ include: ['text', 'size', 'inverted', 'icon', 'iconPosition'],
143
+ },
144
+ },
145
+ /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
146
+ render: ({ icon: _icon, children: _, ...args }: StoryObj<typeof meta.args>) => {
147
+ // @ts-expect-error - This is a playground
148
+ const icon = _icon === 'none' ? undefined : Icons[_icon];
149
+
150
+ return (
151
+ <Flex direction="column" spacing="lg">
152
+ <VariantTitle title="Default Padding" invert={args.inverted}>
153
+ <Flex direction="row" align="center" spacing="none">
154
+ <Box backgroundColor="brand" width="100" height="100" />
155
+ <Button
156
+ {...args}
157
+ colorScheme="functional"
158
+ variant="ghost"
159
+ icon={icon}
160
+ paddingNone={false}
161
+ />
162
+ <Box backgroundColor="brand" width="100" height="100" />
163
+ </Flex>
164
+ </VariantTitle>
165
+ <VariantTitle title="No Padding (paddingNone)" invert={args.inverted}>
166
+ <Flex direction="row" align="center" spacing="none">
167
+ <Box backgroundColor="brand" width="100" height="100" />
168
+ <Button {...args} colorScheme="functional" variant="ghost" icon={icon} paddingNone />
169
+ <Box backgroundColor="brand" width="100" height="100" />
170
+ </Flex>
171
+ </VariantTitle>
172
+ </Flex>
173
+ );
174
+ },
175
+ };
176
+
141
177
  type ColorScheme = ButtonProps['colorScheme'];
142
178
  type Variant = ButtonProps['variant'];
143
179
 
@@ -145,7 +181,7 @@ export const KitchenSink: Story = {
145
181
  parameters: {
146
182
  controls: { include: ['text', 'size', 'inverted'] },
147
183
  },
148
- render: ({ text, inverted, size }) => {
184
+ render: ({ text, inverted, size }: StoryObj<typeof meta.args>) => {
149
185
  const schemes: Array<ColorScheme> = ['highlight', 'destructive', 'affirmative', 'functional'];
150
186
  const variants: Array<Variant> = ['emphasis', 'solid', 'outline', 'ghost'];
151
187
  return (
@@ -174,7 +210,7 @@ export const KitchenSink: Story = {
174
210
  .map(variant => (
175
211
  <Box key={variant} mb="100">
176
212
  <Box mb="100">
177
- <DetailText size="lg" color={inverted ? 'warmWhite50' : 'grey1000'}>
213
+ <DetailText size="lg" inverted={inverted}>
178
214
  {scheme} - {variant}
179
215
  </DetailText>
180
216
  </Box>
@@ -132,6 +132,14 @@ const styles = StyleSheet.create(theme => ({
132
132
  paddingHorizontal: 0,
133
133
  },
134
134
  },
135
+ {
136
+ size: 'md',
137
+ paddingNone: true,
138
+ variant: 'ghost',
139
+ styles: {
140
+ paddingHorizontal: 0,
141
+ },
142
+ },
135
143
  // Variant Color Schemes
136
144
  // Emphasis
137
145
  // Emphasis Yellow
@@ -328,13 +328,17 @@ const Carousel = ({
328
328
  onScrollEndDrag={handleWebScrollEnd}
329
329
  ref={scrollViewRef as any}
330
330
  scrollEnabled={!disabled}
331
- pointerEvents={disabled ? 'none' : 'auto'}
332
331
  scrollEventThrottle={16}
333
332
  showsHorizontalScrollIndicator={false}
334
333
  snapToInterval={itemWidth || width}
335
334
  snapToAlignment={centered ? 'center' : 'start'}
336
335
  decelerationRate="fast"
337
- style={[styles.webContainer, webContainerStyles, itemsStyle]}
336
+ style={[
337
+ styles.webContainer,
338
+ webContainerStyles,
339
+ itemsStyle,
340
+ { pointerEvents: disabled ? 'none' : 'auto' },
341
+ ]}
338
342
  contentContainerStyle={[styles.webContentContainer, webContentContainerStyle]}
339
343
  {...props}
340
344
  >
@@ -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
  )
@@ -7,7 +7,14 @@ import {
7
7
  import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
8
8
  import { CloseMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
9
9
  import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
10
- import { AccessibilityInfo, Dimensions, Platform, ScrollView, View, findNodeHandle } from 'react-native';
10
+ import {
11
+ AccessibilityInfo,
12
+ Dimensions,
13
+ Platform,
14
+ ScrollView,
15
+ View,
16
+ findNodeHandle,
17
+ } from 'react-native';
11
18
  import Animated, {
12
19
  Easing,
13
20
  useAnimatedStyle,
@@ -292,13 +299,23 @@ const Modal = ({
292
299
  </View>
293
300
  ) : null}
294
301
  {inNavModal && (
295
- <InNavModalContainer style={{ flex: stickyFooter ? 1 : 0 }}>
302
+ <InNavModalContainer
303
+ style={{
304
+ flex: stickyFooter ? 1 : 0,
305
+ ...(scrollable ? { marginHorizontal: -1 } : {}),
306
+ }}
307
+ {...(scrollable ? { contentContainerStyle: { paddingHorizontal: 1 } } : {})}
308
+ >
296
309
  {children}
297
- {!stickyFooter ? <View style={styles.inNavModalFooterContainer}>{footer}</View> : null}
310
+ {!stickyFooter ? (
311
+ <View style={styles.inNavModalFooterContainer}>{footer}</View>
312
+ ) : null}
298
313
  </InNavModalContainer>
299
314
  )}
300
315
  {!inNavModal && children}
301
- {((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons ? footer : null}
316
+ {((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons
317
+ ? footer
318
+ : null}
302
319
  </View>
303
320
  )}
304
321
  </>
@@ -322,7 +339,7 @@ const Modal = ({
322
339
 
323
340
  return inNavModal ? (
324
341
  <View
325
- onLayout={(e) => {
342
+ onLayout={e => {
326
343
  setInNavModalHeight(e.nativeEvent.layout.height);
327
344
  }}
328
345
  style={{
@@ -338,9 +355,7 @@ const Modal = ({
338
355
  <Animated.View
339
356
  style={[styles.inNavModalContainer, Platform.OS === 'android' && animatedInNavModalStyle]}
340
357
  >
341
- <View style={styles.inNavModalContent}>
342
- {content}
343
- </View>
358
+ <View style={styles.inNavModalContent}>{content}</View>
344
359
  </Animated.View>
345
360
  </View>
346
361
  ) : (
@@ -471,7 +486,7 @@ const styles = StyleSheet.create((theme, rt) => ({
471
486
  borderTopLeftRadius: theme.components.modal.borderRadius,
472
487
  borderTopRightRadius: theme.components.modal.borderRadius,
473
488
  backgroundColor: theme.color.surface.neutral.strong,
474
- paddingBottom: theme.components.modal.padding + rt.insets.bottom,
489
+ paddingBottom: theme.components.bottomSheet.padding + rt.insets.bottom,
475
490
  variants: {
476
491
  background: {
477
492
  primary: {},
@@ -481,22 +496,22 @@ const styles = StyleSheet.create((theme, rt) => ({
481
496
  },
482
497
  fullscreen: {
483
498
  true: {
484
- padding: theme.components.modal.padding,
499
+ padding: theme.components.bottomSheet.padding,
485
500
  paddingTop: rt.insets.top,
486
501
  },
487
502
  false: {
488
- padding: theme.components.modal.padding,
489
- }
490
- }
503
+ padding: theme.components.bottomSheet.padding,
504
+ },
505
+ },
491
506
  },
492
507
  },
493
508
  inNavModalFooterContainer: {
494
- paddingTop: theme.components.modal.padding,
509
+ paddingTop: theme.components.bottomSheet.padding,
495
510
  },
496
511
  androidContainer: {
497
512
  height: rt.insets.top + 18,
498
- paddingLeft: theme.components.modal.padding,
499
- paddingRight: theme.components.modal.padding,
513
+ paddingLeft: theme.components.bottomSheet.padding,
514
+ paddingRight: theme.components.bottomSheet.padding,
500
515
  justifyContent: 'flex-end',
501
516
  },
502
517
  pretendContent: {
@@ -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;
@@ -101,8 +101,7 @@ const SegmentedControlOptionRoot = ({
101
101
  {children}
102
102
  </BodyText>
103
103
  <AnimatedView
104
- pointerEvents="none"
105
- style={[styles.textLayer, regularLabelStyle]}
104
+ style={[styles.textLayer, styles.pointerEventsNone, regularLabelStyle]}
106
105
  accessible={false}
107
106
  accessibilityElementsHidden
108
107
  importantForAccessibility="no-hide-descendants"
@@ -113,8 +112,7 @@ const SegmentedControlOptionRoot = ({
113
112
  </BodyText>
114
113
  </AnimatedView>
115
114
  <AnimatedView
116
- pointerEvents="none"
117
- style={[styles.textLayer, selectedLabelStyle]}
115
+ style={[styles.textLayer, styles.pointerEventsNone, selectedLabelStyle]}
118
116
  accessible={false}
119
117
  accessibilityElementsHidden
120
118
  importantForAccessibility="no-hide-descendants"
@@ -206,6 +204,9 @@ const styles = StyleSheet.create(theme => ({
206
204
  alignItems: 'center',
207
205
  justifyContent: 'center',
208
206
  },
207
+ pointerEventsNone: {
208
+ pointerEvents: 'none',
209
+ },
209
210
  icon: {
210
211
  variants: {
211
212
  selected: {
@@ -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>(
@@ -22,6 +23,7 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
22
23
  secureTextEntry = false,
23
24
  autoFocus = false,
24
25
  style,
26
+ testID,
25
27
  ...props
26
28
  },
27
29
  ref
@@ -49,12 +51,15 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
49
51
  }
50
52
  }, [length, value]);
51
53
 
52
- const updateValue = (nextValue: string) => {
53
- const trimmedValue = nextValue.slice(0, length);
54
- latestValueRef.current = trimmedValue;
55
- setDisplayValue(trimmedValue);
56
- onChangeText?.(trimmedValue);
57
- };
54
+ const updateValue = useCallback(
55
+ (nextValue: string) => {
56
+ const trimmedValue = nextValue.slice(0, length);
57
+ latestValueRef.current = trimmedValue;
58
+ setDisplayValue(trimmedValue);
59
+ onChangeText?.(trimmedValue);
60
+ },
61
+ [length, onChangeText]
62
+ );
58
63
 
59
64
  const setSelectionIndex = (index: number) => {
60
65
  const clampedIndex = Math.max(0, Math.min(index, length));
@@ -76,16 +81,6 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
76
81
  setFocusedIndex(Math.min(clampedIndex, length - 1));
77
82
  };
78
83
 
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
84
  const handleChangeText = (text: string) => {
90
85
  const prevValue = latestValueRef.current;
91
86
  const nextValue = text.slice(0, length);
@@ -94,14 +89,7 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
94
89
  const diff = nextLength - prevLength;
95
90
  const isBulkInsert = text.length > 1 && diff > 1;
96
91
  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
- }
92
+ const nextIndex = getNextIndexFromValueChange({ prevValue, nextValue, length });
105
93
  updateValue(nextValue);
106
94
  if (isBulkInsert) {
107
95
  setCaretIndex(Math.min(nextLength, length));
@@ -171,7 +159,7 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
171
159
  }
172
160
  },
173
161
  }),
174
- [length, onChangeText]
162
+ [length, updateValue]
175
163
  );
176
164
 
177
165
  const slots = Array.from({ length }, (_, index) => index);
@@ -252,7 +240,7 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
252
240
  maxLength={length}
253
241
  caretHidden
254
242
  style={styles.hiddenInput}
255
- pointerEvents="none"
243
+ testID={testID}
256
244
  />
257
245
  {slots.map(index => {
258
246
  const char = displayValue[index] || '';
@@ -299,11 +287,12 @@ const styles = StyleSheet.create(theme => ({
299
287
  position: 'absolute',
300
288
  width: '100%',
301
289
  height: '100%',
290
+ pointerEvents: 'none',
302
291
  left: 0,
303
292
  top: 0,
304
293
  color: 'transparent',
305
294
  fontSize: 1,
306
- opacity: 0.1,
295
+ opacity: 0.01,
307
296
  },
308
297
  }));
309
298