Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 122 additions & 117 deletions src/OtpInput/OtpInput.tsx
Original file line number Diff line number Diff line change
@@ -1,131 +1,136 @@
import * as React from "react";
import { forwardRef, useImperativeHandle } from "react";
import { Platform, Pressable, Text, TextInput, View } from "react-native";
import { styles } from "./OtpInput.styles";
import { OtpInputProps, OtpInputRef } from "./OtpInput.types";
import { VerticalStick } from "./VerticalStick";

import { useOtpInput } from "./useOtpInput";
import { VerticalStick } from "./VerticalStick";
import { OtpInputProps, OtpInputRef } from "./OtpInput.types";
import { styles } from "./OtpInput.styles";

export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>(
({ defaultValue = "", ...props }, ref) => {
const {
models: { text, inputRef, focusedInputIndex, isFocused, placeholder },
actions: { clear, handlePress, handleTextChange, focus, handleFocus, handleBlur },
forms: { setTextWithRef },
} = useOtpInput({ defaultValue, ...props });

const {
disabled,
numberOfDigits = 6,
autoFocus = true,
hideStick,
focusColor = "#A4D0A4",
focusStickBlinkingDuration,
secureTextEntry = false,
theme = {},
textInputProps,
textProps,
type = "numeric",
} = props;

export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
const {
models: { text, inputRef, focusedInputIndex, isFocused, placeholder },
actions: { clear, handlePress, handleTextChange, focus, handleFocus, handleBlur, blur },
forms: { setTextWithRef },
} = useOtpInput(props);
const {
disabled,
numberOfDigits = 6,
autoFocus = true,
hideStick,
focusColor = "#A4D0A4",
focusStickBlinkingDuration,
secureTextEntry = false,
theme = {},
textInputProps,
textProps,
type = "numeric",
} = props;
const {
containerStyle,
inputsContainerStyle,
pinCodeContainerStyle,
pinCodeTextStyle,
focusStickStyle,
focusedPinCodeContainerStyle,
filledPinCodeContainerStyle,
disabledPinCodeContainerStyle,
placeholderTextStyle,
} = theme;
const {
containerStyle,
inputsContainerStyle,
pinCodeContainerStyle,
pinCodeTextStyle,
focusStickStyle,
focusedPinCodeContainerStyle,
filledPinCodeContainerStyle,
disabledPinCodeContainerStyle,
placeholderTextStyle,
} = theme;

useImperativeHandle(ref, () => ({ clear, focus, setValue: setTextWithRef, blur }));
useImperativeHandle(ref, () => ({ clear, focus, setValue: setTextWithRef }));

const generatePinCodeContainerStyle = (isFocusedContainer: boolean, char: string) => {
const stylesArray = [styles.codeContainer, pinCodeContainerStyle];
if (focusColor && isFocusedContainer) {
stylesArray.push({ borderColor: focusColor });
}
const generatePinCodeContainerStyle = (isFocusedContainer: boolean, char: string) => {
const stylesArray = [styles.codeContainer, pinCodeContainerStyle];
if (focusColor && isFocusedContainer) {
stylesArray.push({ borderColor: focusColor });
}

if (focusedPinCodeContainerStyle && isFocusedContainer) {
stylesArray.push(focusedPinCodeContainerStyle);
}
if (focusedPinCodeContainerStyle && isFocusedContainer) {
stylesArray.push(focusedPinCodeContainerStyle);
}

if (filledPinCodeContainerStyle && Boolean(char)) {
stylesArray.push(filledPinCodeContainerStyle);
}
if (filledPinCodeContainerStyle && Boolean(char)) {
stylesArray.push(filledPinCodeContainerStyle);
}

if (disabledPinCodeContainerStyle && disabled) {
stylesArray.push(disabledPinCodeContainerStyle);
}
if (disabledPinCodeContainerStyle && disabled) {
stylesArray.push(disabledPinCodeContainerStyle);
}

return stylesArray;
};
return stylesArray;
};

const placeholderStyle = {
opacity: !!placeholder ? 0.5 : pinCodeTextStyle?.opacity || 1,
...(!!placeholder ? placeholderTextStyle : []),
};
const placeholderStyle = {
opacity: !!placeholder ? 0.5 : pinCodeTextStyle?.opacity || 1,
...(!!placeholder ? placeholderTextStyle : []),
};

return (
<View style={[styles.container, containerStyle, inputsContainerStyle]}>
{Array(numberOfDigits)
.fill(0)
.map((_, index) => {
const isPlaceholderCell = !!placeholder && !text?.[index];
const char = isPlaceholderCell ? placeholder?.[index] || " " : text[index];
const isFocusedInput = index === focusedInputIndex && !disabled && Boolean(isFocused);
const isFilledLastInput = text.length === numberOfDigits && index === text.length - 1;
const isFocusedContainer = isFocusedInput || (isFilledLastInput && Boolean(isFocused));
return (
<View style={[styles.container, containerStyle, inputsContainerStyle]}>
{Array(numberOfDigits)
.fill(0)
.map((_, index) => {
const isPlaceholderCell = !!placeholder && !text?.[index];
const char = isPlaceholderCell ? placeholder?.[index] || " " : text[index];
const isFocusedInput = index === focusedInputIndex && !disabled && Boolean(isFocused);
const isFilledLastInput = text.length === numberOfDigits && index === text.length - 1;
const isFocusedContainer = isFocusedInput || (isFilledLastInput && Boolean(isFocused));

return (
<Pressable
key={`${char}-${index}`}
disabled={disabled}
onPress={handlePress}
style={generatePinCodeContainerStyle(isFocusedContainer, char)}
testID="otp-input"
>
{isFocusedInput && !hideStick ? (
<VerticalStick
focusColor={focusColor}
style={focusStickStyle}
focusStickBlinkingDuration={focusStickBlinkingDuration}
/>
) : (
<Text
{...textProps}
testID={textProps?.testID ? `${textProps.testID}-${index}` : undefined}
style={[
styles.codeText,
pinCodeTextStyle,
isPlaceholderCell ? placeholderStyle : {},
textProps?.style,
]}
>
{char && secureTextEntry ? "•" : char}
</Text>
)}
</Pressable>
);
})}
<TextInput
value={text}
onChangeText={handleTextChange}
maxLength={numberOfDigits}
inputMode={type === "numeric" ? type : "text"}
textContentType="oneTimeCode"
ref={inputRef}
autoFocus={autoFocus}
secureTextEntry={secureTextEntry}
autoComplete={Platform.OS === "android" ? "sms-otp" : "one-time-code"}
aria-disabled={disabled}
editable={!disabled}
testID="otp-input-hidden"
onFocus={handleFocus}
onBlur={handleBlur}
caretHidden={Platform.OS === "ios"}
{...textInputProps}
style={[styles.hiddenInput, textInputProps?.style]}
/>
</View>
);
});
return (
<Pressable
key={`${char}-${index}`}
disabled={disabled}
onPress={handlePress}
style={generatePinCodeContainerStyle(isFocusedContainer, char)}
testID="otp-input"
>
{isFocusedInput && !hideStick ? (
<VerticalStick
focusColor={focusColor}
style={focusStickStyle}
focusStickBlinkingDuration={focusStickBlinkingDuration}
/>
) : (
<Text
{...textProps}
testID={textProps?.testID ? `${textProps.testID}-${index}` : undefined}
style={[
styles.codeText,
pinCodeTextStyle,
isPlaceholderCell ? placeholderStyle : {},
textProps?.style,
]}
>
{char && secureTextEntry ? "•" : char}
</Text>
)}
</Pressable>
);
})}
<TextInput
value={text}
onChangeText={handleTextChange}
maxLength={numberOfDigits}
inputMode={type === "numeric" ? type : "text"}
textContentType="oneTimeCode"
ref={inputRef}
autoFocus={autoFocus}
secureTextEntry={secureTextEntry}
autoComplete={Platform.OS === "android" ? "sms-otp" : "one-time-code"}
aria-disabled={disabled}
editable={!disabled}
testID="otp-input-hidden"
onFocus={handleFocus}
onBlur={handleBlur}
caretHidden={Platform.OS === "ios"}
{...textInputProps}
style={[styles.hiddenInput, textInputProps?.style]}
/>
</View>
);
}
);
2 changes: 1 addition & 1 deletion src/OtpInput/OtpInput.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export interface OtpInputProps {
textProps?: TextProps;
type?: "alpha" | "numeric" | "alphanumeric";
placeholder?: string;
defaultValue?: string;
}

