@utilitywarehouse/hearth-react-native 0.27.1 → 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 (30) hide show
  1. package/.storybook/vitest.setup.ts +35 -3
  2. package/.turbo/turbo-build.log +5 -4
  3. package/build/components/Carousel/Carousel.js +6 -1
  4. package/build/components/List/List.js +2 -2
  5. package/build/components/SegmentedControl/SegmentedControl.js +4 -1
  6. package/build/components/SegmentedControl/SegmentedControlOption.js +4 -1
  7. package/build/components/TimePicker/TimePickerWheel.js +9 -1
  8. package/build/components/Toast/Toast.context.js +1 -1
  9. package/build/components/VerificationInput/VerificationInput.js +10 -21
  10. package/build/components/VerificationInput/VerificationInput.utils.d.ts +8 -0
  11. package/build/components/VerificationInput/VerificationInput.utils.js +17 -0
  12. package/build/components/VerificationInput/VerificationInput.utils.test.d.ts +1 -0
  13. package/build/components/VerificationInput/VerificationInput.utils.test.js +36 -0
  14. package/docs/changelog.mdx +113 -0
  15. package/package.json +5 -4
  16. package/src/components/Carousel/Carousel.tsx +6 -2
  17. package/src/components/IconContainer/IconContainer.stories.tsx +35 -30
  18. package/src/components/List/List.tsx +5 -4
  19. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -1
  20. package/src/components/SegmentedControl/SegmentedControlOption.tsx +5 -4
  21. package/src/components/TimePicker/TimePickerWheel.tsx +11 -4
  22. package/src/components/Toast/Toast.context.tsx +1 -1
  23. package/src/components/VerificationInput/VerificationInput.stories.tsx +33 -0
  24. package/src/components/VerificationInput/VerificationInput.tsx +16 -29
  25. package/src/components/VerificationInput/VerificationInput.utils.test.ts +48 -0
  26. package/src/components/VerificationInput/VerificationInput.utils.ts +32 -0
  27. package/tsconfig.eslint.json +2 -1
  28. package/vitest.config.js +11 -13
  29. package/vitest.unit.config.ts +9 -0
  30. package/.turbo/turbo-lint.log +0 -72
@@ -1,7 +1,39 @@
1
- import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
2
1
  import { setProjectAnnotations } from '@storybook/react-native-web-vite';
3
- import * as projectAnnotations from './preview';
2
+ import { vi } from 'vitest';
3
+
4
+ // react-native-unistyles/mocks relies on Jest globals.
5
+ if (!(globalThis as { jest?: unknown }).jest) {
6
+ (globalThis as any).jest = vi;
7
+ }
8
+
9
+ await import('react-native-unistyles/mocks');
10
+ const { StyleSheet } = await import('react-native-unistyles');
11
+ const { breakpoints } = await import('../src/core/breakpoints');
12
+ const { themes } = await import('../src/core/themes');
13
+
14
+ vi.mock('../src/core', async () => {
15
+ const unistyles = await import('react-native-unistyles');
16
+
17
+ return {
18
+ breakpoints,
19
+ themes,
20
+ StyleSheet: unistyles.StyleSheet,
21
+ UnistylesRuntime: unistyles.UnistylesRuntime,
22
+ };
23
+ });
24
+
25
+ StyleSheet.configure({
26
+ breakpoints,
27
+ themes,
28
+ settings: {
29
+ initialTheme: 'light',
30
+ adaptiveThemes: false,
31
+ },
32
+ });
33
+
34
+ const a11yAddonAnnotations = await import('@storybook/addon-a11y/preview');
35
+ const projectAnnotations = await import('./preview');
4
36
 
5
37
  // This is an important step to apply the right configuration when testing your stories.
6
38
  // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
