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
+ }