aural-ui 2.1.8 → 2.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "src/ui/lib/utils"
|
|
4
|
+
|
|
5
|
+
import { HelperText, InputBase } from ".."
|
|
6
|
+
|
|
7
|
+
interface OTPInputsType {
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
inputClassName?: string
|
|
10
|
+
inputStyle?: React.CSSProperties
|
|
11
|
+
isNumberInput?: boolean
|
|
12
|
+
length: number
|
|
13
|
+
onChangeOTP: (otp: string) => void
|
|
14
|
+
onKeyPress?: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
|
15
|
+
isValid?: boolean | null
|
|
16
|
+
messages?: {
|
|
17
|
+
success?: string
|
|
18
|
+
error?: string
|
|
19
|
+
neutral?: string
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SingleOtpInputType {
|
|
24
|
+
index: number
|
|
25
|
+
className?: string
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
focus: boolean
|
|
28
|
+
key: string
|
|
29
|
+
autoComplete?: string
|
|
30
|
+
onBlur: () => void
|
|
31
|
+
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
32
|
+
onFocus: () => void
|
|
33
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
|
34
|
+
style?: React.CSSProperties
|
|
35
|
+
value: string | undefined
|
|
36
|
+
type?: "text" | "number"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const defaultSingleInputClasses =
|
|
40
|
+
"mt-2 size-[50px] rounded-md text-center text-fm-2xl font-medium text-fm-neutral-0 outline-none transition duration-200 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [appearance:textfield] w-full max-sm:w-10 rounded-none max-sm:text-fm-xl font-medium border-b max-sm:gap-0"
|
|
41
|
+
|
|
42
|
+
export default function OtpInputs(props: OTPInputsType) {
|
|
43
|
+
const {
|
|
44
|
+
length,
|
|
45
|
+
isNumberInput,
|
|
46
|
+
disabled,
|
|
47
|
+
onChangeOTP,
|
|
48
|
+
inputClassName,
|
|
49
|
+
inputStyle,
|
|
50
|
+
isValid,
|
|
51
|
+
messages,
|
|
52
|
+
...rest
|
|
53
|
+
} = props
|
|
54
|
+
|
|
55
|
+
const [activeInput, setActiveInput] = useState(0)
|
|
56
|
+
const [otpValues, setOTPValues] = useState<string[]>(Array(length).fill(""))
|
|
57
|
+
|
|
58
|
+
const handleOtpChange = (otp: string[]) => {
|
|
59
|
+
const otpValue = otp.join("")
|
|
60
|
+
onChangeOTP(otpValue)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const getRightValue = (str: string) => {
|
|
64
|
+
if (!isNumberInput) {
|
|
65
|
+
return str
|
|
66
|
+
}
|
|
67
|
+
return !str || /\d/.test(str) ? str : ""
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const changeCodeAtFocus = (str: string) => {
|
|
71
|
+
const updatedOTPValues = [...otpValues]
|
|
72
|
+
updatedOTPValues[activeInput] = str[0] || ""
|
|
73
|
+
setOTPValues(updatedOTPValues)
|
|
74
|
+
handleOtpChange(updatedOTPValues)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const focusInput = (inputIndex: number) => {
|
|
78
|
+
const selectedIndex = Math.max(Math.min(length - 1, inputIndex), 0)
|
|
79
|
+
setActiveInput(selectedIndex)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const focusPrevInput = () => {
|
|
83
|
+
focusInput(activeInput - 1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const focusNextInput = () => {
|
|
87
|
+
focusInput(activeInput + 1)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handleOnFocus = (index: number) => () => {
|
|
91
|
+
focusInput(index)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
const val = getRightValue(e.currentTarget.value)
|
|
97
|
+
if (val) {
|
|
98
|
+
changeCodeAtFocus(val)
|
|
99
|
+
focusNextInput()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const onBlur = () => {
|
|
104
|
+
setActiveInput(-1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
108
|
+
switch (e.key) {
|
|
109
|
+
case "Backspace":
|
|
110
|
+
case "Delete": {
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
if (otpValues[activeInput]) {
|
|
113
|
+
changeCodeAtFocus("")
|
|
114
|
+
} else {
|
|
115
|
+
focusPrevInput()
|
|
116
|
+
}
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
case "ArrowLeft": {
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
focusPrevInput()
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
case "ArrowRight": {
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
focusNextInput()
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
case " ":
|
|
130
|
+
case "e":
|
|
131
|
+
case "E": {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
default:
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const messagesMap = {
|
|
142
|
+
true: messages?.success ?? "✓ Valid input",
|
|
143
|
+
false: messages?.error ?? "✗ Invalid input. Try again",
|
|
144
|
+
default: messages?.neutral ?? "Enter OTP...",
|
|
145
|
+
} as const
|
|
146
|
+
|
|
147
|
+
const variantMap = {
|
|
148
|
+
true: "success",
|
|
149
|
+
false: "error",
|
|
150
|
+
default: undefined,
|
|
151
|
+
} as const
|
|
152
|
+
|
|
153
|
+
// Normalize isValid → "true" | "false" | "default"
|
|
154
|
+
const key =
|
|
155
|
+
isValid === true ? "true" : isValid === false ? "false" : "default"
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="mb-4">
|
|
159
|
+
<div className="flex gap-4" {...rest}>
|
|
160
|
+
{Array(length)
|
|
161
|
+
.fill("")
|
|
162
|
+
.map((_, index) => (
|
|
163
|
+
<SingleInput
|
|
164
|
+
index={index}
|
|
165
|
+
data-testid={`otp-input-${index}`}
|
|
166
|
+
key={`SingleInput-${index}`}
|
|
167
|
+
focus={activeInput === index}
|
|
168
|
+
value={otpValues && otpValues[index]}
|
|
169
|
+
autoComplete="off"
|
|
170
|
+
onFocus={handleOnFocus(index)}
|
|
171
|
+
onChange={handleOnChange}
|
|
172
|
+
onKeyDown={handleOnKeyDown}
|
|
173
|
+
onBlur={onBlur}
|
|
174
|
+
style={inputStyle}
|
|
175
|
+
className={cn(
|
|
176
|
+
defaultSingleInputClasses,
|
|
177
|
+
inputClassName && inputClassName
|
|
178
|
+
)}
|
|
179
|
+
type={isNumberInput ? "number" : "text"}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
/>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
<HelperText variant={variantMap[key]}>{messagesMap[key]}</HelperText>
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function SingleInput(props: SingleOtpInputType) {
|
|
190
|
+
const { focus, className, type, index, ...rest } = props
|
|
191
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
192
|
+
const prevFocus = usePrevious(focus)
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (inputRef.current) {
|
|
196
|
+
if (focus) {
|
|
197
|
+
inputRef.current.focus()
|
|
198
|
+
}
|
|
199
|
+
if (focus && focus !== prevFocus) {
|
|
200
|
+
inputRef.current.focus()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}, [focus, prevFocus])
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<InputBase
|
|
207
|
+
id={`otp_input_${index}`}
|
|
208
|
+
type={type}
|
|
209
|
+
className={className}
|
|
210
|
+
ref={inputRef}
|
|
211
|
+
aria-label="OTP input"
|
|
212
|
+
variant="default"
|
|
213
|
+
{...rest}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function usePrevious(value: boolean) {
|
|
219
|
+
const ref = useRef<boolean>(value)
|
|
220
|
+
|
|
221
|
+
// Store current value in ref
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
ref.current = value
|
|
224
|
+
}, [value]) // Only re-run if value changes
|
|
225
|
+
|
|
226
|
+
// Return previous value (happens before update in useEffect above)
|
|
227
|
+
return ref.current
|
|
228
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
dependencies: {},
|
|
3
|
+
devDependencies: {},
|
|
4
|
+
internalDependencies: [],
|
|
5
|
+
tokens: [
|
|
6
|
+
"--color-fm-divider-contrast",
|
|
7
|
+
"--color-fm-divider-negative",
|
|
8
|
+
"--color-fm-divider-positive",
|
|
9
|
+
"--color-fm-divider-primary",
|
|
10
|
+
"--color-fm-divider-tertiary",
|
|
11
|
+
"--color-fm-divider-warning",
|
|
12
|
+
"--color-fm-inactive",
|
|
13
|
+
"--color-fm-placeholder",
|
|
14
|
+
"--color-fm-primary",
|
|
15
|
+
"--color-fm-surface-frosted",
|
|
16
|
+
"--leading-fm-md",
|
|
17
|
+
"--leading-fm-xl",
|
|
18
|
+
"--radius-fm-s",
|
|
19
|
+
"--text-fm-md",
|
|
20
|
+
],
|
|
21
|
+
}
|