|
| 1 | +// Copyright (C) 2025 Nethesis S.r.l. |
| 2 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | + |
| 4 | +import { |
| 5 | + useRef, |
| 6 | + useEffect, |
| 7 | + KeyboardEvent, |
| 8 | + ClipboardEvent, |
| 9 | + forwardRef, |
| 10 | + useImperativeHandle, |
| 11 | +} from 'react' |
| 12 | + |
| 13 | +interface OTPInputProps { |
| 14 | + value: string |
| 15 | + onChange: (value: string) => void |
| 16 | + length?: number |
| 17 | + disabled?: boolean |
| 18 | + className?: string |
| 19 | + error?: boolean |
| 20 | +} |
| 21 | + |
| 22 | +export interface OTPInputRef { |
| 23 | + focus: () => void |
| 24 | + clear: () => void |
| 25 | +} |
| 26 | + |
| 27 | +export const OTPInput = forwardRef<OTPInputRef, OTPInputProps>( |
| 28 | + ({ value, onChange, length = 6, disabled = false, className = '', error = false }, ref) => { |
| 29 | + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) |
| 30 | + |
| 31 | + // Initialize input refs array |
| 32 | + useEffect(() => { |
| 33 | + inputRefs.current = inputRefs.current.slice(0, length) |
| 34 | + }, [length]) |
| 35 | + |
| 36 | + // Expose methods to parent component |
| 37 | + useImperativeHandle(ref, () => ({ |
| 38 | + focus: () => { |
| 39 | + const firstEmptyIndex = value.length < length ? value.length : 0 |
| 40 | + inputRefs.current[firstEmptyIndex]?.focus() |
| 41 | + }, |
| 42 | + clear: () => { |
| 43 | + onChange('') |
| 44 | + inputRefs.current[0]?.focus() |
| 45 | + }, |
| 46 | + })) |
| 47 | + |
| 48 | + // Auto-focus first input on mount |
| 49 | + useEffect(() => { |
| 50 | + if (!disabled) { |
| 51 | + inputRefs.current[0]?.focus() |
| 52 | + } |
| 53 | + }, [disabled]) |
| 54 | + |
| 55 | + const focusInput = (index: number) => { |
| 56 | + if (inputRefs.current[index]) { |
| 57 | + inputRefs.current[index]?.focus() |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + const focusNextInput = (index: number) => { |
| 62 | + if (index < length - 1) { |
| 63 | + focusInput(index + 1) |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + const focusPrevInput = (index: number) => { |
| 68 | + if (index > 0) { |
| 69 | + focusInput(index - 1) |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + const handleChange = (index: number, inputValue: string) => { |
| 74 | + // Remove any non-digit characters |
| 75 | + const digit = inputValue.replace(/\D/g, '') |
| 76 | + |
| 77 | + if (digit.length <= 1) { |
| 78 | + const newValue = value.split('') |
| 79 | + newValue[index] = digit |
| 80 | + const updatedValue = newValue.join('').slice(0, length) |
| 81 | + onChange(updatedValue) |
| 82 | + |
| 83 | + // Auto-focus next input if a digit was entered |
| 84 | + if (digit && index < length - 1) { |
| 85 | + focusNextInput(index) |
| 86 | + } |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => { |
| 91 | + const { key } = event |
| 92 | + |
| 93 | + if (key === 'Backspace') { |
| 94 | + if (value[index]) { |
| 95 | + // Clear current input |
| 96 | + const newValue = value.split('') |
| 97 | + newValue[index] = '' |
| 98 | + onChange(newValue.join('')) |
| 99 | + } else if (index > 0) { |
| 100 | + // Move to previous input and clear it |
| 101 | + const newValue = value.split('') |
| 102 | + newValue[index - 1] = '' |
| 103 | + onChange(newValue.join('')) |
| 104 | + focusPrevInput(index) |
| 105 | + } |
| 106 | + } else if (key === 'ArrowLeft') { |
| 107 | + event.preventDefault() |
| 108 | + focusPrevInput(index) |
| 109 | + } else if (key === 'ArrowRight') { |
| 110 | + event.preventDefault() |
| 111 | + focusNextInput(index) |
| 112 | + } else if (key === 'Delete') { |
| 113 | + event.preventDefault() |
| 114 | + const newValue = value.split('') |
| 115 | + newValue[index] = '' |
| 116 | + onChange(newValue.join('')) |
| 117 | + } else if (/^[0-9]$/.test(key)) { |
| 118 | + // Handle direct digit input |
| 119 | + event.preventDefault() |
| 120 | + handleChange(index, key) |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => { |
| 125 | + event.preventDefault() |
| 126 | + const pasteData = event.clipboardData.getData('text') |
| 127 | + const digits = pasteData.replace(/\D/g, '').slice(0, length) |
| 128 | + onChange(digits) |
| 129 | + |
| 130 | + // Focus the next empty input or the last input |
| 131 | + const nextFocusIndex = Math.min(digits.length, length - 1) |
| 132 | + setTimeout(() => focusInput(nextFocusIndex), 0) |
| 133 | + } |
| 134 | + |
| 135 | + const handleFocus = (index: number) => { |
| 136 | + // Select all text when focusing |
| 137 | + inputRefs.current[index]?.select() |
| 138 | + } |
| 139 | + |
| 140 | + return ( |
| 141 | + <div className={`flex space-x-3 ${className}`}> |
| 142 | + {Array.from({ length }, (_, index) => ( |
| 143 | + <input |
| 144 | + key={index} |
| 145 | + ref={(el) => (inputRefs.current[index] = el)} |
| 146 | + type='text' |
| 147 | + inputMode='numeric' |
| 148 | + pattern='[0-9]*' |
| 149 | + maxLength={1} |
| 150 | + value={value[index] || ''} |
| 151 | + onChange={(e) => handleChange(index, e.target.value)} |
| 152 | + onKeyDown={(e) => handleKeyDown(index, e)} |
| 153 | + onPaste={handlePaste} |
| 154 | + onFocus={() => handleFocus(index)} |
| 155 | + disabled={disabled} |
| 156 | + className={` |
| 157 | + w-12 h-12 text-center text-lg font-semibold |
| 158 | + border-2 rounded-lg |
| 159 | + focus:outline-none transition-colors duration-200 |
| 160 | + ${ |
| 161 | + error |
| 162 | + ? 'border-red-500 dark:border-red-400 focus:ring-2 focus:ring-red-500 focus:border-red-500 dark:focus:ring-red-400 dark:focus:border-red-400' |
| 163 | + : 'focus:ring-2 focus:ring-primary focus:border-primary dark:focus:ring-primaryDark dark:focus:border-primaryDark' |
| 164 | + } |
| 165 | + ${ |
| 166 | + !error && value[index] |
| 167 | + ? 'border-primary dark:border-primaryDark bg-primary/5 dark:bg-primaryDark/5' |
| 168 | + : !error && !value[index] |
| 169 | + ? 'border-gray-300 dark:border-gray-600' |
| 170 | + : '' |
| 171 | + } |
| 172 | + ${ |
| 173 | + error && value[index] |
| 174 | + ? 'bg-red-50 dark:bg-red-900/20' |
| 175 | + : error && !value[index] |
| 176 | + ? 'bg-red-50 dark:bg-red-900/20' |
| 177 | + : '' |
| 178 | + } |
| 179 | + ${ |
| 180 | + disabled |
| 181 | + ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed' |
| 182 | + : 'bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 hover:border-gray-400 dark:hover:border-gray-500' |
| 183 | + } |
| 184 | + `} |
| 185 | + aria-label={`Digit ${index + 1}`} |
| 186 | + /> |
| 187 | + ))} |
| 188 | + </div> |
| 189 | + ) |
| 190 | + }, |
| 191 | +) |
| 192 | + |
| 193 | +OTPInput.displayName = 'OTPInput' |
0 commit comments