Skip to content

Commit 2360b1b

Browse files
committed
feat: implement OTP input component and integrate into RuxOtp
1 parent a78d800 commit 2360b1b

4 files changed

Lines changed: 225 additions & 21 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import React, { useEffect } from 'react'
2+
3+
import { Text } from '@mxenabled/mxui'
4+
import Button from '@mui/material/Button'
5+
import { Stack, styled } from '@mui/system'
6+
import { TextField } from 'src/privacy/input'
7+
8+
import { __ } from 'src/utilities/Intl'
9+
import styles from './otpInput.module.css'
10+
11+
const RESEND_OTP_INTERVAL = 10
12+
const OTP_LENGTH = 6
13+
14+
const getOtpDigits = (value: string) => {
15+
const digits = value.replace(/\D/g, '').slice(0, OTP_LENGTH).split('')
16+
return [...digits, ...new Array(OTP_LENGTH - digits.length).fill('')]
17+
}
18+
19+
export const OTPInput = ({
20+
onChange,
21+
value,
22+
}: {
23+
onChange: React.Dispatch<React.SetStateAction<string>>
24+
value: string
25+
}) => {
26+
const inputRefs = React.useRef<Array<HTMLInputElement | null>>(
27+
Array.from({ length: OTP_LENGTH }, () => null),
28+
)
29+
const [seconds, setSeconds] = React.useState<number>(RESEND_OTP_INTERVAL)
30+
const otpDigits = React.useMemo(() => getOtpDigits(value), [value])
31+
32+
useEffect(() => {
33+
// This timer is a delay before the user can request a new OTP.
34+
setSeconds(RESEND_OTP_INTERVAL)
35+
const timer = window.setInterval(() => {
36+
setSeconds((s) => {
37+
if (s <= 1) {
38+
clearInterval(timer)
39+
return 0
40+
}
41+
return s - 1
42+
})
43+
}, 1000)
44+
45+
return () => clearInterval(timer)
46+
}, [])
47+
48+
const focusInput = (targetIndex: number) => {
49+
inputRefs.current[targetIndex]?.focus()
50+
}
51+
52+
const selectInput = (targetIndex: number) => {
53+
inputRefs.current[targetIndex]?.select()
54+
}
55+
56+
const updateOtp = (updater: (digits: string[]) => void) => {
57+
onChange((prev) => {
58+
const digits = getOtpDigits(prev)
59+
updater(digits)
60+
return digits.join('')
61+
})
62+
}
63+
64+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, currentIndex: number) => {
65+
switch (event.key) {
66+
case 'ArrowUp':
67+
case 'ArrowDown':
68+
case ' ':
69+
event.preventDefault()
70+
break
71+
case 'ArrowLeft':
72+
event.preventDefault()
73+
if (currentIndex > 0) {
74+
focusInput(currentIndex - 1)
75+
selectInput(currentIndex - 1)
76+
}
77+
break
78+
case 'ArrowRight':
79+
event.preventDefault()
80+
if (currentIndex < OTP_LENGTH - 1) {
81+
focusInput(currentIndex + 1)
82+
selectInput(currentIndex + 1)
83+
}
84+
break
85+
case 'Delete':
86+
event.preventDefault()
87+
updateOtp((digits) => {
88+
digits[currentIndex] = ''
89+
})
90+
break
91+
case 'Backspace': {
92+
event.preventDefault()
93+
const isEmpty = event.currentTarget.value === ''
94+
95+
updateOtp((digits) => {
96+
if (isEmpty && currentIndex > 0) {
97+
digits[currentIndex - 1] = ''
98+
} else {
99+
digits[currentIndex] = ''
100+
}
101+
})
102+
103+
if (isEmpty && currentIndex > 0) {
104+
focusInput(currentIndex - 1)
105+
selectInput(currentIndex - 1)
106+
}
107+
break
108+
}
109+
default:
110+
break
111+
}
112+
}
113+
114+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, currentIndex: number) => {
115+
const nextValue = event.target.value.replace(/\D/g, '').slice(-1)
116+
117+
updateOtp((digits) => {
118+
digits[currentIndex] = nextValue
119+
})
120+
121+
if (nextValue && currentIndex < OTP_LENGTH - 1) {
122+
focusInput(currentIndex + 1)
123+
}
124+
}
125+
126+
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>, currentIndex: number) => {
127+
event.preventDefault()
128+
129+
const pastedText = event.clipboardData
130+
.getData('text/plain')
131+
.replace(/\D/g, '')
132+
.slice(0, OTP_LENGTH - currentIndex)
133+
134+
if (!pastedText) {
135+
return
136+
}
137+
138+
updateOtp((digits) => {
139+
for (let index = 0; index < pastedText.length; index += 1) {
140+
digits[currentIndex + index] = pastedText[index]
141+
}
142+
})
143+
144+
setTimeout(() => {
145+
const focusIndex = Math.min(currentIndex + pastedText.length, OTP_LENGTH - 1)
146+
focusInput(focusIndex)
147+
selectInput(focusIndex)
148+
}, 0)
149+
}
150+
151+
return (
152+
<>
153+
<Stack alignItems="center" className={styles.container} direction="row" spacing="8px">
154+
{Array.from({ length: OTP_LENGTH }, (_, index) => (
155+
<OTPTextField
156+
aria-label={`Digit ${index + 1} of ${OTP_LENGTH}`}
157+
inputProps={{
158+
maxLength: 1,
159+
inputMode: 'numeric',
160+
pattern: '[0-9]*',
161+
autoComplete: 'one-time-code',
162+
}}
163+
inputRef={(ele: HTMLInputElement | null) => {
164+
inputRefs.current[index] = ele
165+
}}
166+
key={index}
167+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => handleChange(event, index)}
168+
onFocus={() => selectInput(index)}
169+
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) =>
170+
handleKeyDown(event, index)
171+
}
172+
onPaste={(event: React.ClipboardEvent<HTMLInputElement>) => handlePaste(event, index)}
173+
size="small"
174+
value={otpDigits[index]}
175+
variant="outlined"
176+
/>
177+
))}
178+
</Stack>
179+
180+
{seconds > 0 ? (
181+
<Text truncate={false} variant="caption">
182+
{__('Resend code in (%1 seconds)', seconds)}
183+
</Text>
184+
) : (
185+
<Button fullWidth={true} onClick={() => {}} size="small" variant="text">
186+
{__('Resend code')}
187+
</Button>
188+
)}
189+
</>
190+
)
191+
}
192+
193+
const OTPTextField = styled(TextField)(() => ({
194+
height: '60px',
195+
'& .MuiInputBase-input': {
196+
textAlign: 'center',
197+
fontSize: '23px',
198+
},
199+
'& .MuiOutlinedInput-root': {
200+
height: '100%',
201+
},
202+
}))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.container {
2+
margin: 40px 0 0;
3+
}

