{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"input-otp","title":"Input OTP","type":"registry:ui","description":"Accessible one-time password component with copy paste functionality.","registryDependencies":["utils","https://fysk.dev/r/fysk-provider.json"],"files":[{"path":"fysk/input-otp.tsx","type":"registry:ui","content":"\"use client\"\r\n\r\nimport * as React from \"react\"\r\nimport { cn } from \"@/lib/utils\"\r\nimport { useFyskConfig } from \"@/components/fysk-provider\"\r\n\r\nexport interface OTPInputProps\r\n    extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"onChange\" | \"type\" | \"size\"> {\r\n    length?: number\r\n    onChange?: (value: string) => void\r\n    variant?: \"default\" | \"condensed\" | \"underline\"\r\n    size?: \"default\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"\r\n    state?: \"idle\" | \"loading\" | \"success\" | \"error\"\r\n    inputType?: \"numeric\" | \"alphanumeric\" | \"alpha\"\r\n    Icon?: React.ReactNode\r\n}\r\n\r\nconst OTPInput = React.forwardRef<HTMLDivElement, OTPInputProps>(\r\n    ({\r\n        length: propLength,\r\n        onChange,\r\n        variant = \"default\",\r\n        size = \"default\",\r\n        state = \"idle\",\r\n        inputType = \"numeric\",\r\n        Icon,\r\n        className,\r\n        disabled,\r\n        ...props\r\n    }, ref) => {\r\n        const length = propLength || 6\r\n        const [otp, setOtp] = React.useState<string[]>(new Array(length).fill(\"\"))\r\n        const inputRefs = React.useRef<(HTMLInputElement | null)[]>([])\r\n\r\n        React.useEffect(() => {\r\n            setOtp((prev) => {\r\n                if (prev.length === length) return prev\r\n                const newOtp = new Array(length).fill(\"\")\r\n                // Preserve existing values, truncating if necessary\r\n                for (let i = 0; i < Math.min(prev.length, length); i++) {\r\n                    newOtp[i] = prev[i]\r\n                }\r\n                return newOtp\r\n            })\r\n        }, [length])\r\n\r\n        const patterns = {\r\n            numeric: /^[0-9]$/,\r\n            alpha: /^[a-zA-Z]$/,\r\n            alphanumeric: /^[a-zA-Z0-9]$/\r\n        }\r\n\r\n        const validateChar = (char: string) => {\r\n            return patterns[inputType].test(char)\r\n        }\r\n\r\n        const handleChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {\r\n            const value = e.target.value\r\n\r\n            // Handle clearing\r\n            if (value === \"\") {\r\n                const newOtp = [...otp]\r\n                newOtp[index] = \"\"\r\n                setOtp(newOtp)\r\n                onChange?.(newOtp.join(\"\"))\r\n                return\r\n            }\r\n\r\n            // Get the last character typed\r\n            const char = value.substring(value.length - 1)\r\n\r\n            if (!validateChar(char)) return\r\n\r\n            const newOtp = [...otp]\r\n            newOtp[index] = char\r\n            setOtp(newOtp)\r\n\r\n            const finalOtp = newOtp.join(\"\")\r\n            onChange?.(finalOtp)\r\n\r\n            // Move to next input\r\n            if (char && index < length - 1) {\r\n                inputRefs.current[index + 1]?.focus()\r\n            }\r\n        }\r\n\r\n        const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {\r\n            if (e.key === \"Backspace\" && !otp[index] && index > 0) {\r\n                // Determine if we are crossing a group boundary (for condensation)\r\n                inputRefs.current[index - 1]?.focus()\r\n            }\r\n        }\r\n\r\n        const handlePaste = (e: React.ClipboardEvent) => {\r\n            e.preventDefault()\r\n            const data = e.clipboardData.getData(\"text\").slice(0, length)\r\n\r\n            // Validate entire paste\r\n            const validData = data.split(\"\").every(char => validateChar(char))\r\n            if (!validData) return\r\n\r\n            const newOtp = [...otp]\r\n            data.split(\"\").forEach((char, i) => {\r\n                newOtp[i] = char\r\n            })\r\n            setOtp(newOtp)\r\n            onChange?.(newOtp.join(\"\"))\r\n\r\n            // Focus last filled or next empty\r\n            const nextIndex = data.length < length ? data.length : length - 1\r\n            inputRefs.current[nextIndex]?.focus()\r\n        }\r\n\r\n        // Helper to determine if we should show a separator\r\n        const showSeparator = (index: number) => {\r\n            if (variant !== \"condensed\") return false\r\n            // Default 3-3 split for length 6, or split in half for even lengths\r\n            const halfPoint = Math.floor(length / 2)\r\n            return index === halfPoint - 1 && length > 2\r\n        }\r\n\r\n        const isDisabled = state === \"loading\" || disabled\r\n        const config = useFyskConfig()\r\n\r\n        return (\r\n            <div\r\n                ref={ref}\r\n                className={cn(\r\n                    \"flex items-center\",\r\n                    variant === \"default\" && \"gap-2\",\r\n                    variant === \"condensed\" && \"gap-1\", // Visual gap handled by group styles usually, but we need gap for the separator\r\n                    variant === \"underline\" && \"gap-3\",\r\n                    className\r\n                )}\r\n                onPaste={handlePaste}\r\n            >\r\n                {otp.map((digit, index) => {\r\n                    return (\r\n                        <React.Fragment key={index}>\r\n                            <div className={cn(\"relative flex items-center\")}>\r\n                                <input\r\n                                    ref={(el) => {\r\n                                        inputRefs.current[index] = el\r\n                                    }}\r\n                                    type={inputType === \"numeric\" ? \"tel\" : \"text\"}\r\n                                    inputMode={inputType === \"numeric\" ? \"numeric\" : \"text\"}\r\n                                    maxLength={1}\r\n                                    value={digit}\r\n                                    onChange={(e) => handleChange(e, index)}\r\n                                    onKeyDown={(e) => handleKeyDown(e, index)}\r\n                                    disabled={isDisabled}\r\n                                    className={cn(\r\n                                        \"flex items-center justify-center text-center font-semibold transition-all focus:z-10 focus:outline-none disabled:opacity-50\",\r\n                                        // Sizes\r\n                                        size === \"xs\" && \"h-7 w-7 text-xs\",\r\n                                        size === \"sm\" && \"h-8 w-8 text-sm\",\r\n                                        (size === \"md\" || size === \"default\") && \"h-10 w-10 text-base\",\r\n                                        size === \"lg\" && \"h-12 w-12 text-lg\",\r\n                                        size === \"xl\" && \"h-14 w-14 text-xl\",\r\n\r\n                                        // Default Variant\r\n                                        variant === \"default\" && \"rounded-md border border-border bg-background focus:ring-2 focus:ring-ring/30 focus:border-ring\",\r\n\r\n                                        // Condensed Variant\r\n                                        variant === \"condensed\" && \"border-y border-r border-border bg-background first:rounded-l-md first:border-l last:rounded-r-md focus:ring-2 focus:ring-inset focus:ring-ring/30\",\r\n\r\n                                        // Underline Variant\r\n                                        variant === \"underline\" && \"border-b-2 border-border bg-transparent rounded-none focus:border-ring px-0\",\r\n\r\n                                        // States\r\n                                        state === \"success\" && \"border-green-500/50 bg-green-500/5 text-green-700 dark:text-green-400\",\r\n                                        state === \"error\" && \"border-destructive/50 bg-destructive/5 text-destructive\",\r\n                                        state === \"loading\" && \"animate-pulse opacity-70 bg-muted cursor-wait\"\r\n                                    )}\r\n                                    {...props}\r\n                                />\r\n                            </div>\r\n                            {showSeparator(index) && (\r\n                                <div className=\"flex items-center justify-center w-4 text-muted-foreground\">\r\n                                    {\r\n                                        Icon ? (\r\n                                            <span className=\"w-4 h-4 flex items-center justify-center [&_svg]:size-full\">\r\n                                                {config.icons?.minus}\r\n                                            </span>\r\n                                        ) : (\r\n                                            <span className=\"w-4 h-4 flex items-center justify-center [&_svg]:size-full\">\r\n                                                -\r\n                                            </span>\r\n                                        )\r\n                                    }\r\n                                </div>\r\n                            )}\r\n                        </React.Fragment>\r\n                    )\r\n                })}\r\n            </div>\r\n        )\r\n    }\r\n)\r\nOTPInput.displayName = \"OTPInput\"\r\n\r\nexport { OTPInput }\r\n\r\n"}]}