@utilitywarehouse/hearth-react-native 0.22.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-lint.log +14 -14
- package/CHANGELOG.md +15 -0
- package/build/components/VerificationInput/VerificationInput.js +182 -20
- package/build/components/VerificationInput/VerificationInputSlot.d.ts +7 -3
- package/build/components/VerificationInput/VerificationInputSlot.js +45 -7
- package/docs/changelog.mdx +249 -0
- package/package.json +3 -3
- package/src/components/VerificationInput/VerificationInput.tsx +218 -29
- package/src/components/VerificationInput/VerificationInputSlot.tsx +90 -14
- package/build/components/VerificationInput/useVerificationInput.d.ts +0 -15
- package/build/components/VerificationInput/useVerificationInput.js +0 -73
- package/src/components/VerificationInput/useVerificationInput.ts +0 -88
package/.turbo/turbo-build.log
CHANGED
package/.turbo/turbo-lint.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @utilitywarehouse/hearth-react-native@0.22.
|
|
2
|
+
> @utilitywarehouse/hearth-react-native@0.22.1 lint /home/runner/work/hearth/hearth/packages/react-native
|
|
3
3
|
> TIMING=1 eslint .
|
|
4
4
|
|
|
5
5
|
|
|
@@ -54,19 +54,19 @@
|
|
|
54
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
55
|
|
|
56
56
|
/home/runner/work/hearth/hearth/packages/react-native/src/components/VerificationInput/VerificationInput.tsx
|
|
57
|
-
|
|
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
58
|
|
|
59
59
|
✖ 25 problems (0 errors, 25 warnings)
|
|
60
60
|
|
|
61
|
-
Rule
|
|
62
|
-
|
|
63
|
-
@typescript-eslint/no-unused-vars
|
|
64
|
-
|
|
65
|
-
react-hooks/exhaustive-deps
|
|
66
|
-
|
|
67
|
-
no-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
no-
|
|
71
|
-
@typescript-eslint/triple-slash-reference
|
|
72
|
-
|
|
61
|
+
Rule | Time (ms) | Relative
|
|
62
|
+
:-----------------------------------------|----------:|--------:
|
|
63
|
+
@typescript-eslint/no-unused-vars | 1480.154 | 63.5%
|
|
64
|
+
no-global-assign | 86.111 | 3.7%
|
|
65
|
+
react-hooks/exhaustive-deps | 81.893 | 3.5%
|
|
66
|
+
react-hooks/rules-of-hooks | 58.807 | 2.5%
|
|
67
|
+
no-misleading-character-class | 53.302 | 2.3%
|
|
68
|
+
@typescript-eslint/ban-ts-comment | 49.364 | 2.1%
|
|
69
|
+
no-unexpected-multiline | 36.564 | 1.6%
|
|
70
|
+
no-fallthrough | 32.890 | 1.4%
|
|
71
|
+
@typescript-eslint/triple-slash-reference | 30.004 | 1.3%
|
|
72
|
+
no-regex-spaces | 26.454 | 1.1%
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @utilitywarehouse/hearth-react-native
|
|
2
2
|
|
|
3
|
+
## 0.22.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#971](https://github.com/utilitywarehouse/hearth/pull/971) [`be1dfeb`](https://github.com/utilitywarehouse/hearth/commit/be1dfebd4b43f2df8ef6c5eaa42a88364e796479) Thanks [@jordmccord](https://github.com/jordmccord)! - 💅 [ENHANCEMENT]: Improve VerificationInput OTP handling and accessibility
|
|
8
|
+
|
|
9
|
+
VerificationInput now uses a single hidden input to manage focus, selection, and paste behaviour across platforms, improving caret handling and bulk entry. Accessibility labels and hints are now derived from the form field to provide clearer screen reader output.
|
|
10
|
+
|
|
11
|
+
**Components affected**:
|
|
12
|
+
- `VerificationInput`
|
|
13
|
+
|
|
14
|
+
**Developer changes**:
|
|
15
|
+
|
|
16
|
+
No changes required.
|
|
17
|
+
|
|
3
18
|
## 0.22.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
|
@@ -1,36 +1,187 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef, useImperativeHandle } from 'react';
|
|
3
|
-
import { View } from 'react-native';
|
|
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';
|
|
4
4
|
import { StyleSheet } from 'react-native-unistyles';
|
|
5
5
|
import { FormField } from '../FormField';
|
|
6
|
-
import { useVerificationInput } from './useVerificationInput';
|
|
7
6
|
import { VerificationInputSlot } from './VerificationInputSlot';
|
|
8
7
|
const VerificationInput = forwardRef(({ value = '', onChangeText, label, labelVariant = 'body', helperText, helperIcon, validationStatus = 'initial', validText, invalidText, disabled = false, readonly = false, secureTextEntry = false, autoFocus = false, style, ...props }, ref) => {
|
|
9
8
|
const length = 6;
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const inputRef = useRef(null);
|
|
10
|
+
const latestValueRef = useRef(value);
|
|
11
|
+
const [displayValue, setDisplayValue] = useState(value);
|
|
12
|
+
const [focusedIndex, setFocusedIndex] = useState(null);
|
|
13
|
+
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
14
|
+
const latestSelectionRef = useRef(selection);
|
|
15
|
+
const ignoreNextSelectionRef = useRef(false);
|
|
16
|
+
const pendingFocusIndexRef = useRef(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (value !== latestValueRef.current) {
|
|
19
|
+
const trimmedValue = value.slice(0, length);
|
|
20
|
+
latestValueRef.current = trimmedValue;
|
|
21
|
+
setDisplayValue(trimmedValue);
|
|
22
|
+
const nextPos = Math.min(trimmedValue.length, length);
|
|
23
|
+
const nextSelection = { start: nextPos, end: nextPos };
|
|
24
|
+
ignoreNextSelectionRef.current = true;
|
|
25
|
+
latestSelectionRef.current = nextSelection;
|
|
26
|
+
setSelection(nextSelection);
|
|
27
|
+
}
|
|
28
|
+
}, [length, value]);
|
|
29
|
+
const updateValue = (nextValue) => {
|
|
30
|
+
const trimmedValue = nextValue.slice(0, length);
|
|
31
|
+
latestValueRef.current = trimmedValue;
|
|
32
|
+
setDisplayValue(trimmedValue);
|
|
33
|
+
onChangeText?.(trimmedValue);
|
|
34
|
+
};
|
|
35
|
+
const setSelectionIndex = (index) => {
|
|
36
|
+
const clampedIndex = Math.max(0, Math.min(index, length));
|
|
37
|
+
const hasChar = !!latestValueRef.current[clampedIndex];
|
|
38
|
+
const endIndex = hasChar ? Math.min(clampedIndex + 1, length) : clampedIndex;
|
|
39
|
+
const nextSelection = { start: clampedIndex, end: endIndex };
|
|
40
|
+
ignoreNextSelectionRef.current = true;
|
|
41
|
+
latestSelectionRef.current = nextSelection;
|
|
42
|
+
setSelection(nextSelection);
|
|
43
|
+
setFocusedIndex(Math.min(clampedIndex, length - 1));
|
|
44
|
+
};
|
|
45
|
+
const setCaretIndex = (index) => {
|
|
46
|
+
const clampedIndex = Math.max(0, Math.min(index, length));
|
|
47
|
+
const nextSelection = { start: clampedIndex, end: clampedIndex };
|
|
48
|
+
ignoreNextSelectionRef.current = true;
|
|
49
|
+
latestSelectionRef.current = nextSelection;
|
|
50
|
+
setSelection(nextSelection);
|
|
51
|
+
setFocusedIndex(Math.min(clampedIndex, length - 1));
|
|
52
|
+
};
|
|
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
|
+
const handleChangeText = (text) => {
|
|
63
|
+
const prevValue = latestValueRef.current;
|
|
64
|
+
const nextValue = text.slice(0, length);
|
|
65
|
+
const prevLength = prevValue.length;
|
|
66
|
+
const nextLength = nextValue.length;
|
|
67
|
+
const diff = nextLength - prevLength;
|
|
68
|
+
const isBulkInsert = text.length > 1 && diff > 1;
|
|
69
|
+
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
|
+
}
|
|
75
|
+
updateValue(nextValue);
|
|
76
|
+
if (isBulkInsert) {
|
|
77
|
+
setCaretIndex(Math.min(nextLength, length));
|
|
78
|
+
if (shouldBlur) {
|
|
79
|
+
inputRef.current?.blur();
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (nextIndex >= length) {
|
|
84
|
+
setCaretIndex(nextIndex);
|
|
85
|
+
if (shouldBlur) {
|
|
86
|
+
inputRef.current?.blur();
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (nextLength >= length) {
|
|
91
|
+
setSelectionIndex(nextIndex);
|
|
92
|
+
inputRef.current?.blur();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const hasNextChar = !!nextValue[nextIndex];
|
|
96
|
+
if (hasNextChar) {
|
|
97
|
+
setSelectionIndex(nextIndex);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
setCaretIndex(nextIndex);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const handleFocus = () => {
|
|
104
|
+
if (pendingFocusIndexRef.current !== null) {
|
|
105
|
+
setSelectionIndex(pendingFocusIndexRef.current);
|
|
106
|
+
pendingFocusIndexRef.current = null;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setFocusedIndex(Math.min(selection.start, length - 1));
|
|
110
|
+
};
|
|
111
|
+
const handleBlur = () => {
|
|
112
|
+
setFocusedIndex(null);
|
|
113
|
+
};
|
|
14
114
|
useImperativeHandle(ref, () => ({
|
|
15
|
-
focus: () =>
|
|
16
|
-
|
|
17
|
-
|
|
115
|
+
focus: () => {
|
|
116
|
+
inputRef.current?.focus();
|
|
117
|
+
const nextIndex = Math.min(latestValueRef.current.length, length - 1);
|
|
118
|
+
if (latestValueRef.current.length >= length) {
|
|
119
|
+
setSelectionIndex(nextIndex);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
setCaretIndex(nextIndex);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
blur: () => inputRef.current?.blur(),
|
|
126
|
+
clear: () => {
|
|
127
|
+
updateValue('');
|
|
128
|
+
setSelectionIndex(0);
|
|
129
|
+
inputRef.current?.blur();
|
|
130
|
+
setFocusedIndex(null);
|
|
18
131
|
},
|
|
19
|
-
clear: () => onChangeText?.(''),
|
|
20
132
|
focusIndex: (index) => {
|
|
21
133
|
if (index >= 0 && index < length) {
|
|
22
|
-
|
|
134
|
+
inputRef.current?.focus();
|
|
135
|
+
setSelectionIndex(index);
|
|
23
136
|
}
|
|
24
137
|
},
|
|
25
138
|
}), [length, onChangeText]);
|
|
26
139
|
const slots = Array.from({ length }, (_, index) => index);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
140
|
+
const getAccessibilityLabel = () => {
|
|
141
|
+
return label || props.accessibilityLabel;
|
|
142
|
+
};
|
|
143
|
+
const getAccessibilityHint = () => {
|
|
144
|
+
let accessibilityHint = '';
|
|
145
|
+
if (helperText) {
|
|
146
|
+
accessibilityHint = accessibilityHint + helperText;
|
|
147
|
+
}
|
|
148
|
+
if (validationStatus !== 'initial') {
|
|
149
|
+
if (accessibilityHint.length > 0) {
|
|
150
|
+
accessibilityHint = accessibilityHint + ', ';
|
|
151
|
+
}
|
|
152
|
+
if (validationStatus === 'invalid' && invalidText) {
|
|
153
|
+
accessibilityHint = accessibilityHint + invalidText;
|
|
154
|
+
}
|
|
155
|
+
if (validationStatus === 'valid' && validText) {
|
|
156
|
+
accessibilityHint = accessibilityHint + validText;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return accessibilityHint || props.accessibilityHint;
|
|
160
|
+
};
|
|
161
|
+
return (_jsx(FormField, { label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validationStatus: validationStatus, validText: validText, invalidText: invalidText, disabled: disabled, readonly: readonly, accessibilityHandledByChildren: true, style: [styles.root, style], ...props, children: _jsxs(View, { style: styles.slotsContainer, children: [_jsx(TextInput, { ref: inputRef, value: displayValue, autoFocus: autoFocus, editable: !disabled && !readonly, accessibilityLabel: getAccessibilityLabel(), accessibilityHint: getAccessibilityHint(), accessibilityState: { disabled: disabled || readonly }, importantForAccessibility: "yes", onChangeText: handleChangeText, onSelectionChange: event => {
|
|
162
|
+
const nextSelection = event.nativeEvent.selection;
|
|
163
|
+
if (ignoreNextSelectionRef.current &&
|
|
164
|
+
(nextSelection.start !== latestSelectionRef.current.start ||
|
|
165
|
+
nextSelection.end !== latestSelectionRef.current.end)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (pendingFocusIndexRef.current !== null) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
ignoreNextSelectionRef.current = false;
|
|
172
|
+
latestSelectionRef.current = nextSelection;
|
|
173
|
+
setSelection(nextSelection);
|
|
174
|
+
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 => {
|
|
176
|
+
const char = displayValue[index] || '';
|
|
177
|
+
const isActive = focusedIndex === index;
|
|
178
|
+
const displayChar = secureTextEntry && char ? '*' : char;
|
|
179
|
+
return (_jsx(VerificationInputSlot, { value: displayChar, isActive: isActive, showCaret: isActive && !displayChar, validationStatus: validationStatus, disabled: disabled, readonly: readonly, secureTextEntry: secureTextEntry, onPress: () => {
|
|
180
|
+
pendingFocusIndexRef.current = index;
|
|
181
|
+
inputRef.current?.focus();
|
|
182
|
+
setSelectionIndex(index);
|
|
183
|
+
} }, index));
|
|
184
|
+
})] }) }));
|
|
34
185
|
});
|
|
35
186
|
const styles = StyleSheet.create(theme => ({
|
|
36
187
|
root: {
|
|
@@ -42,6 +193,17 @@ const styles = StyleSheet.create(theme => ({
|
|
|
42
193
|
flexDirection: 'row',
|
|
43
194
|
gap: theme.components.input.verification.gap,
|
|
44
195
|
width: '100%',
|
|
196
|
+
position: 'relative',
|
|
197
|
+
},
|
|
198
|
+
hiddenInput: {
|
|
199
|
+
position: 'absolute',
|
|
200
|
+
width: '100%',
|
|
201
|
+
height: '100%',
|
|
202
|
+
left: 0,
|
|
203
|
+
top: 0,
|
|
204
|
+
color: 'transparent',
|
|
205
|
+
fontSize: 1,
|
|
206
|
+
opacity: 0.1,
|
|
45
207
|
},
|
|
46
208
|
}));
|
|
47
209
|
VerificationInput.displayName = 'VerificationInput';
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
interface VerificationInputSlotProps extends
|
|
1
|
+
import { View, ViewProps } from 'react-native';
|
|
2
|
+
interface VerificationInputSlotProps extends ViewProps {
|
|
3
|
+
value: string;
|
|
3
4
|
isActive: boolean;
|
|
5
|
+
showCaret?: boolean;
|
|
4
6
|
validationStatus: 'initial' | 'valid' | 'invalid';
|
|
5
7
|
disabled?: boolean;
|
|
6
8
|
readonly?: boolean;
|
|
9
|
+
onPress?: () => void;
|
|
10
|
+
secureTextEntry?: boolean;
|
|
7
11
|
}
|
|
8
|
-
export declare const VerificationInputSlot: import("react").ForwardRefExoticComponent<VerificationInputSlotProps & import("react").RefAttributes<
|
|
12
|
+
export declare const VerificationInputSlot: import("react").ForwardRefExoticComponent<VerificationInputSlotProps & import("react").RefAttributes<View>>;
|
|
9
13
|
export {};
|
|
@@ -1,28 +1,46 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect } from 'react';
|
|
3
|
+
import { Pressable, Text } from 'react-native';
|
|
4
|
+
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated';
|
|
3
5
|
import { StyleSheet } from 'react-native-unistyles';
|
|
4
|
-
|
|
5
|
-
export const VerificationInputSlot = forwardRef(({ isActive, validationStatus, disabled, readonly, style, ...props }, ref) => {
|
|
6
|
+
export const VerificationInputSlot = forwardRef(({ value, isActive, showCaret, validationStatus, disabled, readonly, style, onPress, secureTextEntry, ...props }, ref) => {
|
|
6
7
|
styles.useVariants({
|
|
7
8
|
disabled,
|
|
8
9
|
readonly,
|
|
9
10
|
validationStatus,
|
|
10
11
|
active: isActive,
|
|
12
|
+
secureTextEntry,
|
|
11
13
|
});
|
|
12
|
-
|
|
14
|
+
const caretOpacity = useSharedValue(0);
|
|
15
|
+
const animatedCaretStyle = useAnimatedStyle(() => ({
|
|
16
|
+
opacity: caretOpacity.value,
|
|
17
|
+
}));
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (showCaret && !disabled && !readonly) {
|
|
20
|
+
caretOpacity.value = withRepeat(withTiming(1, { duration: 500, easing: Easing.inOut(Easing.ease) }), -1, true);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
caretOpacity.value = withTiming(0, { duration: 150, easing: Easing.out(Easing.ease) });
|
|
24
|
+
}, [caretOpacity, disabled, readonly, showCaret]);
|
|
25
|
+
return (_jsxs(Pressable, { ref: ref, onPress: onPress, disabled: disabled || readonly, style: [styles.slot, style], accessibilityRole: "button", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...props, children: [_jsx(Text, { style: styles.slotText, children: value }), showCaret && !disabled && !readonly && (_jsx(Animated.View, { style: [styles.caret, animatedCaretStyle] }))] }));
|
|
13
26
|
});
|
|
14
27
|
VerificationInputSlot.displayName = 'VerificationInputSlot';
|
|
15
28
|
const styles = StyleSheet.create(theme => ({
|
|
16
29
|
slot: {
|
|
17
|
-
|
|
30
|
+
flexGrow: 0,
|
|
31
|
+
flexShrink: 0,
|
|
18
32
|
width: theme.components.input.height,
|
|
19
33
|
height: theme.components.input.height,
|
|
34
|
+
minWidth: theme.components.input.height,
|
|
35
|
+
minHeight: theme.components.input.height,
|
|
20
36
|
borderWidth: theme.components.input.borderWidth,
|
|
21
37
|
borderColor: theme.color.border.strong,
|
|
22
38
|
borderRadius: theme.components.input.borderRadius,
|
|
23
39
|
backgroundColor: theme.color.surface.neutral.strong,
|
|
24
|
-
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
justifyContent: 'center',
|
|
25
42
|
padding: 0,
|
|
43
|
+
position: 'relative',
|
|
26
44
|
variants: {
|
|
27
45
|
disabled: {
|
|
28
46
|
true: {
|
|
@@ -69,4 +87,24 @@ const styles = StyleSheet.create(theme => ({
|
|
|
69
87
|
},
|
|
70
88
|
],
|
|
71
89
|
},
|
|
90
|
+
slotText: {
|
|
91
|
+
color: theme.color.text.primary,
|
|
92
|
+
fontSize: theme.typography.mobile.bodyText.md.fontSize,
|
|
93
|
+
fontFamily: theme.typography.mobile.bodyText.fontFamily,
|
|
94
|
+
fontWeight: `${theme.typography.mobile.bodyText.fontWeight}`,
|
|
95
|
+
textAlign: 'center',
|
|
96
|
+
variants: {
|
|
97
|
+
secureTextEntry: {
|
|
98
|
+
true: {
|
|
99
|
+
paddingTop: 5,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
caret: {
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
width: 2,
|
|
107
|
+
height: '55%',
|
|
108
|
+
backgroundColor: theme.color.text.brand,
|
|
109
|
+
},
|
|
72
110
|
}));
|
package/docs/changelog.mdx
CHANGED
|
@@ -9,6 +9,255 @@ 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.21.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- [#917](https://github.com/utilitywarehouse/hearth/pull/917) [`6a016dc`](https://github.com/utilitywarehouse/hearth/commit/6a016dca0d1a06e40a877da15aced590d0c68112) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: Add 2xl size variant to Heading component
|
|
17
|
+
|
|
18
|
+
The `Heading` component now supports a `2xl` size option, providing a larger heading size for prominent page titles and hero sections. This size is responsive across device sizes with appropriate font sizes and line heights for mobile, tablet, and desktop viewports.
|
|
19
|
+
|
|
20
|
+
**Components affected**:
|
|
21
|
+
- `Heading`
|
|
22
|
+
|
|
23
|
+
**Developer changes**:
|
|
24
|
+
|
|
25
|
+
Use the new `2xl` size prop:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
<Heading size="2xl">Welcome to Hearth</Heading>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The `2xl` size will render with:
|
|
32
|
+
- Mobile: 44px font size, 52px line height
|
|
33
|
+
- Tablet: 44px font size, 52px line height
|
|
34
|
+
- Desktop: 54px font size, 62px line height
|
|
35
|
+
|
|
36
|
+
- [#949](https://github.com/utilitywarehouse/hearth/pull/949) [`e1aacf0`](https://github.com/utilitywarehouse/hearth/commit/e1aacf06a58fd8358e9e7546ec35d8194a0d8d74) Thanks [@MichalCiesliczka](https://github.com/MichalCiesliczka)! - 🌟 [FEATURE]: Add segment refs to `DateInput` for programmatic focus control
|
|
37
|
+
|
|
38
|
+
The `DateInput` component now supports direct refs for each segment input via `dayRef`, `monthRef`, and `yearRef`.
|
|
39
|
+
This makes it easier to move focus between segments from custom flows (for example, advancing focus after validation or from custom buttons).
|
|
40
|
+
|
|
41
|
+
Documentation and Storybook examples are also updated to show how to use segment refs in real usage.
|
|
42
|
+
|
|
43
|
+
**Components affected**:
|
|
44
|
+
- `DateInput`
|
|
45
|
+
|
|
46
|
+
**Developer changes**:
|
|
47
|
+
|
|
48
|
+
You can now pass refs to each segment and call `.focus()` when needed:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { useRef, useState } from 'react';
|
|
52
|
+
import { TextInput } from 'react-native';
|
|
53
|
+
import { Button, DateInput } from '@utilitywarehouse/hearth-react-native';
|
|
54
|
+
|
|
55
|
+
const DateWithSegmentFocus = () => {
|
|
56
|
+
const [day, setDay] = useState('');
|
|
57
|
+
const [month, setMonth] = useState('');
|
|
58
|
+
const [year, setYear] = useState('');
|
|
59
|
+
|
|
60
|
+
const dayRef = useRef<TextInput>(null);
|
|
61
|
+
const monthRef = useRef<TextInput>(null);
|
|
62
|
+
const yearRef = useRef<TextInput>(null);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<DateInput
|
|
67
|
+
label="Date of birth"
|
|
68
|
+
dayValue={day}
|
|
69
|
+
monthValue={month}
|
|
70
|
+
yearValue={year}
|
|
71
|
+
onDayChange={setDay}
|
|
72
|
+
onMonthChange={setMonth}
|
|
73
|
+
onYearChange={setYear}
|
|
74
|
+
dayRef={dayRef}
|
|
75
|
+
monthRef={monthRef}
|
|
76
|
+
yearRef={yearRef}
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<Button onPress={() => monthRef.current?.focus()}>Focus month</Button>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This is a non-breaking enhancement, so existing `DateInput` usage continues to work without any changes.
|
|
86
|
+
|
|
87
|
+
- [#918](https://github.com/utilitywarehouse/hearth/pull/918) [`2db4dbe`](https://github.com/utilitywarehouse/hearth/commit/2db4dbe273583239b148c4399af829df596a00c1) Thanks [@jordmccord](https://github.com/jordmccord)! - 💔 [BREAKING CHANGE]: Simplify semantic token naming and introduce utility prop types
|
|
88
|
+
|
|
89
|
+
This release simplifies the semantic token naming convention and introduces a new utility prop system to make the API more intuitive and consistent across components.
|
|
90
|
+
|
|
91
|
+
**Components affected**:
|
|
92
|
+
- `Box`
|
|
93
|
+
- `Container`
|
|
94
|
+
- `Card`
|
|
95
|
+
- `Flex`
|
|
96
|
+
- `Grid`
|
|
97
|
+
- `Center`
|
|
98
|
+
- `BodyText`
|
|
99
|
+
- `Heading`
|
|
100
|
+
- `DetailText`
|
|
101
|
+
- `Carousel`
|
|
102
|
+
- `CarouselItem`
|
|
103
|
+
|
|
104
|
+
**Developer changes**:
|
|
105
|
+
|
|
106
|
+
### Background Colors
|
|
107
|
+
|
|
108
|
+
Background color props now accept simplified semantic tokens. Update your code as follows:
|
|
109
|
+
|
|
110
|
+
```diff
|
|
111
|
+
- <Box backgroundColor="backgroundPrimary">
|
|
112
|
+
+ <Box backgroundColor="primary">
|
|
113
|
+
|
|
114
|
+
- <Box backgroundColor="backgroundSecondary">
|
|
115
|
+
+ <Box backgroundColor="secondary">
|
|
116
|
+
|
|
117
|
+
- <Box backgroundColor="backgroundBrand">
|
|
118
|
+
+ <Box backgroundColor="brand">
|
|
119
|
+
|
|
120
|
+
- <Container bg="backgroundPrimary">
|
|
121
|
+
+ <Container bg="primary">
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
You can still use full color tokens (e.g., `backgroundColor={color.blue[400]}`) by using a `StyleSheet`, the `useTheme` hook, or directly importing from the tokens library:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { StyleSheet } from 'react-native';
|
|
128
|
+
|
|
129
|
+
const styles = StyleSheet.create(theme => ({
|
|
130
|
+
customBackground: {
|
|
131
|
+
backgroundColor: theme.color.blue[400],
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
<Box style={styles.customBackground} />;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { useTheme } from '@utilitywarehouse/hearth-react-native';
|
|
140
|
+
|
|
141
|
+
const theme = useTheme();
|
|
142
|
+
|
|
143
|
+
<Box backgroundColor={theme.color.purple[800]} />;
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { color } from '@utilitywarehouse/hearth-tokens';
|
|
148
|
+
|
|
149
|
+
<Box backgroundColor={color.blue[400]} />;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Text Colors
|
|
153
|
+
|
|
154
|
+
Text color props now accept simplified semantic tokens:
|
|
155
|
+
|
|
156
|
+
```diff
|
|
157
|
+
- <BodyText color="white">Text</BodyText>
|
|
158
|
+
+ <BodyText color="inverted">Text</BodyText>
|
|
159
|
+
|
|
160
|
+
- <BodyText color="grey1000">Text</BodyText>
|
|
161
|
+
+ <BodyText color="primary">Text</BodyText>
|
|
162
|
+
|
|
163
|
+
- <Heading color="textSecondary">Heading</Heading>
|
|
164
|
+
+ <Heading color="secondary">Heading</Heading>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Border Colors
|
|
168
|
+
|
|
169
|
+
Border color props now accept simplified semantic tokens:
|
|
170
|
+
|
|
171
|
+
```diff
|
|
172
|
+
- <Box borderColor="grey800">
|
|
173
|
+
+ <Box borderColor="strong">
|
|
174
|
+
|
|
175
|
+
- <Box borderColor="borderSubtle">
|
|
176
|
+
+ <Box borderColor="subtle">
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Utility Props
|
|
180
|
+
|
|
181
|
+
Components now support consistent utility props through shared type interfaces. The following components have been updated to support additional utility props:
|
|
182
|
+
- **Container**: Added `MarginProps`, `PaddingProps`, `GapProps`, and `BackgroundColorProps`
|
|
183
|
+
- **Card**: Added `MarginProps` and `GapProps`
|
|
184
|
+
- **Flex**: Now properly supports `MarginProps`, `PaddingProps`, and `GapProps`
|
|
185
|
+
- **Text components** (BodyText, Heading, DetailText): Now support `MarginProps`
|
|
186
|
+
|
|
187
|
+
This means you can now use margin utilities directly on these components:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
<BodyText mt="200" mb="100">Text with margin utilities</BodyText>
|
|
191
|
+
<Container mx="300" py="200">Container with spacing utilities</Container>
|
|
192
|
+
<Card mt="200" gap="100">Card with margin and gap utilities</Card>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Migration guide**:
|
|
196
|
+
1. Replace semantic background color tokens:
|
|
197
|
+
- `backgroundPrimary` → `primary`
|
|
198
|
+
- `backgroundSecondary` → `secondary`
|
|
199
|
+
- `backgroundBrand` → `brand`
|
|
200
|
+
2. Replace semantic text color tokens:
|
|
201
|
+
- `white` → `inverted` (for text on dark backgrounds)
|
|
202
|
+
- `grey1000` / `textPrimary` → `primary`
|
|
203
|
+
- `textSecondary` → `secondary`
|
|
204
|
+
3. Replace semantic border color tokens:
|
|
205
|
+
- `grey800` / `borderStrong` → `strong`
|
|
206
|
+
- `borderSubtle` → `subtle`
|
|
207
|
+
4. For non-semantic colors, use a `StyleSheet` and use the full color token from the theme:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
import { StyleSheet } from 'react-native';
|
|
211
|
+
|
|
212
|
+
const styles = StyleSheet.create(theme => ({
|
|
213
|
+
customBackground: {
|
|
214
|
+
backgroundColor: theme.color.blue[400],
|
|
215
|
+
},
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
<Box style={styles.customBackground} />;
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
or the `useTheme` hook:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { useTheme } from '@utilitywarehouse/hearth-react-native';
|
|
225
|
+
|
|
226
|
+
const theme = useTheme();
|
|
227
|
+
<Box backgroundColor={theme.color.purple[800]} />;
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
or use the tokens library:
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
import { color } from '@utilitywarehouse/hearth-tokens';
|
|
234
|
+
|
|
235
|
+
<Box backgroundColor={color.purple[800]} />;
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Backwards compatibility**:
|
|
239
|
+
|
|
240
|
+
The full color tokens (e.g., `backgroundPrimary`, `grey1000`) are still supported as fallbacks but are deprecated and will cause type errors. We recommend migrating to the simplified tokens for a cleaner API.
|
|
241
|
+
|
|
242
|
+
**References**:
|
|
243
|
+
- [Semantic tokens documentation](https://github.com/utilitywarehouse/hearth/blob/main/packages/tokens/src/semantic-light.ts)
|
|
244
|
+
|
|
245
|
+
### Patch Changes
|
|
246
|
+
|
|
247
|
+
- [#917](https://github.com/utilitywarehouse/hearth/pull/917) [`6a016dc`](https://github.com/utilitywarehouse/hearth/commit/6a016dca0d1a06e40a877da15aced590d0c68112) Thanks [@jordmccord](https://github.com/jordmccord)! - 💅 [ENHANCEMENT]: Update design tokens from Figma
|
|
248
|
+
|
|
249
|
+
Updated design tokens to include new font sizes, line heights, and component-specific tokens:
|
|
250
|
+
- Added `background.loading` colour token for both light and dark modes
|
|
251
|
+
- Added new font sizes: 575 (44px) and 650 (54px)
|
|
252
|
+
- Added new line heights: 975 (52px) and 1050 (62px)
|
|
253
|
+
- Updated `Modal` component tokens with `mobile.paddingBottom` and `handle.paddingBottom` properties
|
|
254
|
+
- Added `borderBottom` property to `Navigation` component tokens
|
|
255
|
+
- Updated `Skeleton` component `loadingColor` value in light mode
|
|
256
|
+
|
|
257
|
+
**Developer changes**:
|
|
258
|
+
|
|
259
|
+
No changes required. These tokens are automatically applied to components that use them.
|
|
260
|
+
|
|
12
261
|
## 0.20.0
|
|
13
262
|
|
|
14
263
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utilitywarehouse/hearth-react-native",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Utility Warehouse React Native UI library",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"react-native": "0.80.0",
|
|
44
44
|
"react-native-edge-to-edge": "1.6.1",
|
|
45
45
|
"react-native-gesture-handler": "2.28.0",
|
|
46
|
-
"react-native-nitro-modules": "
|
|
46
|
+
"react-native-nitro-modules": "0.31.4",
|
|
47
47
|
"react-native-reanimated": "4.1.3",
|
|
48
48
|
"react-native-svg": "^15.12.1",
|
|
49
49
|
"react-native-unistyles": "3.0.17",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"vite-plugin-svgr": "^4.5.0",
|
|
57
57
|
"vitest": "^3.2.4",
|
|
58
58
|
"@utilitywarehouse/hearth-fonts": "^0.0.4",
|
|
59
|
-
"@utilitywarehouse/hearth-react-icons": "^0.8.0",
|
|
60
59
|
"@utilitywarehouse/hearth-react-native-icons": "^0.8.0",
|
|
60
|
+
"@utilitywarehouse/hearth-react-icons": "^0.8.0",
|
|
61
61
|
"@utilitywarehouse/hearth-svg-assets": "^0.5.0",
|
|
62
62
|
"@utilitywarehouse/hearth-tokens": "^0.2.3"
|
|
63
63
|
},
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { forwardRef, useImperativeHandle } from 'react';
|
|
2
|
-
import { View } from 'react-native';
|
|
1
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
import { Platform, TextInput, View } from 'react-native';
|
|
3
3
|
import { StyleSheet } from 'react-native-unistyles';
|
|
4
4
|
import { FormField } from '../FormField';
|
|
5
|
-
import { useVerificationInput } from './useVerificationInput';
|
|
6
5
|
import type { VerificationInputHandle, VerificationInputProps } from './VerificationInput.props';
|
|
7
6
|
import { VerificationInputSlot } from './VerificationInputSlot';
|
|
8
7
|
|
|
@@ -28,30 +27,147 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
|
|
|
28
27
|
ref
|
|
29
28
|
) => {
|
|
30
29
|
const length = 6;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
const inputRef = useRef<TextInput>(null);
|
|
31
|
+
const latestValueRef = useRef(value);
|
|
32
|
+
const [displayValue, setDisplayValue] = useState(value);
|
|
33
|
+
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
|
34
|
+
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
35
|
+
const latestSelectionRef = useRef(selection);
|
|
36
|
+
const ignoreNextSelectionRef = useRef(false);
|
|
37
|
+
const pendingFocusIndexRef = useRef<number | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (value !== latestValueRef.current) {
|
|
41
|
+
const trimmedValue = value.slice(0, length);
|
|
42
|
+
latestValueRef.current = trimmedValue;
|
|
43
|
+
setDisplayValue(trimmedValue);
|
|
44
|
+
const nextPos = Math.min(trimmedValue.length, length);
|
|
45
|
+
const nextSelection = { start: nextPos, end: nextPos };
|
|
46
|
+
ignoreNextSelectionRef.current = true;
|
|
47
|
+
latestSelectionRef.current = nextSelection;
|
|
48
|
+
setSelection(nextSelection);
|
|
49
|
+
}
|
|
50
|
+
}, [length, value]);
|
|
51
|
+
|
|
52
|
+
const updateValue = (nextValue: string) => {
|
|
53
|
+
const trimmedValue = nextValue.slice(0, length);
|
|
54
|
+
latestValueRef.current = trimmedValue;
|
|
55
|
+
setDisplayValue(trimmedValue);
|
|
56
|
+
onChangeText?.(trimmedValue);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const setSelectionIndex = (index: number) => {
|
|
60
|
+
const clampedIndex = Math.max(0, Math.min(index, length));
|
|
61
|
+
const hasChar = !!latestValueRef.current[clampedIndex];
|
|
62
|
+
const endIndex = hasChar ? Math.min(clampedIndex + 1, length) : clampedIndex;
|
|
63
|
+
const nextSelection = { start: clampedIndex, end: endIndex };
|
|
64
|
+
ignoreNextSelectionRef.current = true;
|
|
65
|
+
latestSelectionRef.current = nextSelection;
|
|
66
|
+
setSelection(nextSelection);
|
|
67
|
+
setFocusedIndex(Math.min(clampedIndex, length - 1));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const setCaretIndex = (index: number) => {
|
|
71
|
+
const clampedIndex = Math.max(0, Math.min(index, length));
|
|
72
|
+
const nextSelection = { start: clampedIndex, end: clampedIndex };
|
|
73
|
+
ignoreNextSelectionRef.current = true;
|
|
74
|
+
latestSelectionRef.current = nextSelection;
|
|
75
|
+
setSelection(nextSelection);
|
|
76
|
+
setFocusedIndex(Math.min(clampedIndex, length - 1));
|
|
77
|
+
};
|
|
78
|
+
|
|
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
|
+
const handleChangeText = (text: string) => {
|
|
90
|
+
const prevValue = latestValueRef.current;
|
|
91
|
+
const nextValue = text.slice(0, length);
|
|
92
|
+
const prevLength = prevValue.length;
|
|
93
|
+
const nextLength = nextValue.length;
|
|
94
|
+
const diff = nextLength - prevLength;
|
|
95
|
+
const isBulkInsert = text.length > 1 && diff > 1;
|
|
96
|
+
const shouldBlur = nextLength >= length;
|
|
97
|
+
let nextIndex = Math.max(
|
|
98
|
+
0,
|
|
99
|
+
Math.min(latestSelectionRef.current.start + (diff >= 0 ? 1 : diff), length)
|
|
100
|
+
);
|
|
101
|
+
if (Platform.OS === 'android') {
|
|
102
|
+
const editedIndex = findDiffIndex(prevValue, nextValue);
|
|
103
|
+
nextIndex = diff >= 0 ? Math.min(editedIndex + 1, length) : Math.max(editedIndex, 0);
|
|
104
|
+
}
|
|
105
|
+
updateValue(nextValue);
|
|
106
|
+
if (isBulkInsert) {
|
|
107
|
+
setCaretIndex(Math.min(nextLength, length));
|
|
108
|
+
if (shouldBlur) {
|
|
109
|
+
inputRef.current?.blur();
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (nextIndex >= length) {
|
|
114
|
+
setCaretIndex(nextIndex);
|
|
115
|
+
if (shouldBlur) {
|
|
116
|
+
inputRef.current?.blur();
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (nextLength >= length) {
|
|
122
|
+
setSelectionIndex(nextIndex);
|
|
123
|
+
inputRef.current?.blur();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hasNextChar = !!nextValue[nextIndex];
|
|
128
|
+
if (hasNextChar) {
|
|
129
|
+
setSelectionIndex(nextIndex);
|
|
130
|
+
} else {
|
|
131
|
+
setCaretIndex(nextIndex);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleFocus = () => {
|
|
136
|
+
if (pendingFocusIndexRef.current !== null) {
|
|
137
|
+
setSelectionIndex(pendingFocusIndexRef.current);
|
|
138
|
+
pendingFocusIndexRef.current = null;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setFocusedIndex(Math.min(selection.start, length - 1));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleBlur = () => {
|
|
145
|
+
setFocusedIndex(null);
|
|
146
|
+
};
|
|
43
147
|
|
|
44
148
|
useImperativeHandle(
|
|
45
149
|
ref,
|
|
46
150
|
() => ({
|
|
47
|
-
focus: () =>
|
|
48
|
-
|
|
49
|
-
|
|
151
|
+
focus: () => {
|
|
152
|
+
inputRef.current?.focus();
|
|
153
|
+
const nextIndex = Math.min(latestValueRef.current.length, length - 1);
|
|
154
|
+
if (latestValueRef.current.length >= length) {
|
|
155
|
+
setSelectionIndex(nextIndex);
|
|
156
|
+
} else {
|
|
157
|
+
setCaretIndex(nextIndex);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
blur: () => inputRef.current?.blur(),
|
|
161
|
+
clear: () => {
|
|
162
|
+
updateValue('');
|
|
163
|
+
setSelectionIndex(0);
|
|
164
|
+
inputRef.current?.blur();
|
|
165
|
+
setFocusedIndex(null);
|
|
50
166
|
},
|
|
51
|
-
clear: () => onChangeText?.(''),
|
|
52
167
|
focusIndex: (index: number) => {
|
|
53
168
|
if (index >= 0 && index < length) {
|
|
54
|
-
|
|
169
|
+
inputRef.current?.focus();
|
|
170
|
+
setSelectionIndex(index);
|
|
55
171
|
}
|
|
56
172
|
},
|
|
57
173
|
}),
|
|
@@ -60,6 +176,29 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
|
|
|
60
176
|
|
|
61
177
|
const slots = Array.from({ length }, (_, index) => index);
|
|
62
178
|
|
|
179
|
+
const getAccessibilityLabel = () => {
|
|
180
|
+
return label || props.accessibilityLabel;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const getAccessibilityHint = () => {
|
|
184
|
+
let accessibilityHint = '';
|
|
185
|
+
if (helperText) {
|
|
186
|
+
accessibilityHint = accessibilityHint + helperText;
|
|
187
|
+
}
|
|
188
|
+
if (validationStatus !== 'initial') {
|
|
189
|
+
if (accessibilityHint.length > 0) {
|
|
190
|
+
accessibilityHint = accessibilityHint + ', ';
|
|
191
|
+
}
|
|
192
|
+
if (validationStatus === 'invalid' && invalidText) {
|
|
193
|
+
accessibilityHint = accessibilityHint + invalidText;
|
|
194
|
+
}
|
|
195
|
+
if (validationStatus === 'valid' && validText) {
|
|
196
|
+
accessibilityHint = accessibilityHint + validText;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return accessibilityHint || props.accessibilityHint;
|
|
200
|
+
};
|
|
201
|
+
|
|
63
202
|
return (
|
|
64
203
|
<FormField
|
|
65
204
|
label={label}
|
|
@@ -71,31 +210,70 @@ const VerificationInput = forwardRef<VerificationInputHandle, VerificationInputP
|
|
|
71
210
|
invalidText={invalidText}
|
|
72
211
|
disabled={disabled}
|
|
73
212
|
readonly={readonly}
|
|
213
|
+
accessibilityHandledByChildren
|
|
74
214
|
style={[styles.root, style]}
|
|
75
215
|
{...props}
|
|
76
216
|
>
|
|
77
217
|
<View style={styles.slotsContainer}>
|
|
218
|
+
<TextInput
|
|
219
|
+
ref={inputRef}
|
|
220
|
+
value={displayValue}
|
|
221
|
+
autoFocus={autoFocus}
|
|
222
|
+
editable={!disabled && !readonly}
|
|
223
|
+
accessibilityLabel={getAccessibilityLabel()}
|
|
224
|
+
accessibilityHint={getAccessibilityHint()}
|
|
225
|
+
accessibilityState={{ disabled: disabled || readonly }}
|
|
226
|
+
importantForAccessibility="yes"
|
|
227
|
+
onChangeText={handleChangeText}
|
|
228
|
+
onSelectionChange={event => {
|
|
229
|
+
const nextSelection = event.nativeEvent.selection;
|
|
230
|
+
if (
|
|
231
|
+
ignoreNextSelectionRef.current &&
|
|
232
|
+
(nextSelection.start !== latestSelectionRef.current.start ||
|
|
233
|
+
nextSelection.end !== latestSelectionRef.current.end)
|
|
234
|
+
) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (pendingFocusIndexRef.current !== null) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
ignoreNextSelectionRef.current = false;
|
|
241
|
+
latestSelectionRef.current = nextSelection;
|
|
242
|
+
setSelection(nextSelection);
|
|
243
|
+
setFocusedIndex(Math.min(nextSelection.start, length - 1));
|
|
244
|
+
}}
|
|
245
|
+
onFocus={handleFocus}
|
|
246
|
+
onBlur={handleBlur}
|
|
247
|
+
selection={selection}
|
|
248
|
+
keyboardType="number-pad"
|
|
249
|
+
textContentType="oneTimeCode"
|
|
250
|
+
autoComplete="sms-otp"
|
|
251
|
+
secureTextEntry={secureTextEntry}
|
|
252
|
+
maxLength={length}
|
|
253
|
+
caretHidden
|
|
254
|
+
style={styles.hiddenInput}
|
|
255
|
+
pointerEvents="none"
|
|
256
|
+
/>
|
|
78
257
|
{slots.map(index => {
|
|
79
258
|
const char = displayValue[index] || '';
|
|
80
259
|
const isActive = focusedIndex === index;
|
|
260
|
+
const displayChar = secureTextEntry && char ? '*' : char;
|
|
81
261
|
|
|
82
262
|
return (
|
|
83
263
|
<VerificationInputSlot
|
|
84
264
|
key={index}
|
|
85
|
-
|
|
86
|
-
inputRefs.current[index] = inputRef;
|
|
87
|
-
}}
|
|
88
|
-
autoFocus={index === 0 && autoFocus}
|
|
89
|
-
value={char}
|
|
265
|
+
value={displayChar}
|
|
90
266
|
isActive={isActive}
|
|
267
|
+
showCaret={isActive && !displayChar}
|
|
91
268
|
validationStatus={validationStatus}
|
|
92
269
|
disabled={disabled}
|
|
93
270
|
readonly={readonly}
|
|
94
271
|
secureTextEntry={secureTextEntry}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
272
|
+
onPress={() => {
|
|
273
|
+
pendingFocusIndexRef.current = index;
|
|
274
|
+
inputRef.current?.focus();
|
|
275
|
+
setSelectionIndex(index);
|
|
276
|
+
}}
|
|
99
277
|
/>
|
|
100
278
|
);
|
|
101
279
|
})}
|
|
@@ -115,6 +293,17 @@ const styles = StyleSheet.create(theme => ({
|
|
|
115
293
|
flexDirection: 'row',
|
|
116
294
|
gap: theme.components.input.verification.gap,
|
|
117
295
|
width: '100%',
|
|
296
|
+
position: 'relative',
|
|
297
|
+
},
|
|
298
|
+
hiddenInput: {
|
|
299
|
+
position: 'absolute',
|
|
300
|
+
width: '100%',
|
|
301
|
+
height: '100%',
|
|
302
|
+
left: 0,
|
|
303
|
+
top: 0,
|
|
304
|
+
color: 'transparent',
|
|
305
|
+
fontSize: 1,
|
|
306
|
+
opacity: 0.1,
|
|
118
307
|
},
|
|
119
308
|
}));
|
|
120
309
|
|
|
@@ -1,33 +1,84 @@
|
|
|
1
|
-
import { forwardRef } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { forwardRef, useEffect } from 'react';
|
|
2
|
+
import { Pressable, Text, View, ViewProps } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
Easing,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withRepeat,
|
|
8
|
+
withTiming,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
3
10
|
import { StyleSheet } from 'react-native-unistyles';
|
|
4
|
-
import InputField from '../Input/InputField';
|
|
5
11
|
|
|
6
|
-
interface VerificationInputSlotProps extends
|
|
12
|
+
interface VerificationInputSlotProps extends ViewProps {
|
|
13
|
+
value: string;
|
|
7
14
|
isActive: boolean;
|
|
15
|
+
showCaret?: boolean;
|
|
8
16
|
validationStatus: 'initial' | 'valid' | 'invalid';
|
|
9
17
|
disabled?: boolean;
|
|
10
18
|
readonly?: boolean;
|
|
19
|
+
onPress?: () => void;
|
|
20
|
+
secureTextEntry?: boolean;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
export const VerificationInputSlot = forwardRef<
|
|
14
|
-
(
|
|
23
|
+
export const VerificationInputSlot = forwardRef<View, VerificationInputSlotProps>(
|
|
24
|
+
(
|
|
25
|
+
{
|
|
26
|
+
value,
|
|
27
|
+
isActive,
|
|
28
|
+
showCaret,
|
|
29
|
+
validationStatus,
|
|
30
|
+
disabled,
|
|
31
|
+
readonly,
|
|
32
|
+
style,
|
|
33
|
+
onPress,
|
|
34
|
+
secureTextEntry,
|
|
35
|
+
...props
|
|
36
|
+
},
|
|
37
|
+
ref
|
|
38
|
+
) => {
|
|
15
39
|
styles.useVariants({
|
|
16
40
|
disabled,
|
|
17
41
|
readonly,
|
|
18
42
|
validationStatus,
|
|
19
43
|
active: isActive,
|
|
44
|
+
secureTextEntry,
|
|
20
45
|
});
|
|
21
46
|
|
|
47
|
+
const caretOpacity = useSharedValue(0);
|
|
48
|
+
const animatedCaretStyle = useAnimatedStyle(() => ({
|
|
49
|
+
opacity: caretOpacity.value,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (showCaret && !disabled && !readonly) {
|
|
54
|
+
caretOpacity.value = withRepeat(
|
|
55
|
+
withTiming(1, { duration: 500, easing: Easing.inOut(Easing.ease) }),
|
|
56
|
+
-1,
|
|
57
|
+
true
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
caretOpacity.value = withTiming(0, { duration: 150, easing: Easing.out(Easing.ease) });
|
|
63
|
+
}, [caretOpacity, disabled, readonly, showCaret]);
|
|
64
|
+
|
|
22
65
|
return (
|
|
23
|
-
<
|
|
66
|
+
<Pressable
|
|
24
67
|
ref={ref}
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
selectTextOnFocus
|
|
28
|
-
keyboardType="number-pad"
|
|
68
|
+
onPress={onPress}
|
|
69
|
+
disabled={disabled || readonly}
|
|
29
70
|
style={[styles.slot, style]}
|
|
30
|
-
|
|
71
|
+
accessibilityRole="button"
|
|
72
|
+
accessible={false}
|
|
73
|
+
accessibilityElementsHidden
|
|
74
|
+
importantForAccessibility="no-hide-descendants"
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
<Text style={styles.slotText}>{value}</Text>
|
|
78
|
+
{showCaret && !disabled && !readonly && (
|
|
79
|
+
<Animated.View style={[styles.caret, animatedCaretStyle]} />
|
|
80
|
+
)}
|
|
81
|
+
</Pressable>
|
|
31
82
|
);
|
|
32
83
|
}
|
|
33
84
|
);
|
|
@@ -36,15 +87,20 @@ VerificationInputSlot.displayName = 'VerificationInputSlot';
|
|
|
36
87
|
|
|
37
88
|
const styles = StyleSheet.create(theme => ({
|
|
38
89
|
slot: {
|
|
39
|
-
|
|
90
|
+
flexGrow: 0,
|
|
91
|
+
flexShrink: 0,
|
|
40
92
|
width: theme.components.input.height,
|
|
41
93
|
height: theme.components.input.height,
|
|
94
|
+
minWidth: theme.components.input.height,
|
|
95
|
+
minHeight: theme.components.input.height,
|
|
42
96
|
borderWidth: theme.components.input.borderWidth,
|
|
43
97
|
borderColor: theme.color.border.strong,
|
|
44
98
|
borderRadius: theme.components.input.borderRadius,
|
|
45
99
|
backgroundColor: theme.color.surface.neutral.strong,
|
|
46
|
-
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center',
|
|
47
102
|
padding: 0,
|
|
103
|
+
position: 'relative',
|
|
48
104
|
variants: {
|
|
49
105
|
disabled: {
|
|
50
106
|
true: {
|
|
@@ -91,4 +147,24 @@ const styles = StyleSheet.create(theme => ({
|
|
|
91
147
|
},
|
|
92
148
|
],
|
|
93
149
|
},
|
|
150
|
+
slotText: {
|
|
151
|
+
color: theme.color.text.primary,
|
|
152
|
+
fontSize: theme.typography.mobile.bodyText.md.fontSize,
|
|
153
|
+
fontFamily: theme.typography.mobile.bodyText.fontFamily,
|
|
154
|
+
fontWeight: `${theme.typography.mobile.bodyText.fontWeight}`,
|
|
155
|
+
textAlign: 'center',
|
|
156
|
+
variants: {
|
|
157
|
+
secureTextEntry: {
|
|
158
|
+
true: {
|
|
159
|
+
paddingTop: 5,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
caret: {
|
|
165
|
+
position: 'absolute',
|
|
166
|
+
width: 2,
|
|
167
|
+
height: '55%',
|
|
168
|
+
backgroundColor: theme.color.text.brand,
|
|
169
|
+
},
|
|
94
170
|
}));
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
|
|
2
|
-
interface UseVerificationInputProps {
|
|
3
|
-
value: string;
|
|
4
|
-
onChangeText?: (text: string) => void;
|
|
5
|
-
}
|
|
6
|
-
export declare const useVerificationInput: ({ value, onChangeText }: UseVerificationInputProps) => {
|
|
7
|
-
inputRefs: import("react").RefObject<(TextInput | null)[]>;
|
|
8
|
-
displayValue: string;
|
|
9
|
-
focusedIndex: number | null;
|
|
10
|
-
handleFocus: (index: number) => void;
|
|
11
|
-
handleBlur: () => void;
|
|
12
|
-
handleChangeText: (text: string, index: number) => void;
|
|
13
|
-
handleKeyPress: (e: NativeSyntheticEvent<TextInputKeyPressEventData>, index: number) => void;
|
|
14
|
-
};
|
|
15
|
-
export {};
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
export const useVerificationInput = ({ value, onChangeText }) => {
|
|
3
|
-
const length = 6;
|
|
4
|
-
const inputRefs = useRef([]);
|
|
5
|
-
const latestValueRef = useRef(value);
|
|
6
|
-
const [displayValue, setDisplayValue] = useState(value);
|
|
7
|
-
const [focusedIndex, setFocusedIndex] = useState(null);
|
|
8
|
-
useEffect(() => {
|
|
9
|
-
if (value !== latestValueRef.current) {
|
|
10
|
-
latestValueRef.current = value;
|
|
11
|
-
setDisplayValue(value);
|
|
12
|
-
}
|
|
13
|
-
}, [value]);
|
|
14
|
-
const handleFocus = (index) => {
|
|
15
|
-
setFocusedIndex(index);
|
|
16
|
-
};
|
|
17
|
-
const handleBlur = () => {
|
|
18
|
-
setFocusedIndex(null);
|
|
19
|
-
};
|
|
20
|
-
const updateValue = (nextValue) => {
|
|
21
|
-
latestValueRef.current = nextValue;
|
|
22
|
-
setDisplayValue(nextValue);
|
|
23
|
-
onChangeText?.(nextValue);
|
|
24
|
-
};
|
|
25
|
-
const handleChangeText = (text, index) => {
|
|
26
|
-
const currentValue = latestValueRef.current;
|
|
27
|
-
const chars = Array(length).fill('');
|
|
28
|
-
for (let i = 0; i < currentValue.length && i < length; i++) {
|
|
29
|
-
chars[i] = currentValue[i];
|
|
30
|
-
}
|
|
31
|
-
if (text.length > 1) {
|
|
32
|
-
// Handle paste
|
|
33
|
-
const pastedChars = text.slice(0, length - index).split('');
|
|
34
|
-
for (let i = 0; i < pastedChars.length; i++) {
|
|
35
|
-
chars[index + i] = pastedChars[i];
|
|
36
|
-
}
|
|
37
|
-
const nextIndex = Math.min(index + pastedChars.length, length - 1);
|
|
38
|
-
inputRefs.current[nextIndex]?.focus();
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
// Handle single char input
|
|
42
|
-
chars[index] = text;
|
|
43
|
-
if (text.length === 1 && index < length - 1) {
|
|
44
|
-
inputRefs.current[index + 1]?.focus();
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
updateValue(chars.join(''));
|
|
48
|
-
};
|
|
49
|
-
const handleKeyPress = (e, index) => {
|
|
50
|
-
if (e.nativeEvent.key === 'Backspace') {
|
|
51
|
-
const currentValue = latestValueRef.current;
|
|
52
|
-
if (!currentValue[index] && index > 0) {
|
|
53
|
-
e.preventDefault();
|
|
54
|
-
inputRefs.current[index - 1]?.focus();
|
|
55
|
-
const chars = Array(length).fill('');
|
|
56
|
-
for (let i = 0; i < currentValue.length && i < length; i++) {
|
|
57
|
-
chars[i] = currentValue[i];
|
|
58
|
-
}
|
|
59
|
-
chars[index - 1] = '';
|
|
60
|
-
updateValue(chars.join(''));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
return {
|
|
65
|
-
inputRefs,
|
|
66
|
-
displayValue,
|
|
67
|
-
focusedIndex,
|
|
68
|
-
handleFocus,
|
|
69
|
-
handleBlur,
|
|
70
|
-
handleChangeText,
|
|
71
|
-
handleKeyPress,
|
|
72
|
-
};
|
|
73
|
-
};
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
|
|
3
|
-
|
|
4
|
-
interface UseVerificationInputProps {
|
|
5
|
-
value: string;
|
|
6
|
-
onChangeText?: (text: string) => void;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const useVerificationInput = ({ value, onChangeText }: UseVerificationInputProps) => {
|
|
10
|
-
const length = 6;
|
|
11
|
-
const inputRefs = useRef<(TextInput | null)[]>([]);
|
|
12
|
-
const latestValueRef = useRef(value);
|
|
13
|
-
const [displayValue, setDisplayValue] = useState(value);
|
|
14
|
-
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
|
15
|
-
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
if (value !== latestValueRef.current) {
|
|
18
|
-
latestValueRef.current = value;
|
|
19
|
-
setDisplayValue(value);
|
|
20
|
-
}
|
|
21
|
-
}, [value]);
|
|
22
|
-
|
|
23
|
-
const handleFocus = (index: number) => {
|
|
24
|
-
setFocusedIndex(index);
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const handleBlur = () => {
|
|
28
|
-
setFocusedIndex(null);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const updateValue = (nextValue: string) => {
|
|
32
|
-
latestValueRef.current = nextValue;
|
|
33
|
-
setDisplayValue(nextValue);
|
|
34
|
-
onChangeText?.(nextValue);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const handleChangeText = (text: string, index: number) => {
|
|
38
|
-
const currentValue = latestValueRef.current;
|
|
39
|
-
const chars = Array(length).fill('');
|
|
40
|
-
for (let i = 0; i < currentValue.length && i < length; i++) {
|
|
41
|
-
chars[i] = currentValue[i];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (text.length > 1) {
|
|
45
|
-
// Handle paste
|
|
46
|
-
const pastedChars = text.slice(0, length - index).split('');
|
|
47
|
-
for (let i = 0; i < pastedChars.length; i++) {
|
|
48
|
-
chars[index + i] = pastedChars[i];
|
|
49
|
-
}
|
|
50
|
-
const nextIndex = Math.min(index + pastedChars.length, length - 1);
|
|
51
|
-
inputRefs.current[nextIndex]?.focus();
|
|
52
|
-
} else {
|
|
53
|
-
// Handle single char input
|
|
54
|
-
chars[index] = text;
|
|
55
|
-
if (text.length === 1 && index < length - 1) {
|
|
56
|
-
inputRefs.current[index + 1]?.focus();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
updateValue(chars.join(''));
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const handleKeyPress = (e: NativeSyntheticEvent<TextInputKeyPressEventData>, index: number) => {
|
|
63
|
-
if (e.nativeEvent.key === 'Backspace') {
|
|
64
|
-
const currentValue = latestValueRef.current;
|
|
65
|
-
if (!currentValue[index] && index > 0) {
|
|
66
|
-
e.preventDefault();
|
|
67
|
-
inputRefs.current[index - 1]?.focus();
|
|
68
|
-
|
|
69
|
-
const chars = Array(length).fill('');
|
|
70
|
-
for (let i = 0; i < currentValue.length && i < length; i++) {
|
|
71
|
-
chars[i] = currentValue[i];
|
|
72
|
-
}
|
|
73
|
-
chars[index - 1] = '';
|
|
74
|
-
updateValue(chars.join(''));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
inputRefs,
|
|
81
|
-
displayValue,
|
|
82
|
-
focusedIndex,
|
|
83
|
-
handleFocus,
|
|
84
|
-
handleBlur,
|
|
85
|
-
handleChangeText,
|
|
86
|
-
handleKeyPress,
|
|
87
|
-
};
|
|
88
|
-
};
|