import type { ComponentProps } from 'react'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { InteractionManager } from 'react-native'; import type { StyleProp, ViewStyle, TextInput } from 'react-native'; import Icon from '../Icon'; import PinCell from './PinCell'; import { StyledErrorContainer, StyledErrorMessage, StyledHiddenInput, StyledSpacer, StyledPinWrapper, StyledWrapper, } from './StyledPinInput'; import type { State } from './StyledPinInput'; interface PinInputProps { /** * The value to show for the input. */ value?: string; /** * Callback function that's called when the text changed. */ onChangeText: (value: string) => void; /** * Callback function that's called when the input is completely filled. */ onFulfill?: (value: string) => void; /** * Number of character for the input. */ length?: number; /* * Whether the input is disabled. */ disabled?: boolean; /** * Whether the pin value is secured using masks. * By default, this is true, meaning that masks are shown to hide pin value. */ secure?: boolean; /** * If true, focuses the input on componentDidMount. */ autoFocus?: boolean; /** * Error message. */ error?: string; /** * Additional style. */ style?: StyleProp; /** * Testing id of the component. */ testID?: string; /** * The text content type of the input. */ textContentType?: ComponentProps['textContentType']; /** * The autofill type of the input. */ autoComplete?: ComponentProps['autoComplete']; /** * If true, indicates that the PinInput is accessible to screen readers. */ accessible?: boolean; } export function getState({ disabled, error, }: { disabled?: boolean; error?: string; }): State { if (disabled) { return 'disabled'; } if (error) { return 'error'; } return 'default'; } export interface PinInputHandler { /** * focus into input */ focus: () => void; /** * blur text input */ blur: () => void; } // Normalize the value to only contain numbers and limit the length export const normalizeValue = (value?: string, length = 4) => { return (value?.match(/\d/g) || []).join('').slice(0, length); }; const PinInput = forwardRef( ( { value = '', onChangeText, onFulfill, length = 4, disabled = false, secure = true, autoFocus = false, error, style, testID, textContentType, autoComplete, accessible, }, ref ) => { const inputRef = useRef(null); const [focused, setFocused] = useState(autoFocus); const state = getState({ disabled, error }); const trimmedValue = normalizeValue(value, length); const focus = useCallback(() => { if (inputRef?.current) { inputRef.current.focus(); setFocused(true); } }, []); const blur = useCallback(() => { if (inputRef?.current) { inputRef.current.blur(); setFocused(false); } }, []); const changeText = useCallback( (text: string) => { const trimmedPin = normalizeValue(text, length); // If current value is already the length, and user is not deleting, // replace it with the new text, starting from the length if ( trimmedValue.length === length && trimmedValue.length <= text.length ) { // Slice the text, starting from the length position const slicedText = text.slice(length); onChangeText?.(normalizeValue(slicedText, length)); if (slicedText.length === length) { onFulfill?.(slicedText); } } else { // Prevent user from entering more than the length onChangeText?.(trimmedPin); if (trimmedPin.length === length) { onFulfill?.(trimmedPin); } } }, [length, onChangeText, onFulfill, trimmedValue.length] ); useEffect(() => { // Must run after animations for keyboard to automatically open if (autoFocus) { InteractionManager.runAfterInteractions(focus); } }, [inputRef]); useImperativeHandle(ref, () => ({ focus, blur, })); const selection = useMemo( () => ({ start: trimmedValue.length, }), [trimmedValue] ); return ( {[...Array(length).keys()].map((index) => ( {index !== 0 && } ))} {state === 'error' && ( {error} )} ); } ); PinInput.displayName = 'PinInput'; export default PinInput;