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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.storybook/vitest.setup.ts +35 -3
  2. package/.turbo/turbo-build.log +5 -4
  3. package/CHANGELOG.md +42 -0
  4. package/build/components/Carousel/Carousel.js +6 -1
  5. package/build/components/List/List.js +2 -2
  6. package/build/components/Modal/Modal.js +1 -1
  7. package/build/components/SegmentedControl/SegmentedControl.js +4 -1
  8. package/build/components/SegmentedControl/SegmentedControlOption.js +24 -2
  9. package/build/components/SegmentedControl/SegmentedControlOption.props.d.ts +3 -1
  10. package/build/components/TimePicker/TimePickerWheel.js +9 -1
  11. package/build/components/Toast/Toast.context.js +1 -1
  12. package/build/components/VerificationInput/VerificationInput.js +10 -21
  13. package/build/components/VerificationInput/VerificationInput.utils.d.ts +8 -0
  14. package/build/components/VerificationInput/VerificationInput.utils.js +17 -0
  15. package/build/components/VerificationInput/VerificationInput.utils.test.d.ts +1 -0
  16. package/build/components/VerificationInput/VerificationInput.utils.test.js +36 -0
  17. package/docs/changelog.mdx +113 -0
  18. package/package.json +6 -5
  19. package/src/components/Carousel/Carousel.tsx +6 -2
  20. package/src/components/IconContainer/IconContainer.stories.tsx +35 -30
  21. package/src/components/List/List.tsx +5 -4
  22. package/src/components/Modal/Modal.tsx +1 -1
  23. package/src/components/SegmentedControl/SegmentedControl.docs.mdx +42 -15
  24. package/src/components/SegmentedControl/SegmentedControl.figma.tsx +8 -9
  25. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +21 -0
  26. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -1
  27. package/src/components/SegmentedControl/SegmentedControlOption.props.ts +3 -1
  28. package/src/components/SegmentedControl/SegmentedControlOption.tsx +58 -34
  29. package/src/components/Select/SelectOption.figma.tsx +2 -2
  30. package/src/components/TimePicker/TimePickerWheel.tsx +11 -4
  31. package/src/components/Toast/Toast.context.tsx +1 -1
  32. package/src/components/VerificationInput/VerificationInput.stories.tsx +33 -0
  33. package/src/components/VerificationInput/VerificationInput.tsx +16 -29
  34. package/src/components/VerificationInput/VerificationInput.utils.test.ts +48 -0
  35. package/src/components/VerificationInput/VerificationInput.utils.ts +32 -0
  36. package/tsconfig.eslint.json +2 -1
  37. package/vitest.config.js +11 -13
  38. package/vitest.unit.config.ts +9 -0
  39. package/.turbo/turbo-lint.log +0 -72
@@ -1,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.0 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
+
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # @utilitywarehouse/hearth-react-native
2
2
 
