@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
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useMemo, useRef } from 'react';
3
+ import { Animated, PanResponder, Platform, View } from 'react-native';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { isEqual } from '../../../utils';
6
+ import { BodyText } from '../../BodyText';
7
+ import { CONTAINER_HEIGHT } from '../enums';
8
+ import { sin } from './animated-math';
9
+ const ITEM_HEIGHT = 44;
10
+ const WheelWeb = ({ value, setValue = () => { }, items }) => {
11
+ const displayCount = 5;
12
+ const translateY = useRef(new Animated.Value(0)).current;
13
+ const renderCount = displayCount * 2 < items.length ? displayCount * 8 : displayCount * 2 - 1;
14
+ const circular = items.length >= displayCount;
15
+ const height = 140;
16
+ const radius = height / 2;
17
+ const valueIndex = useMemo(() => {
18
+ return Math.max(0, items.findIndex(item => item.value === value));
19
+ }, [items, value]);
20
+ const panResponder = useMemo(() => {
21
+ return PanResponder.create({
22
+ onMoveShouldSetPanResponder: () => true,
23
+ onStartShouldSetPanResponderCapture: () => true,
24
+ onPanResponderGrant: () => {
25
+ translateY.setValue(0);
26
+ },
27
+ onPanResponderMove: (evt, gestureState) => {
28
+ translateY.setValue(gestureState.dy);
29
+ evt.stopPropagation();
30
+ },
31
+ onPanResponderRelease: (_, gestureState) => {
32
+ translateY.extractOffset();
33
+ let newValueIndex = valueIndex - Math.round(gestureState.dy / ((radius * 2) / displayCount));
34
+ if (circular) {
35
+ newValueIndex = (newValueIndex + items.length) % items.length;
36
+ }
37
+ else {
38
+ if (newValueIndex < 0) {
39
+ newValueIndex = 0;
40
+ }
41
+ else if (newValueIndex >= items.length) {
42
+ newValueIndex = items.length - 1;
43
+ }
44
+ }
45
+ const newValue = items[newValueIndex];
46
+ if (newValue?.value === value) {
47
+ translateY.setOffset(0);
48
+ translateY.setValue(0);
49
+ }
50
+ else if (newValue?.value) {
51
+ setValue(newValue.value);
52
+ }
53
+ else if (items[0]?.value) {
54
+ setValue(items[0].value);
55
+ }
56
+ },
57
+ });
58
+ }, [circular, displayCount, radius, setValue, value, valueIndex, items, translateY]);
59
+ const displayValues = useMemo(() => {
60
+ const centerIndex = Math.floor(renderCount / 2);
61
+ return Array.from({ length: renderCount }, (_, index) => {
62
+ let targetIndex = valueIndex + index - centerIndex;
63
+ if (circular) {
64
+ targetIndex = ((targetIndex % items.length) + items.length) % items.length;
65
+ }
66
+ else {
67
+ targetIndex = Math.max(0, Math.min(targetIndex, items.length - 1));
68
+ }
69
+ return items[targetIndex] || items[0];
70
+ });
71
+ }, [renderCount, valueIndex, items, circular]);
72
+ const animatedAngles = useMemo(() => {
73
+ //translateY.setValue(0);
74
+ translateY.setOffset(0);
75
+ const currentIndex = displayValues.findIndex(item => item?.value === value);
76
+ return displayValues && displayValues.length > 0
77
+ ? displayValues.map((_, index) => translateY
78
+ .interpolate({
79
+ inputRange: [-radius, radius],
80
+ outputRange: [
81
+ -radius + ((radius * 2) / displayCount) * (index - currentIndex),
82
+ radius + ((radius * 2) / displayCount) * (index - currentIndex),
83
+ ],
84
+ extrapolate: 'extend',
85
+ })
86
+ .interpolate({
87
+ inputRange: [-radius, radius],
88
+ outputRange: [-Math.PI / 2, Math.PI / 2],
89
+ extrapolate: 'clamp',
90
+ }))
91
+ : [];
92
+ }, [displayValues, radius, value, displayCount, translateY]);
93
+ return (_jsxs(View, { style: [styles.container], ...panResponder.panHandlers, children: [_jsx(View, { style: [
94
+ styles.selectedIndicator,
95
+ {
96
+ transform: [{ translateY: -ITEM_HEIGHT / 2 }],
97
+ height: ITEM_HEIGHT,
98
+ },
99
+ ] }), displayValues?.map((displayValue, index) => {
100
+ const animatedAngle = animatedAngles[index];
101
+ return (_jsx(Animated.View, { style: {
102
+ position: 'absolute',
103
+ height: ITEM_HEIGHT - 10,
104
+ transform: animatedAngle
105
+ ? [
106
+ {
107
+ translateY: Animated.multiply(radius, sin(animatedAngle)),
108
+ },
109
+ {
110
+ rotateX: animatedAngle.interpolate({
111
+ inputRange: [-Math.PI / 2, Math.PI / 2],
112
+ outputRange: ['-89deg', '89deg'],
113
+ extrapolate: 'clamp',
114
+ }),
115
+ },
116
+ ]
117
+ : [],
118
+ opacity: displayValue?.value !== value ? 0.3 : 1,
119
+ }, children: _jsx(BodyText, { children: displayValue?.text }) }, `${displayValue?.text}-${index}`));
120
+ })] }));
121
+ };
122
+ const styles = StyleSheet.create({
123
+ container: {
124
+ minWidth: 30,
125
+ overflow: 'hidden',
126
+ alignItems: 'center',
127
+ justifyContent: 'center',
128
+ height: CONTAINER_HEIGHT / 2,
129
+ ...Platform.select({
130
+ web: {
131
+ cursor: 'pointer',
132
+ userSelect: 'none',
133
+ },
134
+ }),
135
+ },
136
+ selectedIndicator: {
137
+ position: 'absolute',
138
+ width: '100%',
139
+ top: '50%',
140
+ },
141
+ });
142
+ const customComparator = (prev, next) => {
143
+ const areEqual = prev.value === next.value && prev.setValue === next.setValue && isEqual(prev.items, next.items);
144
+ return areEqual;
145
+ };
146
+ export default memo(WheelWeb, customComparator);
@@ -0,0 +1,8 @@
1
+ import { PickerOption } from '../DatePicker.props';
2
+ type WheelProps = {
3
+ value: number | string;
4
+ setValue?: (value: any) => void;
5
+ items: PickerOption[];
6
+ };
7
+ declare const _default: import("react").MemoExoticComponent<(props: WheelProps) => import("react/jsx-runtime").JSX.Element>;
8
+ export default _default;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { memo } from 'react';
3
+ import { Platform } from 'react-native';
4
+ import WheelNative from './wheel-native';
5
+ import WheelWeb from './wheel-web';
6
+ const Wheel = (props) => {
7
+ const Component = Platform.OS === 'web' ? WheelWeb : WheelNative;
8
+ return _jsx(Component, { ...props });
9
+ };
10
+ export default memo(Wheel);
@@ -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 => ({
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { BottomSheetFooter, } from '@gorhom/bottom-sheet';
3
3
  import { CloseMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
4
4
  import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
5
- import { AccessibilityInfo, Dimensions, Platform, ScrollView, View, findNodeHandle } from 'react-native';
5
+ import { AccessibilityInfo, Dimensions, Platform, ScrollView, View, findNodeHandle, } from 'react-native';
6
6
  import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated';
7
7
  import { StyleSheet } from 'react-native-unistyles';
8
8
  import { useTheme } from '../../hooks';
@@ -120,7 +120,12 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
120
120
  });
121
121
  const footer = (_jsxs(View, { style: styles.footer, children: [onPressPrimaryButton && primaryButtonText ? (_jsx(Button, { onPress: handlePrimaryButtonPress, text: primaryButtonText, inverted: isBrandBackground && inNavModal, ...primaryButtonProps, variant: primaryButtonProps?.variant ?? 'solid', colorScheme: primaryButtonProps?.colorScheme ?? 'highlight' })) : null, onPressSecondaryButton && secondaryButtonText ? (_jsx(Button, { onPress: handleSecondaryButtonPress, text: secondaryButtonText, inverted: isBrandBackground && inNavModal, ...secondaryButtonProps, variant: secondaryButtonProps?.variant ?? 'outline', colorScheme: secondaryButtonProps?.colorScheme ?? 'functional' })) : null] }));
122
122
  const InNavModalContainer = scrollable ? ScrollView : View;
123
- const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Loading' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsx(Spinner, { size: "lg", color: isBrandBackground && inNavModal ? theme.color.icon.inverted : undefined }), _jsx(Heading, { size: "lg", textAlign: "center", inverted: isBrandBackground && inNavModal, children: loadingHeading })] })) : (_jsxs(View, { style: styles.container, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Modal content' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsxs(View, { style: styles.header, children: [_jsxs(View, { style: styles.headerTextContent, children: [heading && !image ? (_jsx(Heading, { size: "lg", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description && !image ? (_jsx(BodyText, { accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", inverted: isBrandBackground && inNavModal, ...closeButtonProps })) : null] }), image ? (_jsxs(View, { style: styles.imageContainer, children: [image, _jsxs(View, { style: styles.textContent, children: [heading ? (_jsx(Heading, { size: "lg", textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] })] })) : null, inNavModal && (_jsxs(InNavModalContainer, { style: { flex: stickyFooter ? 1 : 0 }, children: [children, !stickyFooter ? _jsx(View, { style: styles.inNavModalFooterContainer, children: footer }) : null] })), !inNavModal && children, ((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons ? footer : null] })) }));
123
+ const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Loading' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsx(Spinner, { size: "lg", color: isBrandBackground && inNavModal ? theme.color.icon.inverted : undefined }), _jsx(Heading, { size: "lg", textAlign: "center", inverted: isBrandBackground && inNavModal, children: loadingHeading })] })) : (_jsxs(View, { style: styles.container, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Modal content' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsxs(View, { style: styles.header, children: [_jsxs(View, { style: styles.headerTextContent, children: [heading && !image ? (_jsx(Heading, { size: "lg", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description && !image ? (_jsx(BodyText, { accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", inverted: isBrandBackground && inNavModal, ...closeButtonProps })) : null] }), image ? (_jsxs(View, { style: styles.imageContainer, children: [image, _jsxs(View, { style: styles.textContent, children: [heading ? (_jsx(Heading, { size: "lg", textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] })] })) : null, inNavModal && (_jsxs(InNavModalContainer, { style: {
124
+ flex: stickyFooter ? 1 : 0,
125
+ ...(scrollable ? { marginHorizontal: -1 } : {}),
126
+ }, ...(scrollable ? { contentContainerStyle: { paddingHorizontal: 1 } } : {}), children: [children, !stickyFooter ? (_jsx(View, { style: styles.inNavModalFooterContainer, children: footer })) : null] })), !inNavModal && children, ((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons
127
+ ? footer
128
+ : null] })) }));
124
129
  const renderFooter = useCallback((props) => (_jsx(BottomSheetFooter, { ...props, children: _jsx(View, { style: styles.footerWrap, children: footer }) })), [
125
130
  onPressPrimaryButton,
126
131
  primaryButtonText,
@@ -129,7 +134,7 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
129
134
  primaryButtonProps,
130
135
  secondaryButtonProps,
131
136
  ]);
132
- return inNavModal ? (_jsxs(View, { onLayout: (e) => {
137
+ return inNavModal ? (_jsxs(View, { onLayout: e => {
133
138
  setInNavModalHeight(e.nativeEvent.layout.height);
134
139
  }, style: {
135
140
  flex: 1,
@@ -240,7 +245,7 @@ const styles = StyleSheet.create((theme, rt) => ({
240
245
  borderTopLeftRadius: theme.components.modal.borderRadius,
241
246
  borderTopRightRadius: theme.components.modal.borderRadius,
242
247
  backgroundColor: theme.color.surface.neutral.strong,
243
- paddingBottom: theme.components.modal.padding + rt.insets.bottom,
248
+ paddingBottom: theme.components.bottomSheet.padding + rt.insets.bottom,
244
249
  variants: {
245
250
  background: {
246
251
  primary: {},
@@ -250,22 +255,22 @@ const styles = StyleSheet.create((theme, rt) => ({
250
255
  },
251
256
  fullscreen: {
252
257
  true: {
253
- padding: theme.components.modal.padding,
258
+ padding: theme.components.bottomSheet.padding,
254
259
  paddingTop: rt.insets.top,
255
260
  },
256
261
  false: {
257
- padding: theme.components.modal.padding,
258
- }
259
- }
262
+ padding: theme.components.bottomSheet.padding,
263
+ },
264
+ },
260
265
  },
261
266
  },
262
267
  inNavModalFooterContainer: {
263
- paddingTop: theme.components.modal.padding,
268
+ paddingTop: theme.components.bottomSheet.padding,
264
269
  },
265
270
  androidContainer: {
266
271
  height: rt.insets.top + 18,
267
- paddingLeft: theme.components.modal.padding,
268
- paddingRight: theme.components.modal.padding,
272
+ paddingLeft: theme.components.bottomSheet.padding,
273
+ paddingRight: theme.components.bottomSheet.padding,
269
274
  justifyContent: 'flex-end',
270
275
  },
271
276
  pretendContent: {
@@ -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,10 +1,11 @@
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
- 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
+ const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVariant = 'body', helperText, helperIcon, validationStatus = 'initial', validText, invalidText, disabled = false, readonly = false, secureTextEntry = false, autoFocus = false, style, testID, ...props }, ref) => {
8
9
  const length = 6;
9
10
  const inputRef = useRef(null);
10
11
  const latestValueRef = useRef(value);
@@ -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, testID: testID }), 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