From 7e4af8c09165eb8a7c0db1c9e03748f36ddeabfd Mon Sep 17 00:00:00 2001 From: Jasleen kaur Date: Mon, 4 May 2026 17:28:48 +0530 Subject: [PATCH] feat: add OTPInput component --- docs/docs/guides/12-otp-input.md | 25 +++ example/src/ExampleList.tsx | 2 + example/src/Examples/OTPInputExample.tsx | 87 ++++++++++ src/components/OTPInput/OTPInput.tsx | 177 +++++++++++++++++++++ src/components/__tests__/OTPInput.test.tsx | 100 ++++++++++++ src/index.tsx | 1 + 6 files changed, 392 insertions(+) create mode 100644 docs/docs/guides/12-otp-input.md create mode 100644 example/src/Examples/OTPInputExample.tsx create mode 100644 src/components/OTPInput/OTPInput.tsx create mode 100644 src/components/__tests__/OTPInput.test.tsx diff --git a/docs/docs/guides/12-otp-input.md b/docs/docs/guides/12-otp-input.md new file mode 100644 index 0000000000..2a02ea1303 --- /dev/null +++ b/docs/docs/guides/12-otp-input.md @@ -0,0 +1,25 @@ +# OTPInput + +A component for entering one-time passwords (OTP) with multiple input fields. + +## Usage + +```js +import * as React from 'react'; +import { OTPInput } from 'react-native-paper'; + +const MyComponent = () => { + const [otp, setOtp] = React.useState(''); + + return ( + console.log(value)} + /> + ); +}; + +export default MyComponent; +``` diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 016f085caa..39eb5ed81f 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -29,6 +29,7 @@ import ListAccordionExampleGroup from './Examples/ListAccordionGroupExample'; import ListItemExample from './Examples/ListItemExample'; import ListSectionExample from './Examples/ListSectionExample'; import MenuExample from './Examples/MenuExample'; +import OTPInputExample from './Examples/OTPInputExample'; import ProgressBarExample from './Examples/ProgressBarExample'; import RadioButtonExample from './Examples/RadioButtonExample'; import RadioButtonGroupExample from './Examples/RadioButtonGroupExample'; @@ -79,6 +80,7 @@ export const mainExamples: Record< listSection: ListSectionExample, listItem: ListItemExample, menu: MenuExample, + otpInput: OTPInputExample, progressbar: ProgressBarExample, radio: RadioButtonExample, radioGroup: RadioButtonGroupExample, diff --git a/example/src/Examples/OTPInputExample.tsx b/example/src/Examples/OTPInputExample.tsx new file mode 100644 index 0000000000..398d5216cd --- /dev/null +++ b/example/src/Examples/OTPInputExample.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { View, StyleSheet } from 'react-native'; + +import { Text, Button } from 'react-native-paper'; + +import OTPInput from '../../../src/components/OTPInput/OTPInput'; + +const OTPInputExample = () => { + const [otp, setOtp] = React.useState(''); + const [otp4, setOtp4] = React.useState(''); + const [error, setError] = React.useState(false); + + const handleComplete = (value: string) => { + if (value !== '123456') { + setError(true); + } else { + setError(false); + } + }; + + return ( + + + 6 Digit OTP + + + { + setOtp(val); + if (error) setError(false); + }} + onComplete={handleComplete} + error={error} + autoFocus + /> + + Try entering 123456 + + + 4 Digit OTP + + + + + + + + + ); +}; +OTPInputExample.title = 'OTP Input'; +export default OTPInputExample; + +const styles = StyleSheet.create({ + container: { + padding: 16, + gap: 16, + }, + title: { + marginTop: 16, + }, + helper: { + opacity: 0.6, + }, + actions: { + marginTop: 24, + }, + input: { + justifyContent: 'space-evenly', + }, +}); diff --git a/src/components/OTPInput/OTPInput.tsx b/src/components/OTPInput/OTPInput.tsx new file mode 100644 index 0000000000..2149b93952 --- /dev/null +++ b/src/components/OTPInput/OTPInput.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import { + View, + StyleSheet, + NativeSyntheticEvent, + TextInputKeyPressEventData, + ViewStyle, + StyleProp, +} from 'react-native'; + +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import { forwardRef } from '../../utils/forwardRef'; +import TextInput from '../TextInput/TextInput'; +type Props = { + length?: number; + value: string; + onChangeText: (text: string) => void; + onComplete?: (otp: string) => void; + disabled?: boolean; + error?: boolean; + autoFocus?: boolean; + style?: StyleProp; + theme?: ThemeProp; + testID?: string; +}; + +const OTPInput = forwardRef( + ( + { + length = 6, + value, + onChangeText, + onComplete, + disabled = false, + error = false, + autoFocus = false, + style, + theme: themeOverrides, + testID, + }, + ref + ) => { + const theme = useInternalTheme(themeOverrides); + + type PaperTextInputRef = React.ElementRef; + const inputsRef = React.useRef>([]); + + const values = React.useMemo(() => { + const arr = value.split('').slice(0, length); + while (arr.length < length) arr.push(''); + return arr; + }, [value, length]); + + const prevValuesRef = React.useRef(values); + + React.useEffect(() => { + prevValuesRef.current = values; + }, [values]); + + const focusInput = (index: number) => { + inputsRef.current[index]?.focus(); + }; + + const updateValue = (arr: string[]) => { + const otp = arr.join(''); + onChangeText(otp); + + if (otp.length === length && !arr.includes('')) { + onComplete?.(otp); + } + }; + + const handleChange = (text: string, index: number) => { + const prev = prevValuesRef.current; + let newValues = [...values]; + + if (text === '') { + if (prev[index] !== '') { + newValues[index] = ''; + updateValue(newValues); + return; + } + + if (index > 0) { + newValues[index - 1] = ''; + updateValue(newValues); + focusInput(index - 1); + return; + } + + return; + } + + if (text.length > 1) { + const pasted = text.replace(/\s/g, '').slice(0, length).split(''); + const filled = Array(length).fill(''); + + pasted.forEach((char, i) => { + filled[i] = char; + }); + + updateValue(filled); + + const lastIndex = Math.min(pasted.length - 1, length - 1); + focusInput(lastIndex); + return; + } + + newValues[index] = text; + updateValue(newValues); + + if (index < length - 1) { + focusInput(index + 1); + } + }; + + const handleKeyPress = ( + e: NativeSyntheticEvent, + index: number + ) => { + if (e.nativeEvent.key === 'Backspace') { + if (values[index] === '' && index > 0) { + focusInput(index - 1); + } + } + }; + + return ( + + {Array.from({ length }).map((_, index) => ( + { + inputsRef.current[index] = input; + }} + mode="outlined" + dense + keyboardType="number-pad" + maxLength={1} + value={values[index]} + onChangeText={(text) => handleChange(text, index)} + onKeyPress={(e) => handleKeyPress(e, index)} + autoFocus={autoFocus && index === 0} + disabled={disabled} + error={error} + textContentType={index === 0 ? 'oneTimeCode' : 'none'} + autoComplete={index === 0 ? 'sms-otp' : 'off'} + importantForAutofill={index === 0 ? 'yes' : 'no'} + accessibilityLabel={`OTP digit ${index + 1}`} + accessibilityRole="text" + style={styles.input} + contentStyle={[styles.content, { color: theme.colors.onSurface }]} + textAlign="center" + /> + ))} + + ); + } +); + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + input: { + width: 48, + paddingHorizontal: 18, + }, + content: { + fontSize: 18, + paddingHorizontal: 0, + }, +}); + +export default OTPInput; diff --git a/src/components/__tests__/OTPInput.test.tsx b/src/components/__tests__/OTPInput.test.tsx new file mode 100644 index 0000000000..1579a13643 --- /dev/null +++ b/src/components/__tests__/OTPInput.test.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; + +import { render, fireEvent } from '@testing-library/react-native'; + +import OTPInput from '../OTPInput/OTPInput'; + +describe('OTPInput', () => { + it('renders correct number of inputs', () => { + const { getAllByLabelText } = render( + {}} length={4} /> + ); + + const inputs = getAllByLabelText(/OTP digit/i); + expect(inputs).toHaveLength(4); + }); + + it('updates value on typing', () => { + const onChangeText = jest.fn(); + + const { getAllByLabelText } = render( + + ); + + const inputs = getAllByLabelText(/OTP digit/i); + + fireEvent.changeText(inputs[0], '1'); + + expect(onChangeText).toHaveBeenCalledWith('1'); + }); + + it('moves to next input on typing', () => { + const { getAllByLabelText } = render( + {}} length={4} autoFocus /> + ); + + const inputs = getAllByLabelText(/OTP digit/i); + + fireEvent.changeText(inputs[0], '1'); + + expect(inputs[1]).toBeTruthy(); + }); + + it('calls onComplete when filled', () => { + const onComplete = jest.fn(); + + const Wrapper = () => { + const [value, setValue] = React.useState(''); + + return ( + + ); + }; + + const { getAllByLabelText } = render(); + + const inputs = getAllByLabelText(/OTP digit/i); + + fireEvent.changeText(inputs[0], '1'); + fireEvent.changeText(inputs[1], '2'); + fireEvent.changeText(inputs[2], '3'); + fireEvent.changeText(inputs[3], '4'); + + expect(onComplete).toHaveBeenCalledWith('1234'); + }); + + it('handles backspace correctly', () => { + const onChangeText = jest.fn(); + + const { getAllByLabelText } = render( + + ); + + const inputs = getAllByLabelText(/OTP digit/i); + + fireEvent(inputs[1], 'keyPress', { + nativeEvent: { key: 'Backspace' }, + }); + + expect(inputs[0]).toBeTruthy(); + }); + + it('handles paste input', () => { + const onChangeText = jest.fn(); + + const { getAllByLabelText } = render( + + ); + + const inputs = getAllByLabelText(/OTP digit/i); + + fireEvent.changeText(inputs[0], '1234'); + + expect(onChangeText).toHaveBeenCalledWith('1234'); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..715ceca156 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -44,6 +44,7 @@ export { default as Icon } from './components/Icon'; export { default as IconButton } from './components/IconButton/IconButton'; export { default as Menu } from './components/Menu/Menu'; export { default as Modal } from './components/Modal'; +export { default as OTPInput } from './components/OTPInput/OTPInput'; export { default as Portal } from './components/Portal/Portal'; export { default as ProgressBar } from './components/ProgressBar'; export { default as RadioButton } from './components/RadioButton';