3
+ ## 0.27.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#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}`.
8
+
9
+ Corrects the container flex style for `inNavModal` modals with a non-sticky footer, where the UX was not great when scrolling.
10
+
11
+ **Components affected**:
12
+ - `Modal`
13
+
14
+ **Developer changes**:
15
+
16
+ No changes required.
17
+
18
+ - [#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`.
19
+
20
+ This adds an optional `icon` prop to `SegmentedControlOption`, allowing icons to be displayed before option labels in segmented controls.
21
+
22
+ Docs and stories were updated to include icon usage examples.
23
+
24
+ **Components affected**:
25
+ - `SegmentedControlOption`
26
+
27
+ **Developer changes**:
28
+
29
+ No changes required for existing usage.
30
+
31
+ To use the new optional icon prop:
32
+
33
+ ```tsx
34
+ import { SegmentedControl, SegmentedControlOption } from '@utilitywarehouse/hearth-react-native';
35
+ import { ElectricitySmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
36
+
37
+ <SegmentedControl defaultValue="energy">
38
+ <SegmentedControlOption value="energy" icon={ElectricitySmallIcon}>
39
+ Energy
40
+ </SegmentedControlOption>
41
+ <SegmentedControlOption value="broadband">Broadband</SegmentedControlOption>
42
+ </SegmentedControl>;
43
+ ```
44
+
3
45
  ## 0.27.0
4
46
 
5
47
  ### Minor Changes
@@ -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 => ({
@@ -120,7 +120,7 @@ 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: { flexGrow: 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: { flex: stickyFooter ? 1 : 0 }, children: [children, !stickyFooter ? _jsx(View, { style: styles.inNavModalFooterContainer, children: footer }) : null] })), !inNavModal && children, ((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons ? footer : null] })) }));
124
124
  const renderFooter = useCallback((props) => (_jsx(BottomSheetFooter, { ...props, children: _jsx(View, { style: styles.footerWrap, children: footer }) })), [
125
125
  onPressPrimaryButton,
126
126
  primaryButtonText,
@@ -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;
@@ -5,9 +5,10 @@ import { Platform, Pressable, View } from 'react-native';
5
5
  import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated';
6
6
  import { StyleSheet } from 'react-native-unistyles';
7
7
  import { BodyText } from '../BodyText';
8
+ import { Icon } from '../Icon';
8
9
  import { useSegmentedControlContext } from './SegmentedControl.context';
9
10
  const AnimatedView = Animated.createAnimatedComponent(View);
10
- const SegmentedControlOptionRoot = ({ value, children, accessibilityLabel, disabled = false, style, states = {}, ...props }) => {
11
+ const SegmentedControlOptionRoot = ({ value, children, icon, accessibilityLabel, disabled = false, style, states = {}, ...props }) => {
11
12
  const { value: selectedValue, select, disabled: allDisabled, size, registerOptionLayout, } = useSegmentedControlContext();
12
13
  const { active = false } = states;
13
14
  const reducedMotion = useReducedMotion();
@@ -35,7 +36,7 @@ const SegmentedControlOptionRoot = ({ value, children, accessibilityLabel, disab
35
36
  const accessibleLabel = typeof children === 'string' || typeof children === 'number' ? String(children) : value;
36
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'
37
38
  ? { 'aria-label': accessibilityLabel ?? accessibleLabel }
38
- : null), children: _jsxs(View, { style: styles.labelWrap, accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : null), 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 }) })] })] }) }));
39
40
  };
40
41
  const SegmentedControlOption = createPressable({ Root: SegmentedControlOptionRoot });
41
42
  SegmentedControlOption.displayName = 'SegmentedControlOption';
@@ -90,6 +91,12 @@ const styles = StyleSheet.create(theme => ({
90
91
  },
91
92
  },
92
93
  },
94
+ contentWrap: {
95
+ flexDirection: 'row',
96
+ alignItems: 'center',
97
+ justifyContent: 'center',
98
+ gap: theme.components.segmentedControl.gap,
99
+ },
93
100
  labelWrap: {
94
101
  position: 'relative',
95
102
  alignItems: 'center',
@@ -105,6 +112,21 @@ const styles = StyleSheet.create(theme => ({
105
112
  alignItems: 'center',
106
113
  justifyContent: 'center',
107
114
  },
115
+ pointerEventsNone: {
116
+ pointerEvents: 'none',
117
+ },
118
+ icon: {
119
+ variants: {
120
+ selected: {
121
+ true: {
122
+ color: theme.color.icon.inverted,
123
+ },
124
+ false: {
125
+ color: theme.color.icon.primary,
126
+ },
127
+ },
128
+ },
129
+ },
108
130
  textRegular: {
109
131
  color: theme.color.text.primary,
110
132
  },
@@ -1,10 +1,12 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ComponentType, ReactNode } from 'react';
2
2
  import type { PressableProps, ViewProps } from 'react-native';
3
3
  export interface SegmentedControlOptionProps extends Omit<PressableProps, 'children'> {
4
4
  /** Unique option value. */
5
5
  value: string;
6
6
  /** Option label/content. */
7
7
  children: ReactNode;
8
+ /** Optional leading icon. */
9
+ icon?: ComponentType<any>;
8
10
  /** Disables only this option. */
9
11
  disabled?: boolean;
10
12
  style?: ViewProps['style'];
@@ -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.0",
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-fonts": "^0.0.4",
60
- "@utilitywarehouse/hearth-react-native-icons": "^0.8.0",
61
59
  "@utilitywarehouse/hearth-svg-assets": "^0.5.0",
62
- "@utilitywarehouse/hearth-tokens": "^0.2.3",
63
- "@utilitywarehouse/hearth-react-icons": "^0.8.0"
60
+ "@utilitywarehouse/hearth-react-native-icons": "^0.8.0",
61
+ "@utilitywarehouse/hearth-react-icons": "^0.8.0",
62
+ "@utilitywarehouse/hearth-fonts": "^0.0.4",
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
  >