'use client'; import * as React from 'react'; import { TextInput, type NativeSyntheticEvent, type TextInputKeyPressEventData, } from 'react-native'; import { getOTPFieldInputState, useOTPFieldRootContext } from '../root/OTPFieldRootContext'; import { useCompositeListItem } from '../../internals/composite/list/useCompositeListItem'; import { useRenderElement } from '../../use-render/useRenderElement'; import { useStableCallback } from '../../hooks/useStableCallback'; import { useMergedRefs } from '../../hooks/useMergedRefs'; import type { OTPFieldRootState } from '../root/OTPFieldRoot'; import type { ZestUIComponentProps } from '../../types'; import { createChangeEventDetails } from '../../utils/createChangeEventDetails'; import { REASONS } from '../../utils/reasons'; import { normalizeOTPValueWithDetails, removeOTPCharacter, replaceOTPValue } from '../utils/otp'; const MASK_CHARACTER = '\u2022'; /** * A single OTP slot. * Renders a ``. * * Its slot index comes from its position among the other inputs, via * `CompositeList` — so slots need no index prop. * * **Diverges from the web deliberately.** Upstream handles paste through a * `paste` event and selection ranges. React Native reports every edit as a whole * string through `onChangeText` and has no paste event, so both cases go through * the same path: whatever arrives is written from this slot onwards. That is also * what makes OS autofill work — iOS and Android deliver the entire SMS code into * the focused slot, which lands here as one long string. */ export function OTPFieldInput(componentProps: OTPFieldInput.Props) { const { render, className, style, ref, ...elementProps } = componentProps; const { activeIndex, autoComplete, disabled, focusInput, id, keyboardType, length, mask, normalizeValue, readOnly, registerInput, reportValueInvalid, setActiveIndex, setValue, state, validationType, value, } = useOTPFieldRootContext(); const { index, onLayout } = useCompositeListItem(); const inputRef = React.useRef(null); const mergedRef = useMergedRefs(ref, inputRef); React.useEffect(() => { if (index < 0) { return undefined; } registerInput(index, inputRef.current); return () => registerInput(index, null); }, [index, registerInput]); const slotValue = value[index] ?? ''; // What the input actually shows, which is a bullet when masked. Every // comparison against the reported text has to use this rather than the real // character, or a mask would leave its bullet in the entered text. const displayedValue = mask && slotValue ? MASK_CHARACTER : slotValue; const handleChangeText = useStableCallback((text: string) => { if (disabled || readOnly || index < 0) { return; } if (text === '') { // The slot was emptied, which only a backspace on a filled slot can do. const next = removeOTPCharacter(value, index); setValue(next, createChangeEventDetails(REASONS.inputClear)); return; } // The slot already showed a character, so RN reports the old one plus what // was added. Only the addition is new. let entered = text; if (displayedValue && text.startsWith(displayedValue) && text.length > displayedValue.length) { entered = text.slice(displayedValue.length); } const [normalized, didRejectCharacters] = normalizeOTPValueWithDetails( entered, length - index, validationType, normalizeValue, ); if (didRejectCharacters) { reportValueInvalid(entered, createChangeEventDetails(REASONS.inputChange)); } if (normalized === '') { return; } const next = replaceOTPValue(value, index, normalized, length, validationType, normalizeValue); const stored = setValue(next, createChangeEventDetails(REASONS.inputChange)); if (stored === null) { return; } // Move to the slot after everything just written, which for a pasted or // autofilled code is the end. focusInput(index + normalized.length); }); const handleKeyPress = useStableCallback( (event: NativeSyntheticEvent) => { if (disabled || readOnly || event.nativeEvent.key !== 'Backspace' || slotValue !== '') { return; } // Backspace on an empty slot steps back and clears the one before it — // `onChangeText` never fires, because the text did not change. if (index > 0) { setValue(removeOTPCharacter(value, index - 1), createChangeEventDetails(REASONS.inputClear)); focusInput(index - 1); } }, ); const inputState = getOTPFieldInputState(state, slotValue, index); return useRenderElement(TextInput, componentProps, { state: inputState, ref: mergedRef, props: [ { // Spreading this is what lets CompositeList correct the slot order from // measured layout instead of registration order. onLayout, nativeID: getSlotId(id, index), value: displayedValue, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: () => setActiveIndex(index), onBlur: () => { if (activeIndex === index) { setActiveIndex(-1); } }, editable: !disabled && !readOnly, keyboardType, // Only the first slot advertises the code: the OS fills the field it is // on, and every slot claiming it would make the suggestion ambiguous. autoComplete: (index === 0 ? autoComplete : 'off') as 'one-time-code' | 'off', textContentType: index === 0 ? ('oneTimeCode' as const) : ('none' as const), enterKeyHint: index === length - 1 ? ('done' as const) : ('next' as const), // Every slot is an identical one-character box, so position is the only // thing that tells them apart out loud. A consumer-supplied // `accessibilityLabel` in `elementProps` still wins. accessibilityLabel: `Digit ${index + 1} of ${length}`, accessibilityState: { disabled: disabled || undefined }, }, elementProps, ], }); } /** The first slot uses the root's id; the rest derive theirs from it. */ function getSlotId(id: string | undefined, index: number) { if (id === undefined) { return undefined; } return index === 0 ? id : `${id}-${index + 1}`; } export interface OTPFieldInputState extends OTPFieldRootState { /** * This slot's character. */ value: string; /** * This slot's index. */ index: number; /** * Whether this slot has a character. */ filled: boolean; } export interface OTPFieldInputProps extends Omit, 'value'> {} export namespace OTPFieldInput { export type State = OTPFieldInputState; export type Props = OTPFieldInputProps; }