@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.
- package/.storybook/vitest.setup.ts +35 -3
- package/.turbo/turbo-build.log +5 -4
- package/build/components/Carousel/Carousel.js +6 -1
- package/build/components/List/List.js +2 -2
- package/build/components/SegmentedControl/SegmentedControl.js +4 -1
- package/build/components/SegmentedControl/SegmentedControlOption.js +4 -1
- package/build/components/TimePicker/TimePickerWheel.js +9 -1
- package/build/components/Toast/Toast.context.js +1 -1
- package/build/components/VerificationInput/VerificationInput.js +10 -21
- package/build/components/VerificationInput/VerificationInput.utils.d.ts +8 -0
- package/build/components/VerificationInput/VerificationInput.utils.js +17 -0
- package/build/components/VerificationInput/VerificationInput.utils.test.d.ts +1 -0
- package/build/components/VerificationInput/VerificationInput.utils.test.js +36 -0
- package/docs/changelog.mdx +113 -0
- package/package.json +5 -4
- package/src/components/Carousel/Carousel.tsx +6 -2
- package/src/components/IconContainer/IconContainer.stories.tsx +35 -30
- package/src/components/List/List.tsx +5 -4
- package/src/components/SegmentedControl/SegmentedControl.tsx +4 -1
- package/src/components/SegmentedControl/SegmentedControlOption.tsx +5 -4
- package/src/components/TimePicker/TimePickerWheel.tsx +11 -4
- package/src/components/Toast/Toast.context.tsx +1 -1
- package/src/components/VerificationInput/VerificationInput.stories.tsx +33 -0
- package/src/components/VerificationInput/VerificationInput.tsx +16 -29
- package/src/components/VerificationInput/VerificationInput.utils.test.ts +48 -0
- package/src/components/VerificationInput/VerificationInput.utils.ts +32 -0
- package/tsconfig.eslint.json +2 -1
- package/vitest.config.js +11 -13
- package/vitest.unit.config.ts +9 -0
- 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
|
|
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]);
|
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
>
|
|
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,
|
|
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, { ...
|
|
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, {
|
|
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, {
|
|
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
|
|
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, {
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
package/docs/changelog.mdx
CHANGED
|
@@ -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.
|
|
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-
|
|
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={[
|
|
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-
|
|
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 = {
|
|
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`}>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
color
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 {...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]}
|
|
50
|
-
<View
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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 {
|
|
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 = (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
+
};
|
package/tsconfig.eslint.json
CHANGED
package/vitest.config.js
CHANGED
|
@@ -1,35 +1,33 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import { fileURLToPath } from
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
3
|
|
|
4
|
-
import { defineConfig } from
|
|
4
|
+
import { defineConfig } from 'vitest/config';
|
|
5
5
|
|
|
6
|
-
import { storybookTest } from
|
|
6
|
+
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
|
7
7
|
|
|
8
8
|
const dirname =
|
|
9
|
-
typeof __dirname !==
|
|
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
|
-
|
|
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,
|
|
20
|
+
storybookTest({ configDir: path.join(dirname, '.storybook') }),
|
|
23
21
|
],
|
|
24
22
|
test: {
|
|
25
|
-
name:
|
|
23
|
+
name: 'storybook',
|
|
26
24
|
browser: {
|
|
27
25
|
enabled: true,
|
|
28
26
|
headless: true,
|
|
29
|
-
provider:
|
|
30
|
-
instances: [{ browser:
|
|
27
|
+
provider: 'playwright',
|
|
28
|
+
instances: [{ browser: 'chromium' }],
|
|
31
29
|
},
|
|
32
|
-
setupFiles: [
|
|
30
|
+
setupFiles: ['.storybook/vitest.setup.ts'],
|
|
33
31
|
},
|
|
34
32
|
},
|
|
35
33
|
],
|
package/.turbo/turbo-lint.log
DELETED
|
@@ -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%
|