@codsod/react-native-otp-input
Version:
A customizable OTP input component for React Native with TypeScript support, enabling seamless user authentication with flexible input handling and cross-platform compatibility.
286 lines (253 loc) • 8.01 kB
JSX
import React, { useState, useRef, useEffect, useCallback, memo } from "react";
import {
View,
TextInput,
StyleSheet,
BackHandler,
Platform,
Keyboard,
} from "react-native";
import PropTypes from "prop-types";
const OTPInput = memo(
({
length = 4,
onOtpComplete = () => {},
style,
inputStyle,
disabled = false,
autoFocus = true,
keyboardType = "numeric",
placeholder = "",
secureTextEntry = false,
error = false,
errorStyle,
}) => {
const [otp, setOtp] = useState(Array(length).fill(""));
const inputRefs = useRef([]);
// Report OTP changes to parent component
useEffect(() => {
// Always report the current OTP value, even when incomplete
onOtpComplete(otp.join(""));
}, [otp, onOtpComplete]);
const handleChange = useCallback(
(text, index) => {
if (disabled) return;
// Handle paste event (multiple characters)
if (text.length > 1) {
const pastedData = text.slice(0, length);
const newOtp = Array(length).fill("");
for (let i = 0; i < pastedData.length; i++) {
if (!isNaN(pastedData[i]) && pastedData[i] !== "") {
newOtp[i] = pastedData[i];
}
}
setOtp(newOtp);
const focusIndex = Math.min(pastedData.length, length - 1);
setTimeout(() => {
inputRefs.current[focusIndex]?.focus();
}, 0);
if (newOtp.every((val) => val !== "")) {
Keyboard.dismiss();
}
return;
}
// Handle single character input
const newOtp = [...otp];
// Only accept numeric input
if (!isNaN(text) && text !== "") {
newOtp[index] = text;
setOtp(newOtp);
// Move to next input if available
if (index < length - 1) {
inputRefs.current[index + 1]?.focus();
} else {
// Last digit entered, can dismiss keyboard
Keyboard.dismiss();
}
} else if (text === "") {
// Handle empty input (usually from clear)
newOtp[index] = "";
setOtp(newOtp);
}
},
[disabled, length, otp]
);
// Handle key press events (for backspace navigation)
const handleKeyPress = useCallback(
(event, index) => {
const { key } = event.nativeEvent;
if (key === "Backspace") {
// If current input has value, clear it
if (otp[index] !== "") {
const newOtp = [...otp];
newOtp[index] = "";
setOtp(newOtp);
}
// If current input is empty and not the first input, move focus to previous
else if (index > 0) {
inputRefs.current[index - 1]?.focus();
}
}
},
[otp]
);
// Handle hardware back button on Android
const handleBackPress = useCallback(() => {
const focusedInputIndex = inputRefs.current.findIndex(
(ref) => ref && ref.isFocused()
);
// No input has focus, find last filled input
if (focusedInputIndex === -1) {
const lastFilledIndex = [...otp]
.reverse()
.findIndex((val) => val !== "");
if (lastFilledIndex !== -1) {
const index = length - 1 - lastFilledIndex;
inputRefs.current[index]?.focus();
return true;
}
return false;
}
// Current field has value, clear it
if (otp[focusedInputIndex] !== "") {
const newOtp = [...otp];
newOtp[focusedInputIndex] = "";
setOtp(newOtp);
return true;
}
// Current field is empty, move focus to previous if possible
if (focusedInputIndex > 0) {
inputRefs.current[focusedInputIndex - 1]?.focus();
return true;
}
// Let the system handle the back button (navigation)
return false;
}, [otp, length]);
// Set up event listeners
useEffect(() => {
let backHandler;
let keyDownHandler;
if (Platform.OS === "android") {
backHandler = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress
);
} else {
// Handle backspace on web/iOS
keyDownHandler = (event) => {
if (event.key === "Backspace") {
const focusedInputIndex = inputRefs.current.findIndex(
(ref) => ref && ref.isFocused()
);
if (focusedInputIndex === -1) return;
// Current field has value, clear it
if (otp[focusedInputIndex] !== "") {
const newOtp = [...otp];
newOtp[focusedInputIndex] = "";
setOtp(newOtp);
event.preventDefault();
return;
}
// Current field is empty, move to previous
if (focusedInputIndex > 0) {
inputRefs.current[focusedInputIndex - 1]?.focus();
event.preventDefault();
}
}
};
if (typeof window !== "undefined") {
window.addEventListener("keydown", keyDownHandler);
}
}
return () => {
if (Platform.OS === "android") {
backHandler?.remove();
} else if (typeof window !== "undefined") {
window.removeEventListener("keydown", keyDownHandler);
}
};
}, [handleBackPress, otp]);
// Reset the OTP input
const reset = useCallback(() => {
setOtp(Array(length).fill(""));
setTimeout(() => {
inputRefs.current[0]?.focus();
}, 0);
}, [length]);
// Handle disabled state
useEffect(() => {
if (disabled) {
reset();
}
}, [disabled, reset]);
return (
<View
style={[styles.otpContainer, style]}
accessible={true}
accessibilityLabel={`OTP input with ${length} digits`}
accessibilityHint="Enter your OTP code"
>
{Array(length)
.fill(0)
.map((_, index) => (
<TextInput
key={index}
ref={(ref) => (inputRefs.current[index] = ref)}
style={[
styles.otpInput,
error && styles.errorInput,
inputStyle,
errorStyle,
]}
value={otp[index]}
onChangeText={(text) => handleChange(text, index)}
onKeyPress={(event) => handleKeyPress(event, index)}
keyboardType={keyboardType}
maxLength={1}
textAlign="center"
autoFocus={autoFocus && index === 0}
editable={!disabled}
placeholder={placeholder}
secureTextEntry={secureTextEntry}
selectTextOnFocus
contextMenuHidden={Platform.OS === "android"}
/>
))}
</View>
);
}
);
OTPInput.propTypes = {
length: PropTypes.number,
onOtpComplete: PropTypes.func,
style: PropTypes.object,
inputStyle: PropTypes.object,
disabled: PropTypes.bool,
autoFocus: PropTypes.bool,
keyboardType: PropTypes.string,
placeholder: PropTypes.string,
secureTextEntry: PropTypes.bool,
error: PropTypes.bool,
errorStyle: PropTypes.object,
};
const styles = StyleSheet.create({
otpContainer: {
flexDirection: "row",
justifyContent: "space-between",
},
otpInput: {
width: 50,
height: 50,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 5,
marginHorizontal: 5,
fontSize: 18,
textAlign: "center",
backgroundColor: "#fff",
},
errorInput: {
borderColor: "#ff0000",
},
});
export default OTPInput;