import * as React from 'react' import { cn } from '@/lib/utils' interface PinInputProps { children: | React.ReactElement | React.ReactElement[] /** * className for the input container */ className?: string /** * `aria-label` for the input fields */ ariaLabel?: string /** * If set, the pin input receives focus on mount, `false` by default */ autoFocus?: boolean /** * Called when value changes */ onChange?: (value: string) => void /** * Called when all inputs have valid value */ onComplete?: (value: string) => void /** * Called when any input doesn't have value */ onIncomplete?: (value: string) => void /** * `name` attribute for input fields */ name?: string /** * `form` attribute for hidden input */ form?: string /** * If set, the input's value will be masked just like password input. This field is `false` by default */ mask?: boolean /** * If set, the pin input component signals to its fields that they should * use `autocomplete="one-time-code"`. This field is `false` by default */ otp?: boolean /** * Uncontrolled pin input default value. */ defaultValue?: string /** * Controlled pin input value. */ value?: string /** * The type of value pin input should allow, `alphanumeric` by default */ type?: 'numeric' | 'alphanumeric' /** * Placeholder for input fields, `○` by default */ placeholder?: string /** * If set, the user cannot set the value, `false` by default */ readOnly?: boolean /** * If set, the input fields are disabled, `false` by default */ disabled?: boolean ref?: React.Ref } const PinInputContext = React.createContext(false) const PinInput = ({ className, children, ref, ...props }: PinInputProps) => { const { defaultValue, value, onChange, onComplete, onIncomplete, placeholder = '○', type = 'alphanumeric', name, form, otp = false, mask = false, disabled = false, readOnly = false, autoFocus = false, ariaLabel = '', ...rest } = props const validChildren = getValidChildren(children) const length = getInputFieldCount(children) // pins, pinValue, refMap, ...handlers const { pins, pinValue, refMap, ...handlers } = usePinInput({ value, defaultValue, placeholder, type, length, readOnly, }) /* call onChange func if pinValue changes */ React.useEffect(() => { if (!onChange) return onChange(pinValue) }, [onChange, pinValue]) /* call onComplete/onIncomplete func if pinValue is valid and completed/incompleted */ const completeRef = React.useRef(pinValue.length === length) React.useEffect(() => { if (pinValue.length === length && completeRef.current === false) { completeRef.current = true if (onComplete) onComplete(pinValue) } if (pinValue.length !== length && completeRef.current === true) { completeRef.current = false if (onIncomplete) onIncomplete(pinValue) } }, [length, onComplete, onIncomplete, pinValue, pins, value]) /* focus on first input field if autoFocus is set */ React.useEffect(() => { if (!autoFocus) return const node = refMap?.get(0) if (node) { node.focus() } }, [autoFocus, refMap]) const skipRef = React.useRef(0) let counter = 0 const clones = validChildren.map((child) => { if (child.type === PinInputField) { const pinIndex = counter counter = counter + 1 return React.cloneElement(child, { name, inputKey: `input-${pinIndex}`, value: length > pinIndex ? pins[pinIndex] : '', onChange: (e: React.ChangeEvent) => handlers.handleChange(e, pinIndex), onFocus: (e: React.FocusEvent) => handlers.handleFocus(e, pinIndex), onBlur: () => handlers.handleBlur(pinIndex), onKeyDown: (e: React.KeyboardEvent) => handlers.handleKeyDown(e, pinIndex), onPaste: (e: React.ClipboardEvent) => handlers.handlePaste(e), placeholder: placeholder, type: type, mask: mask, autoComplete: otp ? 'one-time-code' : 'off', disabled: disabled, readOnly: readOnly, 'aria-label': ariaLabel ? ariaLabel : `Pin input ${counter} of ${length}`, ref: (node: HTMLInputElement | null) => { if (node) { refMap?.set(pinIndex, node) } else { refMap?.delete(pinIndex) } }, }) } skipRef.current = skipRef.current + 1 return child }) return (
{clones}
) } PinInput.displayName = 'PinInput' /* ========== PinInputField ========== */ interface _PinInputFieldProps { mask: boolean inputKey: string type: 'numeric' | 'alphanumeric' } interface PinInputFieldProps extends Omit< React.ComponentPropsWithoutRef<'input'>, keyof _PinInputFieldProps > { component?: T } const PinInputField = ({ className, component, ...props }: PinInputFieldProps & (React.ComponentType extends undefined ? never : React.ComponentProps)) => { const { mask, type, inputKey, ...rest } = props as _PinInputFieldProps & React.ComponentProps // Check if PinInputField is used within PinInput const isInsidePinInput = React.useContext(PinInputContext) if (!isInsidePinInput) { throw new Error( `PinInputField must be used within ${PinInput.displayName}.` ) } const Element = component || 'input' return ( ) } /* ========== usePinInput custom hook ========== */ interface UsePinInputProps { value: string | undefined defaultValue: string | undefined placeholder: string type: 'numeric' | 'alphanumeric' length: number readOnly: boolean } const usePinInput = ({ value, defaultValue, placeholder, type, length, readOnly, }: UsePinInputProps) => { const pinInputs = React.useMemo( () => Array.from({ length }, (_, index) => defaultValue ? defaultValue.charAt(index) : value ? value.charAt(index) : '' ), [defaultValue, length, value] ) const [pins, setPins] = React.useState(pinInputs) const pinValue = pins.join('').trim() /** * Update pins when values change programmatically. * This syncs the pins if the `defaultValue` or `value` prop is updated. */ React.useEffect(() => { setPins(pinInputs) }, [pinInputs]) const itemsRef = React.useRef | null>(null) function getMap() { if (!itemsRef.current) { // Initialize the Map on first usage. itemsRef.current = new Map() } return itemsRef.current } function getNode(index: number) { const map = getMap() const node = map?.get(index) return node } function focusInput(itemId: number) { const node = getNode(itemId) if (node) { node.focus() node.placeholder = '' } } function handleFocus( event: React.FocusEvent, index: number ) { event.target.select() focusInput(index) } function handleBlur(index: number) { const node = getNode(index) if (node) { node.placeholder = placeholder } } function updateInputField(val: string, index: number) { const node = getNode(index) if (node) { node.value = val } setPins((prev) => prev.map((p, i) => { if (i === index) { return val } else { return p } }) ) } function validate(value: string) { const NUMERIC_REGEX = /^[0-9]+$/ const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9]+$/i const regex = type === 'alphanumeric' ? ALPHA_NUMERIC_REGEX : NUMERIC_REGEX return regex.test(value) } const pastedVal = React.useRef(null) function handleChange(e: React.ChangeEvent, index: number) { const inputValue = e.target.value const pastedValue = pastedVal.current const inputChar = pastedValue && pastedValue.length === length ? pastedValue.charAt(length - 1) : inputValue.slice(-1) if (validate(inputChar)) { updateInputField(inputChar, index) pastedVal.current = null if (inputValue.length > 0) { focusInput(index + 1) } } } function handlePaste(event: React.ClipboardEvent) { event.preventDefault() const copyValue = event.clipboardData .getData('text/plain') .replace(/[\n\r\s]+/g, '') const copyArr = copyValue.split('').slice(0, length) const isValid = copyArr.every((c) => validate(c)) if (!isValid) return for (let i = 0; i < length; i++) { if (i < copyArr.length) { updateInputField(copyArr[i], i) } } pastedVal.current = copyValue focusInput(copyArr.length < length ? copyArr.length : length - 1) } function handleKeyDown( event: React.KeyboardEvent, index: number ) { const { ctrlKey, key, shiftKey, metaKey } = event if (type === 'numeric') { const canTypeSign = key === 'Backspace' || key === 'Tab' || key === 'Control' || key === 'Delete' || (ctrlKey && key === 'v') || (metaKey && key === 'v') ? true : !Number.isNaN(Number(key)) if (!canTypeSign || readOnly) { event.preventDefault() } } if (key === 'ArrowLeft' || (shiftKey && key === 'Tab')) { event.preventDefault() focusInput(index - 1) } else if (key === 'ArrowRight' || key === 'Tab' || key === ' ') { event.preventDefault() focusInput(index + 1) } else if (key === 'Delete') { event.preventDefault() } else if (key === 'Backspace') { event.preventDefault() updateInputField('', index) if ((event.target as HTMLInputElement).value === '') { focusInput(index - 1) } } } return { pins, pinValue, refMap: getMap(), handleFocus, handleBlur, handleChange, handlePaste, handleKeyDown, } } /* ========== Util Func ========== */ const getValidChildren = (children: React.ReactNode) => React.Children.toArray(children).filter((child) => { if (React.isValidElement(child)) { return React.isValidElement(child) } throw new Error(`${PinInput.displayName} contains invalid children.`) // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as React.ReactElement[] const getInputFieldCount = (children: React.ReactNode) => React.Children.toArray(children).filter((child) => { if (React.isValidElement(child) && child.type === PinInputField) { return React.isValidElement(child) } }).length export { PinInput, PinInputField }