src/ReturnUserExperience/ReturnUserExperience.tsx

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,6 @@ export const ReturnUserExperience = React.forwardRef(() => {
7474
/>
7575
)}
7676

77-
{view === RUXViews.PHONE_NUMBER && (
78-
<RuxPhoneNumber
79-
setUserEnteredPhone={setUserEnteredPhone}
80-
userEnteredPhone={userEnteredPhone}
81-
/>
82-
)}
83-
84-
{view === RUXViews.PHONE_NUMBER && (
85-
<RuxPhoneNumber
86-
setUserEnteredPhone={setUserEnteredPhone}
87-
userEnteredPhone={userEnteredPhone}
88-
/>
89-
)}
90-
9177
{view === RUXViews.OTP && <RuxOtp />}
9278

9379
{view === RUXViews.LIST && <RuxList />}

src/ReturnUserExperience/RuxOtp.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,31 @@ import Button from '@mui/material/Button'
55

66
import { __ } from 'src/utilities/Intl'
77
import styles from './returnUserExperience.module.css'
8+
import { OTPInput } from 'src/ReturnUserExperience/OTPInput/OTPInput'
9+
10+
export const RuxOtp = () => {
11+
const [otp, setOtp] = React.useState('')
812

9-
export const RuxPhoneNumber = () => {
1013
return (
1114
<>
12-
{/* OTP Style Input */}
13-
OTP Style Input
14-
<Stack className={styles.buttonContainer} spacing="8px">
15-
<Button>{__('Get code')}</Button>
16-
<Text variant="subtitle2">Resend code in (10 seconds)</Text>
15+
<Stack className={styles.titleContainer} spacing="6px">
16+
<Text bold={true} className={styles.centerText} truncate={false} variant="h2">
17+
{__('Verify your phone number')}
18+
</Text>
19+
<Text className={styles.centerText} truncate={false} variant="subtitle1">
20+
{__('Enter the code sent to ••• ••• 1234.')}
21+
</Text>
22+
</Stack>
23+
24+
<OTPInput onChange={setOtp} value={otp} />
25+
26+
<Stack className={styles.buttonContainer}>
27+
<Button onClick={() => {}} variant="contained">
28+
{__('Continue')}
29+
</Button>
1730
</Stack>
1831
</>
1932
)
2033
}
2134

22-
export default RuxPhoneNumber
35+
export default RuxOtp

0 commit comments

Comments
 (0)