export interface OtpInputRef {
clear: () => void;
focus: () => void;
setValue: (value: string) => void;
blur: () => void;
}

export interface Theme {
Expand Down
29 changes: 17 additions & 12 deletions src/OtpInput/useOtpInput.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState, useEffect } from "react";
import { Keyboard, TextInput } from "react-native";
import { OtpInputProps } from "./OtpInput.types";

Expand All @@ -19,18 +19,24 @@ export const useOtpInput = ({
onFocus,
onBlur,
placeholder: _placeholder,
defaultValue = "", // Default value support
}: OtpInputProps) => {
const [text, setText] = useState("");
const [text, setText] = useState(defaultValue.slice(0, numberOfDigits)); // Initialize with defaultValue
const [isFocused, setIsFocused] = useState(autoFocus);
const inputRef = useRef<TextInput>(null);
const focusedInputIndex = text.length;

const placeholder = useMemo(
() => (_placeholder?.length === 1 ? _placeholder.repeat(numberOfDigits) : _placeholder),
[_placeholder, numberOfDigits]
);

useEffect(() => {
// Ensure state updates if defaultValue changes dynamically
setText(defaultValue.slice(0, numberOfDigits));
}, [defaultValue, numberOfDigits]);

const handlePress = () => {
// To fix bug when keyboard is not popping up after being dismissed
if (!Keyboard.isVisible()) {
Keyboard.dismiss();
}
Expand All @@ -40,17 +46,20 @@ export const useOtpInput = ({
const handleTextChange = (value: string) => {
if (type && regexMap[type].test(value)) return;
if (disabled) return;

setText(value);
onTextChange?.(value);

if (value.length === numberOfDigits) {
onFilled?.(value);
blurOnFilled && inputRef.current?.blur();
if (blurOnFilled) inputRef.current?.blur();
}
};

const setTextWithRef = (value: string) => {
const normalizedValue = value.length > numberOfDigits ? value.slice(0, numberOfDigits) : value;
handleTextChange(normalizedValue);
const normalizedValue = value.slice(0, numberOfDigits);
setText(normalizedValue);
onTextChange?.(normalizedValue);
};

const clear = () => {
Expand All @@ -61,10 +70,6 @@ export const useOtpInput = ({
inputRef.current?.focus();
};

const blur = () => {
inputRef.current?.blur();
};

const handleFocus = () => {
setIsFocused(true);
onFocus?.();
Expand All @@ -77,7 +82,7 @@ export const useOtpInput = ({

return {
models: { text, inputRef, focusedInputIndex, isFocused, placeholder },
actions: { handlePress, handleTextChange, clear, focus, blur, handleFocus, handleBlur },
forms: { setText, setTextWithRef },
actions: { handlePress, handleTextChange, clear, focus, handleFocus, handleBlur },
forms: { setTextWithRef },
};
};
Loading