|
1 | | -import { OTPInput, OTPInputContext } from "input-otp"; |
2 | | -import { Dot } from "lucide-react"; |
3 | 1 | import * as React from "react"; |
4 | 2 |
|
5 | 3 | import { cn } from "@/lib/utils"; |
6 | 4 |
|
7 | 5 | const InputOTP = React.forwardRef< |
8 | | - React.ElementRef<typeof OTPInput>, |
9 | | - React.ComponentPropsWithoutRef<typeof OTPInput> |
10 | | ->(({ className, containerClassName, ...props }, ref) => ( |
11 | | - <OTPInput |
12 | | - ref={ref} |
13 | | - containerClassName={cn( |
14 | | - "flex items-center gap-2 has-[:disabled]:opacity-50", |
15 | | - containerClassName, |
16 | | - )} |
17 | | - className={cn("disabled:cursor-not-allowed", className)} |
18 | | - {...props} |
19 | | - /> |
20 | | -)); |
21 | | -InputOTP.displayName = "InputOTP"; |
| 6 | + HTMLInputElement, |
| 7 | + Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & { |
| 8 | + value: string; |
| 9 | + onChange: (value: string) => void; |
| 10 | + maxLength: number; |
| 11 | + } |
| 12 | +>(({ className, value, onChange, maxLength, ...props }, ref) => { |
| 13 | + const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null); |
| 14 | + const inputRef = React.useRef<HTMLInputElement>(null); |
| 15 | + const previousValueRef = React.useRef<string>(value); |
| 16 | + |
| 17 | + React.useImperativeHandle(ref, () => inputRef.current!); |
| 18 | + |
| 19 | + React.useEffect(() => { |
| 20 | + if (value !== previousValueRef.current) { |
| 21 | + const newLength = value.length; |
| 22 | + setFocusedIndex(newLength); |
| 23 | + previousValueRef.current = value; |
| 24 | + } |
| 25 | + }, [value]); |
| 26 | + |
| 27 | + const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 28 | + const newValue = e.target.value.replace(/\D/g, "").slice(0, maxLength); |
| 29 | + onChange(newValue); |
| 30 | + }; |
22 | 31 |
|
23 | | -const InputOTPGroup = React.forwardRef< |
24 | | - React.ElementRef<"div">, |
25 | | - React.ComponentPropsWithoutRef<"div"> |
26 | | ->(({ className, ...props }, ref) => ( |
27 | | - <div ref={ref} className={cn("flex items-center", className)} {...props} /> |
28 | | -)); |
29 | | -InputOTPGroup.displayName = "InputOTPGroup"; |
| 32 | + const handleBoxClick = (index: number) => { |
| 33 | + inputRef.current?.focus(); |
| 34 | + setFocusedIndex(index); |
| 35 | + }; |
30 | 36 |
|
31 | | -const InputOTPSlot = React.forwardRef< |
32 | | - React.ElementRef<"div">, |
33 | | - React.ComponentPropsWithoutRef<"div"> & { index: number } |
34 | | ->(({ index, className, ...props }, ref) => { |
35 | | - const inputOTPContext = React.useContext(OTPInputContext); |
36 | | - // @ts-ignore |
37 | | - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; |
| 37 | + const slots = Array.from({ length: maxLength }, (_, i) => { |
| 38 | + const char = value[i] || ""; |
| 39 | + const isActive = |
| 40 | + focusedIndex === i || (focusedIndex === null && i === value.length); |
| 41 | + const isFilled = !!char; |
| 42 | + |
| 43 | + return ( |
| 44 | + <div |
| 45 | + key={i} |
| 46 | + onClick={() => handleBoxClick(i)} |
| 47 | + className={cn( |
| 48 | + "relative flex h-11 w-11 items-center justify-center rounded-lg border-2 border-input bg-background text-base font-semibold transition-all cursor-text hover:border-ring/50", |
| 49 | + isActive && "border-ring ring-2 ring-ring/20 ring-offset-1", |
| 50 | + isFilled && "border-primary/50 bg-primary/5", |
| 51 | + className, |
| 52 | + )} |
| 53 | + > |
| 54 | + <span className="text-foreground">{char}</span> |
| 55 | + {isActive && !char && ( |
| 56 | + <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> |
| 57 | + <div className="h-5 w-0.5 animate-caret-blink bg-primary duration-1000" /> |
| 58 | + </div> |
| 59 | + )} |
| 60 | + </div> |
| 61 | + ); |
| 62 | + }); |
38 | 63 |
|
39 | 64 | return ( |
40 | | - <div |
41 | | - ref={ref} |
42 | | - className={cn( |
43 | | - "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", |
44 | | - isActive && "z-10 ring-2 ring-ring ring-offset-background", |
45 | | - className, |
46 | | - )} |
47 | | - {...props} |
48 | | - > |
49 | | - {char} |
50 | | - {hasFakeCaret && ( |
51 | | - <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> |
52 | | - <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> |
53 | | - </div> |
54 | | - )} |
| 65 | + <div className="relative"> |
| 66 | + <input |
| 67 | + ref={inputRef} |
| 68 | + type="text" |
| 69 | + value={value} |
| 70 | + onChange={handleChange} |
| 71 | + onFocus={() => setFocusedIndex(value.length)} |
| 72 | + onBlur={() => setFocusedIndex(null)} |
| 73 | + autoComplete="one-time-code" |
| 74 | + inputMode="numeric" |
| 75 | + pattern="[0-9]*" |
| 76 | + maxLength={maxLength} |
| 77 | + className="absolute inset-0 w-full h-full opacity-0 cursor-default" |
| 78 | + style={{ caretColor: "transparent" }} |
| 79 | + {...props} |
| 80 | + /> |
| 81 | + <div className="flex items-center gap-2">{slots}</div> |
55 | 82 | </div> |
56 | 83 | ); |
57 | 84 | }); |
58 | | -InputOTPSlot.displayName = "InputOTPSlot"; |
59 | | - |
60 | | -const InputOTPSeparator = React.forwardRef< |
61 | | - React.ElementRef<"div">, |
62 | | - React.ComponentPropsWithoutRef<"div"> |
63 | | ->(({ ...props }, ref) => ( |
64 | | - <div ref={ref} {...props}> |
65 | | - <Dot /> |
66 | | - </div> |
67 | | -)); |
68 | | -InputOTPSeparator.displayName = "InputOTPSeparator"; |
| 85 | +InputOTP.displayName = "InputOTP"; |
69 | 86 |
|
70 | | -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; |
| 87 | +export { InputOTP }; |
0 commit comments