UNPKG

@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
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;