Skip to content

Commit 828c91c

Browse files
feat: add OTP input component
1 parent 04337ce commit 828c91c

File tree

1 file changed

+193
-0
lines changed
  • src/renderer/src/components/pageComponents/login

1 file changed

+193
-0
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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

Comments
 (0)