7
- setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
39
+ setProjectAnnotations([a11yAddonAnnotations as any, projectAnnotations as any]);
@@ -1,4 +1,5 @@
1
-
2
- > @utilitywarehouse/hearth-react-native@0.27.1 build /home/runner/work/hearth/hearth/packages/react-native
3
- > tsc
4
-
1
+
2
+ 
3
+ > @utilitywarehouse/hearth-react-native@0.27.2-test build /Users/filmondaniels/Projects/Work/hearth/packages/react-native
4
+ > tsc
5
+
@@ -213,7 +213,12 @@ const Carousel = ({ centered = false, children, disabled = false, inactiveItemOp
213
213
  const controls = (_jsx(CarouselControls, { style: [styles.controls, controlsStyle], itemStyle: controlsItemStyle, activeItemStyle: controlsActiveItemStyle, showNavigation: showNavigation, accessibilityHidden: controlsAccessibilityHidden }));
214
214
  // Render for web using ScrollView with scroll snap
215
215
  if (isWeb) {
216
- return (_jsx(CarouselContext.Provider, { value: context, children: _jsxs(View, { style: style, children: [_jsx(ScrollView, { horizontal: true, onScroll: handleWebScroll, onMomentumScrollEnd: handleWebScrollEnd, onScrollEndDrag: handleWebScrollEnd, ref: scrollViewRef, scrollEnabled: !disabled, pointerEvents: disabled ? 'none' : 'auto', scrollEventThrottle: 16, showsHorizontalScrollIndicator: false, snapToInterval: itemWidth || width, snapToAlignment: centered ? 'center' : 'start', decelerationRate: "fast", style: [styles.webContainer, webContainerStyles, itemsStyle], contentContainerStyle: [styles.webContentContainer, webContentContainerStyle], ...props, children: carouselItems.map((item, index) => cloneElement(item, {
216
+ return (_jsx(CarouselContext.Provider, { value: context, children: _jsxs(View, { style: style, children: [_jsx(ScrollView, { horizontal: true, onScroll: handleWebScroll, onMomentumScrollEnd: handleWebScrollEnd, onScrollEndDrag: handleWebScrollEnd, ref: scrollViewRef, scrollEnabled: !disabled, scrollEventThrottle: 16, showsHorizontalScrollIndicator: false, snapToInterval: itemWidth || width, snapToAlignment: centered ? 'center' : 'start', decelerationRate: "fast", style: [
217
+ styles.webContainer,
218
+ webContainerStyles,
219
+ itemsStyle,
220
+ { pointerEvents: disabled ? 'none' : 'auto' },
221
+ ], contentContainerStyle: [styles.webContentContainer, webContentContainerStyle], ...props, children: carouselItems.map((item, index) => cloneElement(item, {
217
222
  active: index === activeIndex,
218
223
  inactiveOpacity: inactiveItemOpacity,
219
224
  key: item?.key || item.props?.id || index,
@@ -6,7 +6,7 @@ import { Card } from '../Card';
6
6
  import { SectionHeader } from '../SectionHeader';
7
7
  import { ListContext } from './List.context';
8
8
  const List = ({ children, heading, helperText, headerTrailingContent, invalidText, ...props }) => {
9
- const { loading, disabled, container = 'none' } = props;
9
+ const { loading, disabled, container = 'none', testID, style, ...rest } = props;
10
10
  const orderRef = useRef([]);
11
11
  const [firstItemId, setFirstItemId] = useState(undefined);
12
12
  const containerToCard = {
@@ -35,7 +35,7 @@ const List = ({ children, heading, helperText, headerTrailingContent, invalidTex
35
35
  registerItem,
36
36
  };
37
37
  styles.useVariants({ disabled });
38
- return (_jsx(ListContext.Provider, { value: value, children: _jsxs(View, { ...props, style: [styles.container, props.style], children: [heading ? (_jsx(SectionHeader, { heading: heading, helperText: helperText, trailingContent: headerTrailingContent, invalidText: invalidText })) : null, container === 'none' ? (_jsx(View, { children: children })) : (React.Children.count(children) > 0 && (_jsx(Card, { ...containerToCard, noPadding: true, style: styles.card, children: _jsx(_Fragment, { children: children }) })))] }) }));
38
+ return (_jsx(ListContext.Provider, { value: value, children: _jsxs(View, { ...rest, style: [styles.container, style], children: [heading ? (_jsx(SectionHeader, { heading: heading, helperText: helperText, trailingContent: headerTrailingContent, invalidText: invalidText })) : null, container === 'none' ? (_jsx(View, { testID: testID, children: children })) : (React.Children.count(children) > 0 && (_jsx(Card, { ...containerToCard, noPadding: true, style: styles.card, testID: testID, children: _jsx(_Fragment, { children: children }) })))] }) }));
39
39
  };
40
40
  List.displayName = 'List';
41
41
  const styles = StyleSheet.create(theme => ({
@@ -153,7 +153,7 @@ const SegmentedControl = ({ value: controlledValue, defaultValue, onValueChange,
153
153
  size,
154
154
  registerOptionLayout,
155
155
  }), [currentValue, select, disabled, size, registerOptionLayout]);
156
- return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsxs(View, { accessibilityRole: "radiogroup", accessibilityState: { disabled }, style: [styles.container, computedStyles, style], ...remainingProps, children: [hasIndicator ? (_jsx(Indicator, { pointerEvents: "none", style: [styles.indicator, indicatorStyle] })) : null, children] }) }));
156
+ return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsxs(View, { accessibilityRole: "radiogroup", accessibilityState: { disabled }, style: [styles.container, computedStyles, style], ...remainingProps, children: [hasIndicator ? (_jsx(Indicator, { style: [styles.indicator, styles.pointerEventsNone, indicatorStyle] })) : null, children] }) }));
157
157
  };
158
158
  SegmentedControl.displayName = 'SegmentedControl';
159
159
  const styles = StyleSheet.create(theme => ({
@@ -192,5 +192,8 @@ const styles = StyleSheet.create(theme => ({
192
192
  borderRadius: theme.components.segmentedControl.borderRadius,
193
193
  backgroundColor: theme.color.interactive.brand.surface.strong.default,
194
194
  },
195
+ pointerEventsNone: {
196
+ pointerEvents: 'none',
197
+ },
195
198
  }));
196
199
  export default SegmentedControl;
@@ -36,7 +36,7 @@ const SegmentedControlOptionRoot = ({ value, children, icon, accessibilityLabel,
36
36
  const accessibleLabel = typeof children === 'string' || typeof children === 'number' ? String(children) : value;
37
37
  return (_jsx(Pressable, { ...props, accessibilityRole: "radio", accessibilityState: { checked: selected, disabled: isDisabled }, accessibilityLabel: accessibilityLabel ?? accessibleLabel, onPress: onPress, onLayout: e => registerOptionLayout(value, e.nativeEvent.layout), disabled: isDisabled, style: [styles.option, style], ...(Platform.OS === 'web'
38
38
  ? { 'aria-label': accessibilityLabel ?? accessibleLabel }
39
- : null), children: _jsxs(View, { style: styles.contentWrap, accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: [icon ? _jsx(Icon, { as: icon, size: "sm", style: styles.icon }) : null, _jsxs(View, { style: styles.labelWrap, children: [_jsx(BodyText, { size: "md", weight: "semibold", style: styles.labelSizer, accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: children }), _jsx(AnimatedView, { pointerEvents: "none", style: [styles.textLayer, regularLabelStyle], accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: _jsx(BodyText, { size: "md", weight: "regular", style: styles.textRegular, children: children }) }), _jsx(AnimatedView, { pointerEvents: "none", style: [styles.textLayer, selectedLabelStyle], accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: _jsx(BodyText, { size: "md", weight: "semibold", style: styles.textSelected, children: children }) })] })] }) }));
39
+ : null), children: _jsxs(View, { style: styles.contentWrap, accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: [icon ? _jsx(Icon, { as: icon, size: "sm", style: styles.icon }) : null, _jsxs(View, { style: styles.labelWrap, children: [_jsx(BodyText, { size: "md", weight: "semibold", style: styles.labelSizer, accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: children }), _jsx(AnimatedView, { style: [styles.textLayer, styles.pointerEventsNone, regularLabelStyle], accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: _jsx(BodyText, { size: "md", weight: "regular", style: styles.textRegular, children: children }) }), _jsx(AnimatedView, { style: [styles.textLayer, styles.pointerEventsNone, selectedLabelStyle], accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), children: _jsx(BodyText, { size: "md", weight: "semibold", style: styles.textSelected, children: children }) })] })] }) }));
40
40
  };
41
41
  const SegmentedControlOption = createPressable({ Root: SegmentedControlOptionRoot });
42
42
  SegmentedControlOption.displayName = 'SegmentedControlOption';
@@ -112,6 +112,9 @@ const styles = StyleSheet.create(theme => ({
112
112
  alignItems: 'center',
113
113
  justifyContent: 'center',
114
114
  },
115
+ pointerEventsNone: {
116
+ pointerEvents: 'none',
117
+ },
115
118
  icon: {
116
119
  variants: {
117
120
  selected: {
@@ -26,7 +26,12 @@ const TimePickerWheel = ({ value, setValue = () => { }, items }) => {
26
26
  setValue(item.value);
27
27
  }
28
28
  }, [setValue, value]);
29
- const renderOverlay = useCallback(() => (_jsxs(View, { style: [styles.overlayContainer], pointerEvents: "none", children: [_jsx(View, { pointerEvents: "none", style: [styles.fadeOverlay, { height: fadeHeight }], children: _jsxs(Svg, { width: "100%", height: "100%", preserveAspectRatio: "none", children: [_jsx(Defs, { children: _jsxs(LinearGradient, { id: `${gradientId}-top`, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx(Stop, { offset: "0", stopColor: theme.color.background.secondary, stopOpacity: 1 }), _jsx(Stop, { offset: "1", stopColor: theme.color.background.secondary, stopOpacity: 0 })] }) }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#${gradientId}-top)` })] }) }), _jsx(View, { pointerEvents: "none", style: [styles.fadeOverlay, styles.fadeOverlayBottom, { height: fadeHeight }], children: _jsxs(Svg, { width: "100%", height: "100%", preserveAspectRatio: "none", children: [_jsx(Defs, { children: _jsxs(LinearGradient, { id: `${gradientId}-bottom`, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx(Stop, { offset: "0", stopColor: theme.color.background.secondary, stopOpacity: 0 }), _jsx(Stop, { offset: "1", stopColor: theme.color.background.secondary, stopOpacity: 1 })] }) }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#${gradientId}-bottom)` })] }) })] })), [fadeHeight, gradientId, theme.color.background.secondary]);
29
+ const renderOverlay = useCallback(() => (_jsxs(View, { style: [styles.overlayContainer, styles.pointerEventsNone], children: [_jsx(View, { style: [styles.fadeOverlay, styles.pointerEventsNone, { height: fadeHeight }], children: _jsxs(Svg, { width: "100%", height: "100%", preserveAspectRatio: "none", children: [_jsx(Defs, { children: _jsxs(LinearGradient, { id: `${gradientId}-top`, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx(Stop, { offset: "0", stopColor: theme.color.background.secondary, stopOpacity: 1 }), _jsx(Stop, { offset: "1", stopColor: theme.color.background.secondary, stopOpacity: 0 })] }) }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#${gradientId}-top)` })] }) }), _jsx(View, { style: [
30
+ styles.fadeOverlay,
31
+ styles.fadeOverlayBottom,
32
+ styles.pointerEventsNone,
33
+ { height: fadeHeight },
34
+ ], children: _jsxs(Svg, { width: "100%", height: "100%", preserveAspectRatio: "none", children: [_jsx(Defs, { children: _jsxs(LinearGradient, { id: `${gradientId}-bottom`, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx(Stop, { offset: "0", stopColor: theme.color.background.secondary, stopOpacity: 0 }), _jsx(Stop, { offset: "1", stopColor: theme.color.background.secondary, stopOpacity: 1 })] }) }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#${gradientId}-bottom)` })] }) })] })), [fadeHeight, gradientId, theme.color.background.secondary]);
30
35
  const renderItem = useCallback(({ item }) => (_jsx(View, { style: styles.indicator, children: _jsx(BodyText, { size: "lg", children: item.label }) })), []);
31
36
  return (_jsxs(View, { style: [styles.container, { height: pickerHeight }], children: [_jsx(View, { style: styles.overlayContainer, children: _jsx(View, { style: [styles.selection] }) }), _jsx(WheelPicker, { data: data, value: value, onValueChanged: handleValueChanged, itemHeight: ITEM_HEIGHT, visibleItemCount: displayCount, width: theme.components.timePicker.time.item.width, renderItem: renderItem, renderOverlay: renderOverlay })] }));
32
37
  };
@@ -74,5 +79,8 @@ const styles = StyleSheet.create(theme => ({
74
79
  top: undefined,
75
80
  bottom: 0,
76
81
  },
82
+ pointerEventsNone: {
83
+ pointerEvents: 'none',
84
+ },
77
85
  }));
78
86
  export default TimePickerWheel;
@@ -63,7 +63,7 @@ export const ToastProvider = ({ children, safeAreaPadding = true, }) => {
63
63
  timers.current = {};
64
64
  };
65
65
  }, []);
66
- return (_jsxs(ToastContext.Provider, { value: { addToast, removeToast }, children: [children, _jsx(View, { pointerEvents: "box-none", style: styles.container, children: _jsx(View, { style: styles.stack, children: toasts.map(t => (_jsx(ToastItem, { ref: el => {
66
+ return (_jsxs(ToastContext.Provider, { value: { addToast, removeToast }, children: [children, _jsx(View, { style: styles.container, children: _jsx(View, { style: styles.stack, children: toasts.map(t => (_jsx(ToastItem, { ref: el => {
67
67
  toastRefs.current[t.id] = el;
68
68
  }, toast: t, onClose: removeToast }, t.id))) }) })] }));
69
69
  };
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
- import { Platform, TextInput, View } from 'react-native';
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
+ import { TextInput, View } from 'react-native';
4
4
  import { StyleSheet } from 'react-native-unistyles';
5
5
  import { FormField } from '../FormField';
6
+ import { getNextIndexFromValueChange } from './VerificationInput.utils';
6
7
  import { VerificationInputSlot } from './VerificationInputSlot';
7
8
  const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVariant = 'body', helperText, helperIcon, validationStatus = 'initial', validText, invalidText, disabled = false, readonly = false, secureTextEntry = false, autoFocus = false, style, ...props }, ref) => {
8
9
  const length = 6;
@@ -26,12 +27,12 @@ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVa
26
27
  setSelection(nextSelection);
27
28
  }
28
29
  }, [length, value]);
29
- const updateValue = (nextValue) => {
30
+ const updateValue = useCallback((nextValue) => {
30
31
  const trimmedValue = nextValue.slice(0, length);
31
32
  latestValueRef.current = trimmedValue;
32
33
  setDisplayValue(trimmedValue);
33
34
  onChangeText?.(trimmedValue);
34
- };
35
+ }, [length, onChangeText]);
35
36
  const setSelectionIndex = (index) => {
36
37
  const clampedIndex = Math.max(0, Math.min(index, length));
37
38
  const hasChar = !!latestValueRef.current[clampedIndex];
@@ -50,15 +51,6 @@ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVa
50
51
  setSelection(nextSelection);
51
52
  setFocusedIndex(Math.min(clampedIndex, length - 1));
52
53
  };
53
- const findDiffIndex = (prevValue, nextValue) => {
54
- const minLength = Math.min(prevValue.length, nextValue.length);
55
- for (let i = 0; i < minLength; i += 1) {
56
- if (prevValue[i] !== nextValue[i]) {
57
- return i;
58
- }
59
- }
60
- return minLength;
61
- };
62
54
  const handleChangeText = (text) => {
63
55
  const prevValue = latestValueRef.current;
64
56
  const nextValue = text.slice(0, length);
@@ -67,11 +59,7 @@ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVa
67
59
  const diff = nextLength - prevLength;
68
60
  const isBulkInsert = text.length > 1 && diff > 1;
69
61
  const shouldBlur = nextLength >= length;
70
- let nextIndex = Math.max(0, Math.min(latestSelectionRef.current.start + (diff >= 0 ? 1 : diff), length));
71
- if (Platform.OS === 'android') {
72
- const editedIndex = findDiffIndex(prevValue, nextValue);
73
- nextIndex = diff >= 0 ? Math.min(editedIndex + 1, length) : Math.max(editedIndex, 0);
74
- }
62
+ const nextIndex = getNextIndexFromValueChange({ prevValue, nextValue, length });
75
63
  updateValue(nextValue);
76
64
  if (isBulkInsert) {
77
65
  setCaretIndex(Math.min(nextLength, length));
@@ -135,7 +123,7 @@ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVa
135
123
  setSelectionIndex(index);
136
124
  }
137
125
  },
138
- }), [length, onChangeText]);
126
+ }), [length, updateValue]);
139
127
  const slots = Array.from({ length }, (_, index) => index);
140
128
  const getAccessibilityLabel = () => {
141
129
  return label || props.accessibilityLabel;
@@ -172,7 +160,7 @@ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVa
172
160
  latestSelectionRef.current = nextSelection;
173
161
  setSelection(nextSelection);
174
162
  setFocusedIndex(Math.min(nextSelection.start, length - 1));
175
- }, onFocus: handleFocus, onBlur: handleBlur, selection: selection, keyboardType: "number-pad", textContentType: "oneTimeCode", autoComplete: "sms-otp", secureTextEntry: secureTextEntry, maxLength: length, caretHidden: true, style: styles.hiddenInput, pointerEvents: "none" }), slots.map(index => {
163
+ }, onFocus: handleFocus, onBlur: handleBlur, selection: selection, keyboardType: "number-pad", textContentType: "oneTimeCode", autoComplete: "sms-otp", secureTextEntry: secureTextEntry, maxLength: length, caretHidden: true, style: styles.hiddenInput }), slots.map(index => {
176
164
  const char = displayValue[index] || '';
177
165
  const isActive = focusedIndex === index;
178
166
  const displayChar = secureTextEntry && char ? '*' : char;
@@ -199,11 +187,12 @@ const styles = StyleSheet.create(theme => ({
199
187
  position: 'absolute',
200
188
  width: '100%',
201
189
  height: '100%',
190
+ pointerEvents: 'none',
202
191
  left: 0,
203
192
  top: 0,
204
193
  color: 'transparent',
205
194
  fontSize: 1,
206
- opacity: 0.1,
195
+ opacity: 0.01,
207
196
  },
208
197
  }));
209
198
  VerificationInput.displayName = 'VerificationInput';
@@ -0,0 +1,8 @@
1
+ interface GetNextIndexFromValueChangeOptions {
2
+ prevValue: string;
3
+ nextValue: string;
4
+ length: number;
5
+ }
6
+ export declare const findDiffIndex: (prevValue: string, nextValue: string) => number;
7
+ export declare const getNextIndexFromValueChange: ({ prevValue, nextValue, length, }: GetNextIndexFromValueChangeOptions) => number;
8
+ export {};
@@ -0,0 +1,17 @@
1
+ export const findDiffIndex = (prevValue, nextValue) => {
2
+ const minLength = Math.min(prevValue.length, nextValue.length);
3
+ for (let i = 0; i < minLength; i += 1) {
4
+ if (prevValue[i] !== nextValue[i]) {
5
+ return i;
6
+ }
7
+ }
8
+ return minLength;
9
+ };
10
+ export const getNextIndexFromValueChange = ({ prevValue, nextValue, length, }) => {
11
+ const diff = nextValue.length - prevValue.length;
12
+ const editedIndex = findDiffIndex(prevValue, nextValue);
13
+ if (diff >= 0) {
14
+ return Math.min(editedIndex + 1, length);
15
+ }
16
+ return Math.max(editedIndex, 0);
17
+ };
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { findDiffIndex, getNextIndexFromValueChange } from './VerificationInput.utils';
3
+ describe('findDiffIndex', () => {
4
+ it('returns first differing index', () => {
5
+ expect(findDiffIndex('12', '19')).toBe(1);
6
+ });
7
+ it('returns previous length when next appends', () => {
8
+ expect(findDiffIndex('12', '123')).toBe(2);
9
+ });
10
+ it('returns next length when next shortens with same prefix', () => {
11
+ expect(findDiffIndex('123', '12')).toBe(2);
12
+ });
13
+ });
14
+ describe('getNextIndexFromValueChange', () => {
15
+ it('moves to the slot after the one that changed when inserting in an empty later slot', () => {
16
+ expect(getNextIndexFromValueChange({
17
+ prevValue: '12',
18
+ nextValue: '123',
19
+ length: 6,
20
+ })).toBe(3);
21
+ });
22
+ it('caps at length when value becomes full', () => {
23
+ expect(getNextIndexFromValueChange({
24
+ prevValue: '12345',
25
+ nextValue: '123456',
26
+ length: 6,
27
+ })).toBe(6);
28
+ });
29
+ it('stays at edited index for deletions', () => {
30
+ expect(getNextIndexFromValueChange({
31
+ prevValue: '123',
32
+ nextValue: '12',
33
+ length: 6,
34
+ })).toBe(2);
35
+ });
36
+ });
@@ -9,6 +9,119 @@ import { BackToTopButton } from './components';
9
9
  The changelog for the Hearth React Native library. Here you can find all the changes, improvements, and bug fixes for each version.
10
10
 
11
11
 
12
+ ## 0.27.1
13
+
14
+ ### Patch Changes
15
+
16
+ - [#990](https://github.com/utilitywarehouse/hearth/pull/990) [`958e0e1`](https://github.com/utilitywarehouse/hearth/commit/958e0e1a9d5451d1e11fecadc69ae3c5ad9d42ca) Thanks [@declanelcocks](https://github.com/declanelcocks)! - 🐛 [FIX]: Fix `Modal` layout when `inNavModal` and `stickyFooter={false}`.
17
+
18
+ Corrects the container flex style for `inNavModal` modals with a non-sticky footer, where the UX was not great when scrolling.
19
+
20
+ **Components affected**:
21
+ - `Modal`
22
+
23
+ **Developer changes**:
24
+
25
+ No changes required.
26
+
27
+ - [#992](https://github.com/utilitywarehouse/hearth/pull/992) [`2560b3d`](https://github.com/utilitywarehouse/hearth/commit/2560b3dcba7ed4981fad585628f96afd07d8de4f) Thanks [@jordmccord](https://github.com/jordmccord)! - 💅 [ENHANCEMENT]: Add optional leading `icon` support to `SegmentedControlOption`.
28
+
29
+ This adds an optional `icon` prop to `SegmentedControlOption`, allowing icons to be displayed before option labels in segmented controls.
30
+
31
+ Docs and stories were updated to include icon usage examples.
32
+
33
+ **Components affected**:
34
+ - `SegmentedControlOption`
35
+
36
+ **Developer changes**:
37
+
38
+ No changes required for existing usage.
39
+
40
+ To use the new optional icon prop:
41
+
42
+ ```tsx
43
+ import { SegmentedControl, SegmentedControlOption } from '@utilitywarehouse/hearth-react-native';
44
+ import { ElectricitySmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
45
+
46
+ <SegmentedControl defaultValue="energy">
47
+ <SegmentedControlOption value="energy" icon={ElectricitySmallIcon}>
48
+ Energy
49
+ </SegmentedControlOption>
50
+ <SegmentedControlOption value="broadband">Broadband</SegmentedControlOption>
51
+ </SegmentedControl>;
52
+ ```
53
+
54
+ ## 0.27.0
55
+
56
+ ### Minor Changes
57
+
58
+ - [#987](https://github.com/utilitywarehouse/hearth/pull/987) [`eb962d2`](https://github.com/utilitywarehouse/hearth/commit/eb962d2f33b63fa3aeda0b291fd41ace90d04c41) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: Add `SegmentedControl` and `SegmentedControlOption` components.
59
+
60
+ This introduces a new segmented control component for switching between a small set of related options.
61
+ The component includes controlled and uncontrolled usage, size variants (`sm`, `md`), animated selected indicator movement, and improved accessibility semantics for screen readers.
62
+
63
+ **Components affected**:
64
+ - `SegmentedControl`
65
+ - `SegmentedControlOption`
66
+
67
+ **Developer changes**:
68
+
69
+ Import and compose the new components as follows:
70
+
71
+ ```tsx
72
+ import { SegmentedControl, SegmentedControlOption } from '@utilitywarehouse/hearth-react-native';
73
+
74
+ <SegmentedControl defaultValue="day" size="sm">
75
+ <SegmentedControlOption value="day">Day</SegmentedControlOption>
76
+ <SegmentedControlOption value="week">Week</SegmentedControlOption>
77
+ <SegmentedControlOption value="month">Month</SegmentedControlOption>
78
+ </SegmentedControl>;
79
+ ```
80
+
81
+ ### Patch Changes
82
+
83
+ - [#989](https://github.com/utilitywarehouse/hearth/pull/989) [`c97122e`](https://github.com/utilitywarehouse/hearth/commit/c97122eb429ec4adef656fb245a9256a5619df61) Thanks [@jordmccord](https://github.com/jordmccord)! - 🐛 [FIX]: Ensure horizontal `Banner` fills available width when `onPress` is not provided.
84
+
85
+ Fixed a layout issue where a horizontal `Banner` without `onPress` could fail to stretch correctly within its parent container.
86
+
87
+ **Components affected**:
88
+ - `Banner`
89
+
90
+ **Developer changes**:
91
+
92
+ No changes required.
93
+
94
+ ## 0.26.0
95
+
96
+ ### Minor Changes
97
+
98
+ - [#981](https://github.com/utilitywarehouse/hearth/pull/981) [`df56387`](https://github.com/utilitywarehouse/hearth/commit/df563872e6bf040d419f6c7fce2343ebe560edb9) Thanks [@declanelcocks](https://github.com/declanelcocks)! - 🌟 [ENHANCEMENT]: Improve `Modal` behaviour when used inside a React Navigation modal (`inNavModal`).
99
+
100
+ The following improvements have been made to the `Modal` component when used in a navigation context with `inNavModal={true}`:
101
+ - **`scrollable` prop**: Content is now rendered inside a `ScrollView` by default. Set `scrollable={false}` to opt out, for example when you need to centre content or use a custom layout.
102
+ - **`stickyFooter` support**: The `stickyFooter` prop now works correctly in `inNavModal` mode.
103
+ - **Auto full-screen detection**: When the modal fills the entire screen (e.g. with `presentation: 'fullScreenModal'`), the `fullscreen` style is applied automatically. The `fullscreen` prop is no longer available when `inNavModal` is `true`.
104
+
105
+ **Components affected**:
106
+ - `Modal`
107
+
108
+ **Developer changes**:
109
+
110
+ No changes are required for existing usage. If you were passing `fullscreen` alongside `inNavModal={true}`, remove the `fullscreen` prop — full-screen styling is now detected automatically:
111
+
112
+ ```diff
113
+ - <Modal inNavModal fullscreen>
114
+ + <Modal inNavModal>
115
+ ```
116
+
117
+ To disable the default `ScrollView` wrapping in `inNavModal` mode:
118
+
119
+ ```tsx
120
+ <Modal inNavModal scrollable={false}>
121
+ {/* custom layout */}
122
+ </Modal>
123
+ ```
124
+
12
125
  ## 0.25.0
13
126
 
14
127
  ### Minor Changes
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-test",
4
4
  "description": "Utility Warehouse React Native UI library",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -56,11 +56,11 @@
56
56
  "vite": "^7.1.3",
57
57
  "vite-plugin-svgr": "^4.5.0",
58
58
  "vitest": "^3.2.4",
59
+ "@utilitywarehouse/hearth-svg-assets": "^0.5.0",
60
+ "@utilitywarehouse/hearth-react-native-icons": "^0.8.0",
59
61
  "@utilitywarehouse/hearth-react-icons": "^0.8.0",
60
62
  "@utilitywarehouse/hearth-fonts": "^0.0.4",
61
- "@utilitywarehouse/hearth-react-native-icons": "^0.8.0",
62
- "@utilitywarehouse/hearth-tokens": "^0.2.3",
63
- "@utilitywarehouse/hearth-svg-assets": "^0.5.0"
63
+ "@utilitywarehouse/hearth-tokens": "^0.2.3"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@gorhom/bottom-sheet": "^5.0.0",
@@ -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",
@@ -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
  )
@@ -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>(
@@ -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
 
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { findDiffIndex, getNextIndexFromValueChange } from './VerificationInput.utils';
3
+
4
+ describe('findDiffIndex', () => {
5
+ it('returns first differing index', () => {
6
+ expect(findDiffIndex('12', '19')).toBe(1);
7
+ });
8
+
9
+ it('returns previous length when next appends', () => {
10
+ expect(findDiffIndex('12', '123')).toBe(2);
11
+ });
12
+
13
+ it('returns next length when next shortens with same prefix', () => {
14
+ expect(findDiffIndex('123', '12')).toBe(2);
15
+ });
16
+ });
17
+
18
+ describe('getNextIndexFromValueChange', () => {
19
+ it('moves to the slot after the one that changed when inserting in an empty later slot', () => {
20
+ expect(
21
+ getNextIndexFromValueChange({
22
+ prevValue: '12',
23
+ nextValue: '123',
24
+ length: 6,
25
+ })
26
+ ).toBe(3);
27
+ });
28
+
29
+ it('caps at length when value becomes full', () => {
30
+ expect(
31
+ getNextIndexFromValueChange({
32
+ prevValue: '12345',
33
+ nextValue: '123456',
34
+ length: 6,
35
+ })
36
+ ).toBe(6);
37
+ });
38
+
39
+ it('stays at edited index for deletions', () => {
40
+ expect(
41
+ getNextIndexFromValueChange({
42
+ prevValue: '123',
43
+ nextValue: '12',
44
+ length: 6,
45
+ })
46
+ ).toBe(2);
47
+ });
48
+ });
@@ -0,0 +1,32 @@
1
+ interface GetNextIndexFromValueChangeOptions {
2
+ prevValue: string;
3
+ nextValue: string;
4
+ length: number;
5
+ }
6
+
7
+ export const findDiffIndex = (prevValue: string, nextValue: string): number => {
8
+ const minLength = Math.min(prevValue.length, nextValue.length);
9
+
10
+ for (let i = 0; i < minLength; i += 1) {
11
+ if (prevValue[i] !== nextValue[i]) {
12
+ return i;
13
+ }
14
+ }
15
+
16
+ return minLength;
17
+ };
18
+
19
+ export const getNextIndexFromValueChange = ({
20
+ prevValue,
21
+ nextValue,
22
+ length,
23
+ }: GetNextIndexFromValueChangeOptions): number => {
24
+ const diff = nextValue.length - prevValue.length;
25
+ const editedIndex = findDiffIndex(prevValue, nextValue);
26
+
27
+ if (diff >= 0) {
28
+ return Math.min(editedIndex + 1, length);
29
+ }
30
+
31
+ return Math.max(editedIndex, 0);
32
+ };
@@ -5,7 +5,8 @@
5
5
  "./src/**/*.stories.tsx",
6
6
  "./src/**/*.figma.tsx",
7
7
  "./.storybook/**/*",
8
- "./docs/**/*"
8
+ "./docs/**/*",
9
+ "./vitest.unit.config.ts"
9
10
  ],
10
11
  "exclude": [
11
12
  "node_modules"
package/vitest.config.js CHANGED
@@ -1,35 +1,33 @@
1
- import path from "node:path";
2
- import { fileURLToPath } from "node:url";
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
3
 
4
- import { defineConfig } from "vitest/config";
4
+ import { defineConfig } from 'vitest/config';
5
5
 
6
- import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
6
+ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
7
7
 
8
8
  const dirname =
9
- typeof __dirname !== "undefined"
10
- ? __dirname
11
- : path.dirname(fileURLToPath(import.meta.url));
9
+ typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
12
10
 
13
11
  // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
14
12
  export default defineConfig({
15
13
  test: {
16
- workspace: [
14
+ projects: [
17
15
  {
18
16
  extends: true,
19
17
  plugins: [
20
18
  // The plugin will run tests for the stories defined in your Storybook config
21
19
  // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
22
- storybookTest({ configDir: path.join(dirname, ".storybook") }),
20
+ storybookTest({ configDir: path.join(dirname, '.storybook') }),
23
21
  ],
24
22
  test: {
25
- name: "storybook",
23
+ name: 'storybook',
26
24
  browser: {
27
25
  enabled: true,
28
26
  headless: true,
29
- provider: "playwright",
30
- instances: [{ browser: "chromium" }],
27
+ provider: 'playwright',
28
+ instances: [{ browser: 'chromium' }],
31
29
  },
32
- setupFiles: [".storybook/vitest.setup.js"],
30
+ setupFiles: ['.storybook/vitest.setup.ts'],
33
31
  },
34
32
  },
35
33
  ],
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
6
+ exclude: ['src/**/*.stories.ts', 'src/**/*.stories.tsx'],
7
+ environment: 'node',
8
+ },
9
+ });
@@ -1,72 +0,0 @@
1
-
2
- > @utilitywarehouse/hearth-react-native@0.27.1 lint /home/runner/work/hearth/hearth/packages/react-native
3
- > TIMING=1 eslint .
4
-
5
-
6
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Carousel/Carousel.context.tsx
7
- 6:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
8
-
9
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Carousel/Carousel.tsx
10
- 146:6 warning React Hook useMemo has a missing dependency: 'hasCarouselControlsInTree'. Either include it or remove the dependency array react-hooks/exhaustive-deps
11
-
12
- /home/runner/work/hearth/hearth/packages/react-native/src/components/DatePicker/DatePicker.tsx
13
- 109:6 warning React Hook useCallback has an unnecessary dependency: 'modalRef.current'. Either exclude it or remove the dependency array. Mutable values like 'modalRef.current' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
14
- 259:6 warning React Hook useEffect has a missing dependency: 'initialState'. Either include it or remove the dependency array react-hooks/exhaustive-deps
15
- 346:6 warning React Hook useEffect has a missing dependency: 'onChange'. Either include it or remove the dependency array react-hooks/exhaustive-deps
16
- 468:5 warning React Hook useCallback has a missing dependency: 'onChange'. Either include it or remove the dependency array react-hooks/exhaustive-deps
17
- 536:6 warning React Hook useEffect has a missing dependency: 'onSelectMonth'. Either include it or remove the dependency array react-hooks/exhaustive-deps
18
- 542:6 warning React Hook useEffect has a missing dependency: 'onSelectYear'. Either include it or remove the dependency array react-hooks/exhaustive-deps
19
-
20
- /home/runner/work/hearth/hearth/packages/react-native/src/components/DatePicker/DatePickerDay.tsx
21
- 76:6 warning React Hook useMemo has an unnecessary dependency: 'styles.rangeRoot'. Either exclude it or remove the dependency array. Outer scope values like 'styles.rangeRoot' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
22
- 84:6 warning React Hook useMemo has a missing dependency: 'isSelected'. Either include it or remove the dependency array react-hooks/exhaustive-deps
23
-
24
- /home/runner/work/hearth/hearth/packages/react-native/src/components/DatePicker/DatePickerDays.tsx
25
- 179:6 warning React Hook useMemo has unnecessary dependencies: 'month' and 'year'. Either exclude them or remove the dependency array react-hooks/exhaustive-deps
26
-
27
- /home/runner/work/hearth/hearth/packages/react-native/src/components/DatePicker/DatePickerYears.tsx
28
- 52:6 warning React Hook useCallback has a missing dependency: 'containerHeight'. Either include it or remove the dependency array. Outer scope values like 'styles' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
29
-
30
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Input/Input.tsx
31
- 78:8 warning React Hook useEffect has a missing dependency: 'formFieldContext'. Either include it or remove the dependency array react-hooks/exhaustive-deps
32
-
33
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Modal/Modal.tsx
34
- 86:6 warning React Hook useCallback has an unnecessary dependency: 'Platform.OS'. Either exclude it or remove the dependency array. Outer scope values like 'Platform.OS' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
35
- 313:5 warning React Hook useCallback has a missing dependency: 'footer'. Either include it or remove the dependency array react-hooks/exhaustive-deps
36
-
37
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Modal/Modal.web.tsx
38
- 66:6 warning React Hook useCallback has an unnecessary dependency: 'Platform.OS'. Either exclude it or remove the dependency array. Outer scope values like 'Platform.OS' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
39
-
40
- /home/runner/work/hearth/hearth/packages/react-native/src/components/PillGroup/PillGroup.tsx
41
- 17:9 warning The 'normalizedValue' conditional could make the dependencies of useMemo Hook (at line 33) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of 'normalizedValue' in its own useMemo() Hook react-hooks/exhaustive-deps
42
-
43
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Tabs/Tabs.tsx
44
- 53:6 warning React Hook useEffect has a missing dependency: 'tabValues'. Either include it or remove the dependency array react-hooks/exhaustive-deps
45
- 53:7 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps
46
- 104:5 warning React Hook useMemo has an unnecessary dependency: 'tabValues'. Either exclude it or remove the dependency array react-hooks/exhaustive-deps
47
- 127:62 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps
48
-
49
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Textarea/Textarea.tsx
50
- 45:6 warning React Hook useEffect has a missing dependency: 'formFieldContext'. Either include it or remove the dependency array react-hooks/exhaustive-deps
51
-
52
- /home/runner/work/hearth/hearth/packages/react-native/src/components/Toast/Toast.context.tsx
53
- 14:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
54
- 106:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
55
-
56
- /home/runner/work/hearth/hearth/packages/react-native/src/components/VerificationInput/VerificationInput.tsx
57
- 174:7 warning React Hook useImperativeHandle has a missing dependency: 'updateValue'. Either include it or remove the dependency array react-hooks/exhaustive-deps
58
-
59
- ✖ 25 problems (0 errors, 25 warnings)
60
-
61
- Rule | Time (ms) | Relative
62
- :-----------------------------------------|----------:|--------:
63
- @typescript-eslint/no-unused-vars | 1499.293 | 61.6%
64
- react-hooks/exhaustive-deps | 127.430 | 5.2%
65
- react-hooks/rules-of-hooks | 84.158 | 3.5%
66
- no-global-assign | 65.981 | 2.7%
67
- no-unexpected-multiline | 44.926 | 1.8%
68
- @typescript-eslint/ban-ts-comment | 39.212 | 1.6%
69
- @typescript-eslint/triple-slash-reference | 36.913 | 1.5%
70
- no-misleading-character-class | 36.268 | 1.5%
71
- no-useless-escape | 28.431 | 1.2%
72
- @typescript-eslint/no-unused-expressions | 25.564 | 1.1%