@leo-showdar/react-native-otp-input-kit 0.1.0
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/LICENSE +21 -0
- package/README.md +221 -0
- package/package.json +53 -0
- package/src/components/OtpInput/OtpInput.tsx +96 -0
- package/src/components/OtpInput/OtpInputCell.tsx +98 -0
- package/src/components/OtpInput/index.ts +2 -0
- package/src/components/OtpInput/styles.ts +56 -0
- package/src/components/OtpResendTimer/OtpResendTimer.tsx +86 -0
- package/src/components/OtpResendTimer/index.ts +1 -0
- package/src/hooks/useCountdown.ts +97 -0
- package/src/hooks/useOtpInput.ts +279 -0
- package/src/index.ts +16 -0
- package/src/types/index.ts +110 -0
- package/src/utils/createOtpArray.ts +3 -0
- package/src/utils/sanitizeOtp.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 leo-showdar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# @leo-showdar/react-native-otp-input-kit
|
|
2
|
+
|
|
3
|
+
Lightweight React Native TypeScript components for OTP input and resend timer flows.
|
|
4
|
+
|
|
5
|
+
The package is designed for login, phone verification, email verification, and two-step verification screens. It ships only React Native components and hooks, with no native modules and no runtime dependencies beyond React and React Native.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @leo-showdar/react-native-otp-input-kit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import * as React from 'react';
|
|
17
|
+
import { Alert, SafeAreaView, StyleSheet, Text } from 'react-native';
|
|
18
|
+
import { OtpInput, OtpResendTimer } from '@leo-showdar/react-native-otp-input-kit';
|
|
19
|
+
|
|
20
|
+
export function VerifyPhoneScreen() {
|
|
21
|
+
return (
|
|
22
|
+
<SafeAreaView style={styles.screen}>
|
|
23
|
+
<Text style={styles.title}>Enter the code sent to your phone</Text>
|
|
24
|
+
|
|
25
|
+
<OtpInput
|
|
26
|
+
autoFocus
|
|
27
|
+
length={6}
|
|
28
|
+
onComplete={(code) => Alert.alert('Code complete', code)}
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
<OtpResendTimer onResend={() => Alert.alert('New code sent')} />
|
|
32
|
+
</SafeAreaView>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const styles = StyleSheet.create({
|
|
37
|
+
screen: {
|
|
38
|
+
flex: 1,
|
|
39
|
+
justifyContent: 'center',
|
|
40
|
+
padding: 24,
|
|
41
|
+
rowGap: 20,
|
|
42
|
+
},
|
|
43
|
+
title: {
|
|
44
|
+
color: '#111827',
|
|
45
|
+
fontSize: 20,
|
|
46
|
+
fontWeight: '700',
|
|
47
|
+
textAlign: 'center',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Controlled Usage
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import * as React from 'react';
|
|
56
|
+
import { Button, View } from 'react-native';
|
|
57
|
+
import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
|
|
58
|
+
|
|
59
|
+
export function ControlledOtpExample() {
|
|
60
|
+
const [code, setCode] = React.useState('');
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View>
|
|
64
|
+
<OtpInput
|
|
65
|
+
value={code}
|
|
66
|
+
onChangeCode={setCode}
|
|
67
|
+
allowCellSelection={false}
|
|
68
|
+
onComplete={(completedCode) => {
|
|
69
|
+
// Submit completedCode to your verification endpoint.
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<Button title="Clear" onPress={() => setCode('')} />
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Custom Styles
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
|
|
83
|
+
|
|
84
|
+
export function StyledOtpExample() {
|
|
85
|
+
return (
|
|
86
|
+
<OtpInput
|
|
87
|
+
containerStyle={{ justifyContent: 'center' }}
|
|
88
|
+
cellStyle={{
|
|
89
|
+
backgroundColor: '#F9FAFB',
|
|
90
|
+
borderColor: '#CBD5E1',
|
|
91
|
+
borderRadius: 12,
|
|
92
|
+
height: 56,
|
|
93
|
+
width: 52,
|
|
94
|
+
}}
|
|
95
|
+
focusedCellStyle={{
|
|
96
|
+
borderColor: '#0F766E',
|
|
97
|
+
borderWidth: 2,
|
|
98
|
+
}}
|
|
99
|
+
filledCellStyle={{
|
|
100
|
+
borderColor: '#64748B',
|
|
101
|
+
}}
|
|
102
|
+
textStyle={{
|
|
103
|
+
color: '#0F172A',
|
|
104
|
+
fontSize: 24,
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Error State
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import * as React from 'react';
|
|
115
|
+
import { Text, View } from 'react-native';
|
|
116
|
+
import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
|
|
117
|
+
|
|
118
|
+
export function OtpErrorExample() {
|
|
119
|
+
const [code, setCode] = React.useState('');
|
|
120
|
+
const [hasError, setHasError] = React.useState(false);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<View>
|
|
124
|
+
<OtpInput
|
|
125
|
+
value={code}
|
|
126
|
+
onChangeCode={(nextCode) => {
|
|
127
|
+
setCode(nextCode);
|
|
128
|
+
setHasError(false);
|
|
129
|
+
}}
|
|
130
|
+
onComplete={() => setHasError(true)}
|
|
131
|
+
error={hasError}
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
{hasError ? (
|
|
135
|
+
<Text style={{ color: '#B91C1C', marginTop: 8 }}>
|
|
136
|
+
That code did not match. Check the message and try again.
|
|
137
|
+
</Text>
|
|
138
|
+
) : null}
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Secure OTP
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { OtpInput } from '@leo-showdar/react-native-otp-input-kit';
|
|
148
|
+
|
|
149
|
+
export function SecureOtpExample() {
|
|
150
|
+
return <OtpInput secureTextEntry length={6} />;
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Resend Timer
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import * as React from 'react';
|
|
158
|
+
import { Text, View } from 'react-native';
|
|
159
|
+
import { OtpResendTimer } from '@leo-showdar/react-native-otp-input-kit';
|
|
160
|
+
|
|
161
|
+
export function ResendTimerExample() {
|
|
162
|
+
const [message, setMessage] = React.useState('');
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<View>
|
|
166
|
+
<OtpResendTimer
|
|
167
|
+
seconds={45}
|
|
168
|
+
countdownLabel="Request a new code in"
|
|
169
|
+
resendLabel="Send a new code"
|
|
170
|
+
onResend={() => setMessage('A new verification code was sent.')}
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
{message ? <Text>{message}</Text> : null}
|
|
174
|
+
</View>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## OtpInput Props
|
|
180
|
+
|
|
181
|
+
| Prop | Type | Default | Description |
|
|
182
|
+
| --- | --- | --- | --- |
|
|
183
|
+
| `length` | `number` | `6` | Number of OTP digits. |
|
|
184
|
+
| `value` | `string` | `undefined` | Controlled OTP value. |
|
|
185
|
+
| `defaultValue` | `string` | `''` | Initial value for uncontrolled usage. |
|
|
186
|
+
| `onChangeCode` | `(code: string) => void` | `undefined` | Called when the OTP changes. |
|
|
187
|
+
| `onComplete` | `(code: string) => void` | `undefined` | Called when the OTP reaches `length`. |
|
|
188
|
+
| `allowCellSelection` | `boolean` | `true` | Allows tapping any cell to edit it. Set `false` for sequential input and right-to-left delete behavior. |
|
|
189
|
+
| `autoFocus` | `boolean` | `false` | Focuses the first cell on mount. |
|
|
190
|
+
| `disabled` | `boolean` | `false` | Disables editing and lowers opacity. |
|
|
191
|
+
| `error` | `boolean` | `false` | Applies error styling and accessibility state. |
|
|
192
|
+
| `secureTextEntry` | `boolean` | `false` | Masks entered digits. |
|
|
193
|
+
| `placeholder` | `string` | `undefined` | Placeholder shown in empty cells. |
|
|
194
|
+
| `keyboardType` | `TextInputProps['keyboardType']` | `'number-pad'` | Keyboard type for each cell. |
|
|
195
|
+
| `containerStyle` | `ViewStyle` | `undefined` | Style override for the cell row. |
|
|
196
|
+
| `cellStyle` | `ViewStyle` | `undefined` | Base style override for each cell. |
|
|
197
|
+
| `focusedCellStyle` | `ViewStyle` | `undefined` | Style applied to the focused cell. |
|
|
198
|
+
| `filledCellStyle` | `ViewStyle` | `undefined` | Style applied to cells with a value. |
|
|
199
|
+
| `errorCellStyle` | `ViewStyle` | `undefined` | Style applied when `error` is true. |
|
|
200
|
+
| `textStyle` | `TextStyle` | `undefined` | Text style override for each cell. |
|
|
201
|
+
| `testID` | `string` | `undefined` | Test id for the container and cell ids. |
|
|
202
|
+
|
|
203
|
+
## OtpResendTimer Props
|
|
204
|
+
|
|
205
|
+
| Prop | Type | Default | Description |
|
|
206
|
+
| --- | --- | --- | --- |
|
|
207
|
+
| `seconds` | `number` | `60` | Countdown duration in seconds. |
|
|
208
|
+
| `autoStart` | `boolean` | `true` | Starts the countdown on mount. |
|
|
209
|
+
| `onResend` | `() => void` | `undefined` | Called when resend is pressed. |
|
|
210
|
+
| `onFinish` | `() => void` | `undefined` | Called when the countdown reaches zero. |
|
|
211
|
+
| `renderText` | `(remainingSeconds: number, canResend: boolean) => React.ReactNode` | `undefined` | Custom text renderer. |
|
|
212
|
+
| `resendLabel` | `string` | `'Resend code'` | Label shown when resend is available. |
|
|
213
|
+
| `countdownLabel` | `string` | `'Resend code in'` | Label shown while counting down. |
|
|
214
|
+
| `disabled` | `boolean` | `false` | Prevents resend presses. |
|
|
215
|
+
| `textStyle` | `TextStyle` | `undefined` | Style for default countdown text. |
|
|
216
|
+
| `resendTextStyle` | `TextStyle` | `undefined` | Style for default resend text. |
|
|
217
|
+
| `testID` | `string` | `undefined` | Test id for the pressable timer. |
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leo-showdar/react-native-otp-input-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight React Native TypeScript components for OTP input and resend timer flows.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"react-native": "src/index.ts",
|
|
8
|
+
"source": "src/index.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "jest",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react-native",
|
|
19
|
+
"otp",
|
|
20
|
+
"otp-input",
|
|
21
|
+
"verification-code",
|
|
22
|
+
"resend-timer",
|
|
23
|
+
"typescript"
|
|
24
|
+
],
|
|
25
|
+
"author": "leo-showdar",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/leo-showdar/react-native-otp-input-kit.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/leo-showdar/react-native-otp-input-kit/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/leo-showdar/react-native-otp-input-kit#readme",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": ">=18.2.0",
|
|
40
|
+
"react-native": ">=0.72.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@testing-library/react-native": "^12.7.2",
|
|
44
|
+
"@types/jest": "^29.5.12",
|
|
45
|
+
"@types/react": "^18.3.31",
|
|
46
|
+
"babel-jest": "^29.7.0",
|
|
47
|
+
"jest": "^29.7.0",
|
|
48
|
+
"react": "^18.2.0",
|
|
49
|
+
"react-native": "^0.75.0",
|
|
50
|
+
"react-test-renderer": "^18.2.0",
|
|
51
|
+
"typescript": "^5.5.4"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { OtpInputProps } from '../../types';
|
|
5
|
+
import { useOtpInput } from '../../hooks/useOtpInput';
|
|
6
|
+
import { OtpInputCell } from './OtpInputCell';
|
|
7
|
+
import { otpInputStyles } from './styles';
|
|
8
|
+
|
|
9
|
+
export function OtpInput({
|
|
10
|
+
length = 6,
|
|
11
|
+
value,
|
|
12
|
+
defaultValue,
|
|
13
|
+
onChangeCode,
|
|
14
|
+
onComplete,
|
|
15
|
+
allowCellSelection = true,
|
|
16
|
+
autoFocus = false,
|
|
17
|
+
disabled = false,
|
|
18
|
+
error = false,
|
|
19
|
+
secureTextEntry = false,
|
|
20
|
+
placeholder,
|
|
21
|
+
keyboardType = 'number-pad',
|
|
22
|
+
containerStyle,
|
|
23
|
+
cellStyle,
|
|
24
|
+
focusedCellStyle,
|
|
25
|
+
filledCellStyle,
|
|
26
|
+
errorCellStyle,
|
|
27
|
+
textStyle,
|
|
28
|
+
testID,
|
|
29
|
+
}: OtpInputProps) {
|
|
30
|
+
const {
|
|
31
|
+
cellValues,
|
|
32
|
+
focusedIndex,
|
|
33
|
+
inputRefs,
|
|
34
|
+
setFocusedIndex,
|
|
35
|
+
focusCell,
|
|
36
|
+
handleChangeText,
|
|
37
|
+
handleKeyPress,
|
|
38
|
+
} = useOtpInput({
|
|
39
|
+
length,
|
|
40
|
+
value,
|
|
41
|
+
defaultValue,
|
|
42
|
+
onChangeCode,
|
|
43
|
+
onComplete,
|
|
44
|
+
allowCellSelection,
|
|
45
|
+
});
|
|
46
|
+
const didAutoFocusRef = React.useRef(false);
|
|
47
|
+
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (autoFocus && !disabled && !didAutoFocusRef.current) {
|
|
50
|
+
didAutoFocusRef.current = true;
|
|
51
|
+
requestAnimationFrame(() => focusCell(0));
|
|
52
|
+
}
|
|
53
|
+
}, [autoFocus, disabled, focusCell]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View
|
|
57
|
+
accessibilityLabel="Verification code input"
|
|
58
|
+
accessibilityHint={error ? 'The entered verification code has an error.' : undefined}
|
|
59
|
+
accessibilityState={{ disabled }}
|
|
60
|
+
style={[
|
|
61
|
+
otpInputStyles.container,
|
|
62
|
+
disabled && otpInputStyles.disabledContainer,
|
|
63
|
+
containerStyle,
|
|
64
|
+
]}
|
|
65
|
+
testID={testID}
|
|
66
|
+
>
|
|
67
|
+
{cellValues.map((cellValue, index) => (
|
|
68
|
+
<OtpInputCell
|
|
69
|
+
key={index}
|
|
70
|
+
value={cellValue}
|
|
71
|
+
index={index}
|
|
72
|
+
length={length}
|
|
73
|
+
focused={focusedIndex === index}
|
|
74
|
+
filled={cellValue.length > 0}
|
|
75
|
+
error={error}
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
secureTextEntry={secureTextEntry}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
keyboardType={keyboardType}
|
|
80
|
+
cellStyle={cellStyle}
|
|
81
|
+
focusedCellStyle={focusedCellStyle}
|
|
82
|
+
filledCellStyle={filledCellStyle}
|
|
83
|
+
errorCellStyle={errorCellStyle}
|
|
84
|
+
textStyle={textStyle}
|
|
85
|
+
testID={testID}
|
|
86
|
+
inputRef={(input) => {
|
|
87
|
+
inputRefs.current[index] = input;
|
|
88
|
+
}}
|
|
89
|
+
onFocus={setFocusedIndex}
|
|
90
|
+
onChangeText={handleChangeText}
|
|
91
|
+
onKeyPress={handleKeyPress}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Pressable, Text, TextInput } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { OtpInputCellProps } from '../../types';
|
|
5
|
+
import { otpInputStyles } from './styles';
|
|
6
|
+
|
|
7
|
+
export const OtpInputCell = React.memo(function OtpInputCell({
|
|
8
|
+
value,
|
|
9
|
+
index,
|
|
10
|
+
length = 6,
|
|
11
|
+
focused = false,
|
|
12
|
+
filled = false,
|
|
13
|
+
error = false,
|
|
14
|
+
disabled = false,
|
|
15
|
+
secureTextEntry = false,
|
|
16
|
+
placeholder,
|
|
17
|
+
keyboardType = 'number-pad',
|
|
18
|
+
cellStyle,
|
|
19
|
+
focusedCellStyle,
|
|
20
|
+
filledCellStyle,
|
|
21
|
+
errorCellStyle,
|
|
22
|
+
textStyle,
|
|
23
|
+
testID,
|
|
24
|
+
inputRef,
|
|
25
|
+
onFocus,
|
|
26
|
+
onChangeText,
|
|
27
|
+
onKeyPress,
|
|
28
|
+
}: OtpInputCellProps) {
|
|
29
|
+
const localInputRef = React.useRef<TextInput | null>(null);
|
|
30
|
+
const displayValue = secureTextEntry && value ? '•' : value || placeholder || '';
|
|
31
|
+
const showPlaceholder = !value && Boolean(placeholder);
|
|
32
|
+
const setInputRef = React.useCallback(
|
|
33
|
+
(input: TextInput | null) => {
|
|
34
|
+
localInputRef.current = input;
|
|
35
|
+
inputRef?.(input);
|
|
36
|
+
},
|
|
37
|
+
[inputRef]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const focusInput = React.useCallback(() => {
|
|
41
|
+
if (disabled) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onFocus?.(index);
|
|
46
|
+
requestAnimationFrame(() => {
|
|
47
|
+
localInputRef.current?.focus();
|
|
48
|
+
});
|
|
49
|
+
}, [disabled, index, onFocus]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Pressable
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
onPress={focusInput}
|
|
55
|
+
style={[
|
|
56
|
+
otpInputStyles.cell,
|
|
57
|
+
filled && otpInputStyles.filledCell,
|
|
58
|
+
focused && otpInputStyles.focusedCell,
|
|
59
|
+
error && otpInputStyles.errorCell,
|
|
60
|
+
cellStyle,
|
|
61
|
+
filled && filledCellStyle,
|
|
62
|
+
focused && focusedCellStyle,
|
|
63
|
+
error && errorCellStyle,
|
|
64
|
+
]}
|
|
65
|
+
>
|
|
66
|
+
<Text
|
|
67
|
+
style={[
|
|
68
|
+
otpInputStyles.cellText,
|
|
69
|
+
showPlaceholder && otpInputStyles.placeholderText,
|
|
70
|
+
textStyle,
|
|
71
|
+
]}
|
|
72
|
+
>
|
|
73
|
+
{displayValue}
|
|
74
|
+
</Text>
|
|
75
|
+
|
|
76
|
+
<TextInput
|
|
77
|
+
ref={setInputRef}
|
|
78
|
+
value=""
|
|
79
|
+
onFocus={() => onFocus?.(index)}
|
|
80
|
+
onChangeText={(text) => onChangeText(text, index)}
|
|
81
|
+
onKeyPress={(event) => onKeyPress(event, index)}
|
|
82
|
+
editable={!disabled}
|
|
83
|
+
caretHidden
|
|
84
|
+
contextMenuHidden
|
|
85
|
+
selectTextOnFocus={false}
|
|
86
|
+
selection={{ start: 0, end: 0 }}
|
|
87
|
+
keyboardType={keyboardType}
|
|
88
|
+
textContentType="oneTimeCode"
|
|
89
|
+
autoComplete="sms-otp"
|
|
90
|
+
maxLength={length}
|
|
91
|
+
accessibilityLabel={`Verification code digit ${index + 1}`}
|
|
92
|
+
accessibilityState={{ disabled }}
|
|
93
|
+
testID={testID ? `${testID}-cell-${index}` : undefined}
|
|
94
|
+
style={otpInputStyles.hiddenInput}
|
|
95
|
+
/>
|
|
96
|
+
</Pressable>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { StyleSheet } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export const otpInputStyles = StyleSheet.create({
|
|
4
|
+
container: {
|
|
5
|
+
alignItems: 'center',
|
|
6
|
+
flexDirection: 'row',
|
|
7
|
+
gap: 10,
|
|
8
|
+
},
|
|
9
|
+
disabledContainer: {
|
|
10
|
+
opacity: 0.48,
|
|
11
|
+
},
|
|
12
|
+
cell: {
|
|
13
|
+
alignItems: 'center',
|
|
14
|
+
backgroundColor: '#FFFFFF',
|
|
15
|
+
borderColor: '#D1D5DB',
|
|
16
|
+
borderRadius: 10,
|
|
17
|
+
borderWidth: 1,
|
|
18
|
+
height: 52,
|
|
19
|
+
justifyContent: 'center',
|
|
20
|
+
minWidth: 48,
|
|
21
|
+
overflow: 'hidden',
|
|
22
|
+
width: 48,
|
|
23
|
+
},
|
|
24
|
+
hiddenInput: {
|
|
25
|
+
color: 'transparent',
|
|
26
|
+
fontSize: 1,
|
|
27
|
+
height: 1,
|
|
28
|
+
left: '50%',
|
|
29
|
+
opacity: 0,
|
|
30
|
+
padding: 0,
|
|
31
|
+
position: 'absolute',
|
|
32
|
+
textAlign: 'center',
|
|
33
|
+
top: '50%',
|
|
34
|
+
width: 1,
|
|
35
|
+
},
|
|
36
|
+
cellText: {
|
|
37
|
+
color: '#111827',
|
|
38
|
+
fontSize: 22,
|
|
39
|
+
fontWeight: '600',
|
|
40
|
+
textAlign: 'center',
|
|
41
|
+
},
|
|
42
|
+
placeholderText: {
|
|
43
|
+
color: '#9CA3AF',
|
|
44
|
+
},
|
|
45
|
+
focusedCell: {
|
|
46
|
+
borderColor: '#2563EB',
|
|
47
|
+
borderWidth: 2,
|
|
48
|
+
},
|
|
49
|
+
filledCell: {
|
|
50
|
+
borderColor: '#9CA3AF',
|
|
51
|
+
},
|
|
52
|
+
errorCell: {
|
|
53
|
+
backgroundColor: '#FEF2F2',
|
|
54
|
+
borderColor: '#DC2626',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { OtpResendTimerProps } from '../../types';
|
|
5
|
+
import { useCountdown } from '../../hooks/useCountdown';
|
|
6
|
+
|
|
7
|
+
export function OtpResendTimer({
|
|
8
|
+
seconds = 60,
|
|
9
|
+
autoStart = true,
|
|
10
|
+
onResend,
|
|
11
|
+
onFinish,
|
|
12
|
+
renderText,
|
|
13
|
+
resendLabel = 'Resend code',
|
|
14
|
+
countdownLabel = 'Resend code in',
|
|
15
|
+
disabled = false,
|
|
16
|
+
textStyle,
|
|
17
|
+
resendTextStyle,
|
|
18
|
+
testID,
|
|
19
|
+
}: OtpResendTimerProps) {
|
|
20
|
+
const { remainingSeconds, canResend, reset, start } = useCountdown({
|
|
21
|
+
seconds,
|
|
22
|
+
autoStart,
|
|
23
|
+
onFinish,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const resendEnabled = canResend && !disabled;
|
|
27
|
+
|
|
28
|
+
const handlePress = React.useCallback(() => {
|
|
29
|
+
if (!resendEnabled) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onResend?.();
|
|
34
|
+
reset();
|
|
35
|
+
start();
|
|
36
|
+
}, [onResend, resendEnabled, reset, start]);
|
|
37
|
+
|
|
38
|
+
const content = renderText ? (
|
|
39
|
+
renderText(remainingSeconds, resendEnabled)
|
|
40
|
+
) : (
|
|
41
|
+
<Text
|
|
42
|
+
style={[
|
|
43
|
+
styles.text,
|
|
44
|
+
resendEnabled && styles.resendText,
|
|
45
|
+
textStyle,
|
|
46
|
+
resendEnabled && resendTextStyle,
|
|
47
|
+
]}
|
|
48
|
+
>
|
|
49
|
+
{resendEnabled ? resendLabel : `${countdownLabel} ${remainingSeconds}s`}
|
|
50
|
+
</Text>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Pressable
|
|
55
|
+
accessibilityRole="button"
|
|
56
|
+
accessibilityLabel={resendEnabled ? resendLabel : `${countdownLabel} ${remainingSeconds} seconds`}
|
|
57
|
+
accessibilityState={{ disabled: !resendEnabled }}
|
|
58
|
+
disabled={!resendEnabled}
|
|
59
|
+
onPress={handlePress}
|
|
60
|
+
hitSlop={8}
|
|
61
|
+
style={styles.pressable}
|
|
62
|
+
testID={testID}
|
|
63
|
+
>
|
|
64
|
+
{content}
|
|
65
|
+
</Pressable>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create({
|
|
70
|
+
pressable: {
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
justifyContent: 'center',
|
|
73
|
+
minHeight: 44,
|
|
74
|
+
minWidth: 44,
|
|
75
|
+
},
|
|
76
|
+
text: {
|
|
77
|
+
color: '#4B5563',
|
|
78
|
+
fontSize: 15,
|
|
79
|
+
fontWeight: '500',
|
|
80
|
+
textAlign: 'center',
|
|
81
|
+
},
|
|
82
|
+
resendText: {
|
|
83
|
+
color: '#2563EB',
|
|
84
|
+
fontWeight: '600',
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OtpResendTimer } from './OtpResendTimer';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { UseCountdownOptions, UseCountdownReturn } from '../types';
|
|
4
|
+
|
|
5
|
+
export function useCountdown({
|
|
6
|
+
seconds = 60,
|
|
7
|
+
autoStart = true,
|
|
8
|
+
onFinish,
|
|
9
|
+
}: UseCountdownOptions = {}): UseCountdownReturn {
|
|
10
|
+
const safeSeconds = Math.max(0, seconds);
|
|
11
|
+
const onFinishRef = React.useRef(onFinish);
|
|
12
|
+
const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
|
13
|
+
const [remainingSeconds, setRemainingSeconds] = React.useState(safeSeconds);
|
|
14
|
+
const [running, setRunning] = React.useState(autoStart && safeSeconds > 0);
|
|
15
|
+
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
onFinishRef.current = onFinish;
|
|
18
|
+
}, [onFinish]);
|
|
19
|
+
|
|
20
|
+
const stop = React.useCallback(() => {
|
|
21
|
+
setRunning(false);
|
|
22
|
+
|
|
23
|
+
if (intervalRef.current) {
|
|
24
|
+
clearInterval(intervalRef.current);
|
|
25
|
+
intervalRef.current = null;
|
|
26
|
+
}
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const start = React.useCallback(() => {
|
|
30
|
+
if (safeSeconds <= 0) {
|
|
31
|
+
setRemainingSeconds(0);
|
|
32
|
+
setRunning(false);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setRemainingSeconds((current) => (current > 0 ? current : safeSeconds));
|
|
37
|
+
setRunning(true);
|
|
38
|
+
}, [safeSeconds]);
|
|
39
|
+
|
|
40
|
+
const reset = React.useCallback(() => {
|
|
41
|
+
setRemainingSeconds(safeSeconds);
|
|
42
|
+
setRunning(autoStart && safeSeconds > 0);
|
|
43
|
+
}, [autoStart, safeSeconds]);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
setRemainingSeconds(safeSeconds);
|
|
47
|
+
setRunning(autoStart && safeSeconds > 0);
|
|
48
|
+
}, [autoStart, safeSeconds]);
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (!running) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
intervalRef.current = setInterval(() => {
|
|
56
|
+
setRemainingSeconds((current) => {
|
|
57
|
+
if (current <= 1) {
|
|
58
|
+
if (intervalRef.current) {
|
|
59
|
+
clearInterval(intervalRef.current);
|
|
60
|
+
intervalRef.current = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setRunning(false);
|
|
64
|
+
onFinishRef.current?.();
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return current - 1;
|
|
69
|
+
});
|
|
70
|
+
}, 1000);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
if (intervalRef.current) {
|
|
74
|
+
clearInterval(intervalRef.current);
|
|
75
|
+
intervalRef.current = null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}, [running]);
|
|
79
|
+
|
|
80
|
+
React.useEffect(
|
|
81
|
+
() => () => {
|
|
82
|
+
if (intervalRef.current) {
|
|
83
|
+
clearInterval(intervalRef.current);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
remainingSeconds,
|
|
91
|
+
canResend: remainingSeconds === 0 && !running,
|
|
92
|
+
running,
|
|
93
|
+
start,
|
|
94
|
+
stop,
|
|
95
|
+
reset,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { UseOtpInputOptions, UseOtpInputReturn } from '../types';
|
|
5
|
+
import { createOtpArray } from '../utils/createOtpArray';
|
|
6
|
+
import { sanitizeOtp } from '../utils/sanitizeOtp';
|
|
7
|
+
|
|
8
|
+
function normalizeOtpCells(values: string[], length: number): string[] {
|
|
9
|
+
return Array.from({ length }, (_, index) => sanitizeOtp(values[index] ?? '', 1));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getReplacementDigit(text: string, currentDigit: string): string {
|
|
13
|
+
const digits = sanitizeOtp(text, text.length);
|
|
14
|
+
const differentDigit = digits.split('').find((digit) => digit !== currentDigit);
|
|
15
|
+
|
|
16
|
+
return differentDigit ?? digits[digits.length - 1] ?? '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSequentialFocusIndex(values: string[], length: number): number {
|
|
20
|
+
const firstEmptyIndex = values.findIndex((value) => !value);
|
|
21
|
+
|
|
22
|
+
return firstEmptyIndex === -1 ? length - 1 : firstEmptyIndex;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getLastFilledIndex(values: string[]): number {
|
|
26
|
+
for (let index = values.length - 1; index >= 0; index -= 1) {
|
|
27
|
+
if (values[index]) {
|
|
28
|
+
return index;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return -1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function replaceCellAndClearAfter(values: string[], digit: string, index: number): string[] {
|
|
36
|
+
return values.map((value, valueIndex) => {
|
|
37
|
+
if (valueIndex < index) {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (valueIndex === index) {
|
|
42
|
+
return digit;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return '';
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useOtpInput({
|
|
50
|
+
length = 6,
|
|
51
|
+
value,
|
|
52
|
+
defaultValue = '',
|
|
53
|
+
onChangeCode,
|
|
54
|
+
onComplete,
|
|
55
|
+
allowCellSelection = true,
|
|
56
|
+
}: UseOtpInputOptions = {}): UseOtpInputReturn {
|
|
57
|
+
const safeLength = Math.max(1, length);
|
|
58
|
+
const isControlled = value !== undefined;
|
|
59
|
+
const inputRefs = React.useRef<Array<TextInput | null>>([]);
|
|
60
|
+
const completedCodeRef = React.useRef<string | null>(null);
|
|
61
|
+
const [cellValues, setCellValues] = React.useState(() =>
|
|
62
|
+
createOtpArray(sanitizeOtp(value ?? defaultValue, safeLength), safeLength)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const [focusedIndex, setFocusedIndex] = React.useState(0);
|
|
66
|
+
const code = cellValues.join('');
|
|
67
|
+
const cellValuesRef = React.useRef(cellValues);
|
|
68
|
+
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
cellValuesRef.current = cellValues;
|
|
71
|
+
}, [cellValues]);
|
|
72
|
+
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (!isControlled) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nextCode = sanitizeOtp(value ?? '', safeLength);
|
|
79
|
+
|
|
80
|
+
if (nextCode !== code) {
|
|
81
|
+
setCellValues(createOtpArray(nextCode, safeLength));
|
|
82
|
+
}
|
|
83
|
+
}, [code, isControlled, safeLength, value]);
|
|
84
|
+
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
setCellValues((currentValues) => {
|
|
87
|
+
if (currentValues.length === safeLength) {
|
|
88
|
+
return currentValues;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return normalizeOtpCells(currentValues, safeLength);
|
|
92
|
+
});
|
|
93
|
+
}, [safeLength]);
|
|
94
|
+
|
|
95
|
+
const focusCell = React.useCallback(
|
|
96
|
+
(index: number) => {
|
|
97
|
+
const requestedIndex = allowCellSelection
|
|
98
|
+
? index
|
|
99
|
+
: getSequentialFocusIndex(cellValuesRef.current, safeLength);
|
|
100
|
+
const nextIndex = Math.min(Math.max(requestedIndex, 0), safeLength - 1);
|
|
101
|
+
setFocusedIndex(nextIndex);
|
|
102
|
+
inputRefs.current[nextIndex]?.focus();
|
|
103
|
+
},
|
|
104
|
+
[allowCellSelection, safeLength]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const commitCellValues = React.useCallback(
|
|
108
|
+
(nextValues: string[]) => {
|
|
109
|
+
const normalizedValues = normalizeOtpCells(nextValues, safeLength);
|
|
110
|
+
const nextCode = normalizedValues.join('');
|
|
111
|
+
|
|
112
|
+
setCellValues(normalizedValues);
|
|
113
|
+
onChangeCode?.(nextCode);
|
|
114
|
+
|
|
115
|
+
if (nextCode.length !== safeLength) {
|
|
116
|
+
completedCodeRef.current = null;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
[onChangeCode, safeLength]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const replaceCell = React.useCallback(
|
|
123
|
+
(digit: string, index: number) => {
|
|
124
|
+
const nextValues = [...cellValues];
|
|
125
|
+
nextValues[index] = digit;
|
|
126
|
+
commitCellValues(nextValues);
|
|
127
|
+
},
|
|
128
|
+
[cellValues, commitCellValues]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const handleChangeText = React.useCallback(
|
|
132
|
+
(text: string, index: number) => {
|
|
133
|
+
const pastedText = sanitizeOtp(text, safeLength);
|
|
134
|
+
const targetIndex = allowCellSelection
|
|
135
|
+
? index
|
|
136
|
+
: getSequentialFocusIndex(cellValues, safeLength);
|
|
137
|
+
|
|
138
|
+
if (!pastedText) {
|
|
139
|
+
if (allowCellSelection && text === '' && cellValues[index]) {
|
|
140
|
+
replaceCell('', index);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (pastedText.length === safeLength) {
|
|
146
|
+
commitCellValues(createOtpArray(pastedText, safeLength));
|
|
147
|
+
requestAnimationFrame(() => focusCell(safeLength - 1));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!allowCellSelection && code.length === safeLength) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (pastedText.length > 1 && cellValues[targetIndex]) {
|
|
156
|
+
const replacementDigit = getReplacementDigit(pastedText, cellValues[targetIndex]);
|
|
157
|
+
const nextValues = allowCellSelection
|
|
158
|
+
? replaceCellAndClearAfter(cellValues, replacementDigit, targetIndex)
|
|
159
|
+
: (() => {
|
|
160
|
+
const values = [...cellValues];
|
|
161
|
+
values[targetIndex] = replacementDigit;
|
|
162
|
+
return values;
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
commitCellValues(nextValues);
|
|
166
|
+
|
|
167
|
+
if (targetIndex < safeLength - 1) {
|
|
168
|
+
requestAnimationFrame(() => focusCell(targetIndex + 1));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (code.length === safeLength && !cellValues[targetIndex]) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (pastedText.length === 1) {
|
|
179
|
+
const nextValues =
|
|
180
|
+
allowCellSelection && cellValues[targetIndex]
|
|
181
|
+
? replaceCellAndClearAfter(cellValues, pastedText, targetIndex)
|
|
182
|
+
: (() => {
|
|
183
|
+
const values = [...cellValues];
|
|
184
|
+
values[targetIndex] = pastedText;
|
|
185
|
+
return values;
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
commitCellValues(nextValues);
|
|
189
|
+
|
|
190
|
+
if (targetIndex < safeLength - 1) {
|
|
191
|
+
requestAnimationFrame(() => focusCell(targetIndex + 1));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const nextValues = [...cellValues];
|
|
198
|
+
|
|
199
|
+
pastedText.split('').forEach((digit, offset) => {
|
|
200
|
+
const nextIndex = targetIndex + offset;
|
|
201
|
+
|
|
202
|
+
if (nextIndex < safeLength) {
|
|
203
|
+
nextValues[nextIndex] = digit;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
commitCellValues(nextValues);
|
|
208
|
+
requestAnimationFrame(() =>
|
|
209
|
+
focusCell(Math.min(targetIndex + pastedText.length, safeLength - 1))
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
[
|
|
213
|
+
allowCellSelection,
|
|
214
|
+
cellValues,
|
|
215
|
+
code.length,
|
|
216
|
+
commitCellValues,
|
|
217
|
+
focusCell,
|
|
218
|
+
replaceCell,
|
|
219
|
+
safeLength,
|
|
220
|
+
]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const handleKeyPress = React.useCallback(
|
|
224
|
+
(
|
|
225
|
+
event: NativeSyntheticEvent<TextInputKeyPressEventData>,
|
|
226
|
+
index: number
|
|
227
|
+
) => {
|
|
228
|
+
if (event.nativeEvent.key !== 'Backspace') {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!allowCellSelection) {
|
|
233
|
+
const lastFilledIndex = getLastFilledIndex(cellValues);
|
|
234
|
+
|
|
235
|
+
if (lastFilledIndex >= 0) {
|
|
236
|
+
replaceCell('', lastFilledIndex);
|
|
237
|
+
requestAnimationFrame(() => focusCell(lastFilledIndex));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (cellValues[index]) {
|
|
244
|
+
replaceCell('', index);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (index > 0) {
|
|
249
|
+
requestAnimationFrame(() => focusCell(index - 1));
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
[allowCellSelection, cellValues, focusCell, replaceCell]
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const clear = React.useCallback(() => {
|
|
256
|
+
commitCellValues(createOtpArray('', safeLength));
|
|
257
|
+
requestAnimationFrame(() => focusCell(0));
|
|
258
|
+
}, [commitCellValues, focusCell, safeLength]);
|
|
259
|
+
|
|
260
|
+
React.useEffect(() => {
|
|
261
|
+
if (code.length === safeLength && completedCodeRef.current !== code) {
|
|
262
|
+
completedCodeRef.current = code;
|
|
263
|
+
onComplete?.(code);
|
|
264
|
+
requestAnimationFrame(() => focusCell(safeLength - 1));
|
|
265
|
+
}
|
|
266
|
+
}, [code, focusCell, onComplete, safeLength]);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
code,
|
|
270
|
+
cellValues,
|
|
271
|
+
focusedIndex,
|
|
272
|
+
inputRefs,
|
|
273
|
+
setFocusedIndex,
|
|
274
|
+
handleChangeText,
|
|
275
|
+
handleKeyPress,
|
|
276
|
+
focusCell,
|
|
277
|
+
clear,
|
|
278
|
+
};
|
|
279
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { OtpInput } from './components/OtpInput';
|
|
2
|
+
export { OtpInputCell } from './components/OtpInput';
|
|
3
|
+
export { OtpResendTimer } from './components/OtpResendTimer';
|
|
4
|
+
export { useOtpInput } from './hooks/useOtpInput';
|
|
5
|
+
export { useCountdown } from './hooks/useCountdown';
|
|
6
|
+
export { sanitizeOtp } from './utils/sanitizeOtp';
|
|
7
|
+
export { createOtpArray } from './utils/createOtpArray';
|
|
8
|
+
export type {
|
|
9
|
+
OtpInputCellProps,
|
|
10
|
+
OtpInputProps,
|
|
11
|
+
OtpResendTimerProps,
|
|
12
|
+
UseCountdownOptions,
|
|
13
|
+
UseCountdownReturn,
|
|
14
|
+
UseOtpInputOptions,
|
|
15
|
+
UseOtpInputReturn,
|
|
16
|
+
} from './types';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
NativeSyntheticEvent,
|
|
4
|
+
TextInput,
|
|
5
|
+
TextInputKeyPressEventData,
|
|
6
|
+
TextInputProps,
|
|
7
|
+
TextStyle,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
|
|
11
|
+
export type OtpInputProps = {
|
|
12
|
+
length?: number;
|
|
13
|
+
value?: string;
|
|
14
|
+
defaultValue?: string;
|
|
15
|
+
onChangeCode?: (code: string) => void;
|
|
16
|
+
onComplete?: (code: string) => void;
|
|
17
|
+
allowCellSelection?: boolean;
|
|
18
|
+
autoFocus?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
error?: boolean;
|
|
21
|
+
secureTextEntry?: boolean;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
keyboardType?: TextInputProps['keyboardType'];
|
|
24
|
+
containerStyle?: ViewStyle;
|
|
25
|
+
cellStyle?: ViewStyle;
|
|
26
|
+
focusedCellStyle?: ViewStyle;
|
|
27
|
+
filledCellStyle?: ViewStyle;
|
|
28
|
+
errorCellStyle?: ViewStyle;
|
|
29
|
+
textStyle?: TextStyle;
|
|
30
|
+
testID?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type OtpInputCellProps = {
|
|
34
|
+
value: string;
|
|
35
|
+
index: number;
|
|
36
|
+
length?: number;
|
|
37
|
+
focused?: boolean;
|
|
38
|
+
filled?: boolean;
|
|
39
|
+
error?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
secureTextEntry?: boolean;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
keyboardType?: TextInputProps['keyboardType'];
|
|
44
|
+
cellStyle?: ViewStyle;
|
|
45
|
+
focusedCellStyle?: ViewStyle;
|
|
46
|
+
filledCellStyle?: ViewStyle;
|
|
47
|
+
errorCellStyle?: ViewStyle;
|
|
48
|
+
textStyle?: TextStyle;
|
|
49
|
+
testID?: string;
|
|
50
|
+
inputRef?: (input: TextInput | null) => void;
|
|
51
|
+
onFocus?: (index: number) => void;
|
|
52
|
+
onChangeText: (text: string, index: number) => void;
|
|
53
|
+
onKeyPress: (
|
|
54
|
+
event: NativeSyntheticEvent<TextInputKeyPressEventData>,
|
|
55
|
+
index: number
|
|
56
|
+
) => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type OtpResendTimerProps = {
|
|
60
|
+
seconds?: number;
|
|
61
|
+
autoStart?: boolean;
|
|
62
|
+
onResend?: () => void;
|
|
63
|
+
onFinish?: () => void;
|
|
64
|
+
renderText?: (remainingSeconds: number, canResend: boolean) => React.ReactNode;
|
|
65
|
+
resendLabel?: string;
|
|
66
|
+
countdownLabel?: string;
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
textStyle?: TextStyle;
|
|
69
|
+
resendTextStyle?: TextStyle;
|
|
70
|
+
testID?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type UseOtpInputOptions = {
|
|
74
|
+
length?: number;
|
|
75
|
+
value?: string;
|
|
76
|
+
defaultValue?: string;
|
|
77
|
+
onChangeCode?: (code: string) => void;
|
|
78
|
+
onComplete?: (code: string) => void;
|
|
79
|
+
allowCellSelection?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type UseOtpInputReturn = {
|
|
83
|
+
code: string;
|
|
84
|
+
cellValues: string[];
|
|
85
|
+
focusedIndex: number;
|
|
86
|
+
inputRefs: React.MutableRefObject<Array<TextInput | null>>;
|
|
87
|
+
setFocusedIndex: React.Dispatch<React.SetStateAction<number>>;
|
|
88
|
+
handleChangeText: (text: string, index: number) => void;
|
|
89
|
+
handleKeyPress: (
|
|
90
|
+
event: NativeSyntheticEvent<TextInputKeyPressEventData>,
|
|
91
|
+
index: number
|
|
92
|
+
) => void;
|
|
93
|
+
focusCell: (index: number) => void;
|
|
94
|
+
clear: () => void;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type UseCountdownOptions = {
|
|
98
|
+
seconds?: number;
|
|
99
|
+
autoStart?: boolean;
|
|
100
|
+
onFinish?: () => void;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type UseCountdownReturn = {
|
|
104
|
+
remainingSeconds: number;
|
|
105
|
+
canResend: boolean;
|
|
106
|
+
running: boolean;
|
|
107
|
+
start: () => void;
|
|
108
|
+
stop: () => void;
|
|
109
|
+
reset: () => void;
|
|
110
|
+
};
|