feat: integrate input-otp library and enhance OTP input handling
- Added input-otp library to streamline OTP input functionality. - Refactored OtpInput component to utilize InputOTP for improved user experience and mobile SMS autofill. - Enhanced LoginOtpStep and VerificationStep components to handle OTP input errors and clear states effectively. - Updated global styles to include animations for OTP caret, improving visual feedback during input. - Made minor adjustments to LoginForm and OtpStep components for better error handling and user interaction.
This commit is contained in:
parent
7d290c814d
commit
1610e436a5
@ -26,6 +26,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@ -322,6 +322,21 @@
|
|||||||
|
|
||||||
/* Line-height tokens */
|
/* Line-height tokens */
|
||||||
--leading-display: var(--cp-leading-display);
|
--leading-display: var(--cp-leading-display);
|
||||||
|
|
||||||
|
/* OTP caret blink animation */
|
||||||
|
--animate-caret-blink: caret-blink 1.2s ease-out infinite;
|
||||||
|
|
||||||
|
@keyframes caret-blink {
|
||||||
|
0%,
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@ -1,21 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* OtpInput - 6-digit OTP code input
|
* OtpInput - 6-digit OTP code input
|
||||||
*
|
*
|
||||||
* Auto-focuses next input on entry, handles paste, backspace navigation.
|
* Wrapper around shadcn InputOTP (backed by input-otp library).
|
||||||
|
* Provides mobile SMS autofill, paste support, and accessible slot rendering.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
type KeyboardEvent,
|
|
||||||
type ClipboardEvent,
|
|
||||||
} from "react";
|
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||||
|
|
||||||
interface OtpInputProps {
|
interface OtpInputProps {
|
||||||
length?: number | undefined;
|
length?: number | undefined;
|
||||||
@ -27,86 +21,6 @@ interface OtpInputProps {
|
|||||||
autoFocus?: boolean | undefined;
|
autoFocus?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInputBorderClass(error: string | undefined, isActive: boolean): string {
|
|
||||||
if (error) return "border-danger focus:ring-danger focus:border-danger";
|
|
||||||
if (isActive) return "border-primary";
|
|
||||||
return "border-border hover:border-muted-foreground/50";
|
|
||||||
}
|
|
||||||
|
|
||||||
function useOtpHandlers({
|
|
||||||
digits,
|
|
||||||
length,
|
|
||||||
onChange,
|
|
||||||
onComplete,
|
|
||||||
inputRefs,
|
|
||||||
setActiveIndex,
|
|
||||||
}: {
|
|
||||||
digits: string[];
|
|
||||||
length: number;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
onComplete: ((value: string) => void) | undefined;
|
|
||||||
inputRefs: React.MutableRefObject<(HTMLInputElement | null)[]>;
|
|
||||||
setActiveIndex: (index: number) => void;
|
|
||||||
}) {
|
|
||||||
const focusInput = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const clampedIndex = Math.max(0, Math.min(index, length - 1));
|
|
||||||
inputRefs.current[clampedIndex]?.focus();
|
|
||||||
setActiveIndex(clampedIndex);
|
|
||||||
},
|
|
||||||
[length, inputRefs, setActiveIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(index: number, char: string) => {
|
|
||||||
if (!/^\d?$/.test(char)) return;
|
|
||||||
const newDigits = [...digits];
|
|
||||||
newDigits[index] = char;
|
|
||||||
const newValue = newDigits.join("");
|
|
||||||
onChange(newValue);
|
|
||||||
if (char && index < length - 1) focusInput(index + 1);
|
|
||||||
if (newValue.length === length) onComplete?.(newValue);
|
|
||||||
},
|
|
||||||
[digits, length, onChange, onComplete, focusInput]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Backspace") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (digits[index]) {
|
|
||||||
handleChange(index, "");
|
|
||||||
} else if (index > 0) {
|
|
||||||
focusInput(index - 1);
|
|
||||||
handleChange(index - 1, "");
|
|
||||||
}
|
|
||||||
} else if (e.key === "ArrowLeft" && index > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
focusInput(index - 1);
|
|
||||||
} else if (e.key === "ArrowRight" && index < length - 1) {
|
|
||||||
e.preventDefault();
|
|
||||||
focusInput(index + 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[digits, focusInput, handleChange, length]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
|
||||||
(e: ClipboardEvent<HTMLInputElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
|
||||||
if (pastedData) {
|
|
||||||
onChange(pastedData);
|
|
||||||
focusInput(Math.min(pastedData.length, length - 1));
|
|
||||||
if (pastedData.length === length) onComplete?.(pastedData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[length, onChange, onComplete, focusInput]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { focusInput, handleChange, handleKeyDown, handlePaste };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OtpInput({
|
export function OtpInput({
|
||||||
length = 6,
|
length = 6,
|
||||||
value,
|
value,
|
||||||
@ -116,61 +30,25 @@ export function OtpInput({
|
|||||||
error,
|
error,
|
||||||
autoFocus = true,
|
autoFocus = true,
|
||||||
}: OtpInputProps) {
|
}: OtpInputProps) {
|
||||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
|
|
||||||
const digits = useMemo(() => {
|
|
||||||
const d = [...value].slice(0, length);
|
|
||||||
while (d.length < length) d.push("");
|
|
||||||
return d;
|
|
||||||
}, [value, length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFocus && !disabled) {
|
|
||||||
const firstEmptyIndex = digits.findIndex(d => !d);
|
|
||||||
inputRefs.current[firstEmptyIndex === -1 ? 0 : firstEmptyIndex]?.focus();
|
|
||||||
}
|
|
||||||
}, [autoFocus, disabled, digits]);
|
|
||||||
|
|
||||||
const { handleChange, handleKeyDown, handlePaste } = useOtpHandlers({
|
|
||||||
digits,
|
|
||||||
length,
|
|
||||||
onChange,
|
|
||||||
onComplete,
|
|
||||||
inputRefs,
|
|
||||||
setActiveIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center">
|
||||||
{digits.map((digit, index) => (
|
<InputOTP
|
||||||
<input
|
maxLength={length}
|
||||||
key={index}
|
value={value}
|
||||||
ref={el => {
|
onChange={onChange}
|
||||||
inputRefs.current[index] = el;
|
{...(onComplete && { onComplete })}
|
||||||
}}
|
disabled={disabled}
|
||||||
type="text"
|
autoFocus={autoFocus}
|
||||||
inputMode="numeric"
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
autoComplete="one-time-code"
|
containerClassName="justify-center"
|
||||||
maxLength={1}
|
>
|
||||||
value={digit}
|
<InputOTPGroup>
|
||||||
disabled={disabled}
|
{Array.from({ length }, (_, i) => (
|
||||||
onChange={e => handleChange(index, e.target.value)}
|
<InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
|
||||||
onKeyDown={e => handleKeyDown(index, e)}
|
))}
|
||||||
onPaste={handlePaste}
|
</InputOTPGroup>
|
||||||
onFocus={() => setActiveIndex(index)}
|
</InputOTP>
|
||||||
className={cn(
|
|
||||||
"w-12 h-14 text-center text-xl font-semibold",
|
|
||||||
"rounded-lg border bg-card text-foreground",
|
|
||||||
"focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary",
|
|
||||||
"transition-all duration-150",
|
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
||||||
getInputBorderClass(error, activeIndex === index)
|
|
||||||
)}
|
|
||||||
aria-label={`Digit ${index + 1}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-danger text-center" role="alert">
|
<p className="text-sm text-danger text-center" role="alert">
|
||||||
|
|||||||
69
apps/portal/src/components/ui/input-otp.tsx
Normal file
69
apps/portal/src/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { index: number }) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
// Index is always valid — we control it via the length prop in OtpInput
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
|
||||||
|
"first:rounded-l-lg first:border-l last:rounded-r-lg",
|
||||||
|
"bg-card text-foreground",
|
||||||
|
isActive && "z-10 ring-2 ring-primary border-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
@ -152,6 +152,7 @@ export function LoginForm({
|
|||||||
expiresAt={otpState.expiresAt}
|
expiresAt={otpState.expiresAt}
|
||||||
onVerify={handleOtpVerify}
|
onVerify={handleOtpVerify}
|
||||||
onBack={handleBackToCredentials}
|
onBack={handleBackToCredentials}
|
||||||
|
onClearError={clearError}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ interface LoginOtpStepProps {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
onVerify: (code: string, rememberDevice: boolean) => Promise<void>;
|
onVerify: (code: string, rememberDevice: boolean) => Promise<void>;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onClearError?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
@ -37,6 +38,7 @@ export function LoginOtpStep({
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
onVerify,
|
onVerify,
|
||||||
onBack,
|
onBack,
|
||||||
|
onClearError,
|
||||||
loading = false,
|
loading = false,
|
||||||
error,
|
error,
|
||||||
}: LoginOtpStepProps) {
|
}: LoginOtpStepProps) {
|
||||||
@ -78,33 +80,41 @@ export function LoginOtpStep({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [expiresAt]);
|
}, [expiresAt]);
|
||||||
|
|
||||||
const handleVerify = useCallback(async () => {
|
const handleCodeChange = useCallback(
|
||||||
if (code.length !== 6 || isVerifying || loading) return;
|
(value: string) => {
|
||||||
|
setCode(value);
|
||||||
|
if (error) onClearError?.();
|
||||||
|
},
|
||||||
|
[error, onClearError]
|
||||||
|
);
|
||||||
|
|
||||||
setIsVerifying(true);
|
const submitCode = useCallback(
|
||||||
try {
|
async (codeToSubmit: string) => {
|
||||||
await onVerify(code, rememberDevice);
|
if (codeToSubmit.length !== 6 || isVerifying || loading) return;
|
||||||
} finally {
|
setIsVerifying(true);
|
||||||
setIsVerifying(false);
|
try {
|
||||||
}
|
await onVerify(codeToSubmit, rememberDevice);
|
||||||
}, [code, isVerifying, loading, onVerify, rememberDevice]);
|
} catch {
|
||||||
|
// Verification failed — clear input so user can retry.
|
||||||
|
// The error message from the store remains visible until they type again.
|
||||||
|
setCode("");
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isVerifying, loading, onVerify, rememberDevice]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVerify = useCallback(async () => {
|
||||||
|
await submitCode(code);
|
||||||
|
}, [code, submitCode]);
|
||||||
|
|
||||||
const handleComplete = useCallback(
|
const handleComplete = useCallback(
|
||||||
(completedCode: string) => {
|
(completedCode: string) => {
|
||||||
setCode(completedCode);
|
setCode(completedCode);
|
||||||
// Auto-submit when code is complete
|
void submitCode(completedCode);
|
||||||
if (completedCode.length === 6 && !isVerifying && !loading) {
|
|
||||||
void (async () => {
|
|
||||||
setIsVerifying(true);
|
|
||||||
try {
|
|
||||||
await onVerify(completedCode, rememberDevice);
|
|
||||||
} finally {
|
|
||||||
setIsVerifying(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isVerifying, loading, onVerify, rememberDevice]
|
[submitCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isExpired = timeRemaining !== null && timeRemaining <= 0;
|
const isExpired = timeRemaining !== null && timeRemaining <= 0;
|
||||||
@ -115,7 +125,7 @@ export function LoginOtpStep({
|
|||||||
<OtpHeader maskedEmail={maskedEmail} />
|
<OtpHeader maskedEmail={maskedEmail} />
|
||||||
<OtpCodeSection
|
<OtpCodeSection
|
||||||
code={code}
|
code={code}
|
||||||
setCode={setCode}
|
setCode={handleCodeChange}
|
||||||
handleComplete={handleComplete}
|
handleComplete={handleComplete}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
isExpired={isExpired}
|
isExpired={isExpired}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
import { OtpInput } from "@/components/molecules";
|
import { OtpInput } from "@/components/molecules";
|
||||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||||
@ -13,19 +13,31 @@ export function VerificationStep() {
|
|||||||
const { state, send } = useGetStartedMachine();
|
const { state, send } = useGetStartedMachine();
|
||||||
|
|
||||||
const loading = state.matches({ verification: "loading" });
|
const loading = state.matches({ verification: "loading" });
|
||||||
const error = state.context.error;
|
const machineError = state.context.error;
|
||||||
const attemptsRemaining = state.context.attemptsRemaining;
|
const attemptsRemaining = state.context.attemptsRemaining;
|
||||||
const email = state.context.formData.email;
|
const email = state.context.formData.email;
|
||||||
|
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [resending, setResending] = useState(false);
|
const [resending, setResending] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync machine errors into local state so we can clear on typing,
|
||||||
|
// and clear the input so the user can retry
|
||||||
|
useEffect(() => {
|
||||||
|
if (machineError) {
|
||||||
|
setLocalError(machineError);
|
||||||
|
setCode("");
|
||||||
|
}
|
||||||
|
}, [machineError]);
|
||||||
|
|
||||||
const handleCodeChange = (value: string) => {
|
const handleCodeChange = (value: string) => {
|
||||||
setCode(value);
|
setCode(value);
|
||||||
|
if (localError) setLocalError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComplete = (completedCode: string) => {
|
const handleComplete = (completedCode: string) => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
setCode(completedCode);
|
||||||
send({ type: "VERIFY_CODE", code: completedCode });
|
send({ type: "VERIFY_CODE", code: completedCode });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -61,7 +73,7 @@ export function VerificationStep() {
|
|||||||
onChange={handleCodeChange}
|
onChange={handleCodeChange}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
error={error ?? undefined}
|
error={localError ?? undefined}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -73,7 +73,11 @@ export function OtpStep() {
|
|||||||
|
|
||||||
const handleComplete = useCallback(
|
const handleComplete = useCallback(
|
||||||
async (code: string) => {
|
async (code: string) => {
|
||||||
await verifyOtp(code);
|
try {
|
||||||
|
await verifyOtp(code);
|
||||||
|
} catch {
|
||||||
|
setOtpValue("");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[verifyOtp]
|
[verifyOtp]
|
||||||
);
|
);
|
||||||
|
|||||||
76
docs/plans/2026-03-06-otp-input-redesign-design.md
Normal file
76
docs/plans/2026-03-06-otp-input-redesign-design.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# OTP Input Redesign: Replace Custom with shadcn InputOTP
|
||||||
|
|
||||||
|
**Date**: 2026-03-06
|
||||||
|
**Status**: Approved
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The custom `OtpInput` component (`components/molecules/OtpInput/OtpInput.tsx`) has fundamental issues:
|
||||||
|
|
||||||
|
- `onComplete` never fired on typing due to a JS bug (`!string.includes("")` is always false)
|
||||||
|
- Manual focus management across 6 separate `<input>` elements is fragile
|
||||||
|
- No mobile SMS autofill support
|
||||||
|
- No password manager detection
|
||||||
|
- Edge cases around paste, backspace, and focus are hard to maintain
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Replace the custom implementation with `input-otp` via shadcn/ui's InputOTP component (Approach A).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
input-otp (npm)
|
||||||
|
-> components/ui/input-otp.tsx (shadcn primitives)
|
||||||
|
-> components/molecules/OtpInput/OtpInput.tsx (wrapper, same API)
|
||||||
|
-> LoginOtpStep, OtpStep, VerificationStep (no changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component API (unchanged)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OtpInputProps {
|
||||||
|
length?: number; // default 6
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onComplete?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
autoFocus?: boolean; // default true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visual Design
|
||||||
|
|
||||||
|
- Stripe-style grouped layout: two groups of 3 slots separated by a dash
|
||||||
|
- Slots: `border-border` default, `border-primary` + ring on active, `border-danger` on error
|
||||||
|
- `bg-card`, `text-foreground`, rounded corners on group edges
|
||||||
|
- Disabled: `opacity-50 cursor-not-allowed`
|
||||||
|
- Blinking caret animation
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
| --------------------------------------------- | --------------------------------- |
|
||||||
|
| `package.json` | Add `input-otp` dependency |
|
||||||
|
| `components/ui/input-otp.tsx` | New — shadcn generated primitives |
|
||||||
|
| `components/molecules/OtpInput/OtpInput.tsx` | Rewrite internals, keep API |
|
||||||
|
| `LoginOtpStep`, `OtpStep`, `VerificationStep` | No changes |
|
||||||
|
|
||||||
|
## What Gets Deleted
|
||||||
|
|
||||||
|
- `useOtpHandlers` hook (focus, paste, keydown management)
|
||||||
|
- `getInputBorderClass` helper
|
||||||
|
- All 6 individual `<input>` elements
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Consumer-level error handling (already implemented this session) remains unchanged:
|
||||||
|
|
||||||
|
- LoginOtpStep: catches errors, clears input, clears error on typing via `onClearError`
|
||||||
|
- OtpStep: catches errors, clears input, clears error on typing via `clearOtpError`
|
||||||
|
- VerificationStep: syncs machine error to local state, clears input and error on typing
|
||||||
|
|
||||||
|
## shadcn CLI Note
|
||||||
|
|
||||||
|
Generated file imports `cn` from `@/lib/utils`. Project has `cn` at `@/shared/utils`. Fix the import in the generated file after running the CLI.
|
||||||
328
docs/plans/2026-03-06-otp-input-redesign-plan.md
Normal file
328
docs/plans/2026-03-06-otp-input-redesign-plan.md
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
# OTP Input Redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Replace the custom OtpInput component with shadcn's InputOTP backed by `input-otp`, keeping the same external API so consumers need zero changes.
|
||||||
|
|
||||||
|
**Architecture:** Install `input-otp` → generate shadcn `input-otp.tsx` primitives → rewrite `OtpInput` molecule internals using those primitives. All 3 consumers (LoginOtpStep, OtpStep, VerificationStep) continue using the same `<OtpInput>` props interface unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** `input-otp` library, shadcn/ui primitives, Tailwind v4, React 19
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Install `input-otp` dependency
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `apps/portal/package.json`
|
||||||
|
|
||||||
|
**Step 1: Install the package**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @customer-portal/portal add input-otp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify installation**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @customer-portal/portal exec -- node -e "require('input-otp')" 2>&1 || echo "ESM module, checking package exists" && ls node_modules/input-otp/package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Package exists in node_modules.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/portal/package.json pnpm-lock.yaml
|
||||||
|
git commit -m "chore: add input-otp dependency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create shadcn InputOTP primitives
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `apps/portal/src/components/ui/input-otp.tsx`
|
||||||
|
|
||||||
|
The shadcn CLI may not work cleanly because `components.json` aliases `utils` to `@/lib/utils` which doesn't exist. Create the component manually based on shadcn's new-york input-otp source, using the project's `@/shared/utils` import for `cn`.
|
||||||
|
|
||||||
|
**Step 1: Create the primitives file**
|
||||||
|
|
||||||
|
Create `apps/portal/src/components/ui/input-otp.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { index: number }) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
|
||||||
|
"first:rounded-l-lg first:border-l last:rounded-r-lg",
|
||||||
|
"bg-card text-foreground",
|
||||||
|
isActive && "z-10 ring-2 ring-primary border-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add caret-blink animation to globals.css**
|
||||||
|
|
||||||
|
Add inside the `@theme` block in `apps/portal/src/app/globals.css`, before the closing `}`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* OTP caret blink animation */
|
||||||
|
--animate-caret-blink: caret-blink 1.2s ease-out infinite;
|
||||||
|
|
||||||
|
@keyframes caret-blink {
|
||||||
|
0%,
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify type-check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/portal/src/components/ui/input-otp.tsx apps/portal/src/app/globals.css
|
||||||
|
git commit -m "feat: add shadcn InputOTP primitives with caret-blink animation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Rewrite OtpInput molecule
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Rewrite: `apps/portal/src/components/molecules/OtpInput/OtpInput.tsx`
|
||||||
|
|
||||||
|
**Step 1: Replace the entire file**
|
||||||
|
|
||||||
|
Replace `apps/portal/src/components/molecules/OtpInput/OtpInput.tsx` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/**
|
||||||
|
* OtpInput - 6-digit OTP code input
|
||||||
|
*
|
||||||
|
* Wrapper around shadcn InputOTP (backed by input-otp library).
|
||||||
|
* Provides mobile SMS autofill, paste support, and accessible slot rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
InputOTPSeparator,
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
|
|
||||||
|
interface OtpInputProps {
|
||||||
|
length?: number | undefined;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onComplete?: ((value: string) => void) | undefined;
|
||||||
|
disabled?: boolean | undefined;
|
||||||
|
error?: string | undefined;
|
||||||
|
autoFocus?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OtpInput({
|
||||||
|
length = 6,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onComplete,
|
||||||
|
disabled = false,
|
||||||
|
error,
|
||||||
|
autoFocus = true,
|
||||||
|
}: OtpInputProps) {
|
||||||
|
const firstGroupSize = Math.ceil(length / 2);
|
||||||
|
const secondGroupSize = length - firstGroupSize;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={length}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onComplete={onComplete}
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
containerClassName="justify-center"
|
||||||
|
>
|
||||||
|
<InputOTPGroup className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}>
|
||||||
|
{Array.from({ length: firstGroupSize }, (_, i) => (
|
||||||
|
<InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
|
||||||
|
))}
|
||||||
|
</InputOTPGroup>
|
||||||
|
|
||||||
|
{secondGroupSize > 0 && (
|
||||||
|
<>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup
|
||||||
|
className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}
|
||||||
|
>
|
||||||
|
{Array.from({ length: secondGroupSize }, (_, i) => (
|
||||||
|
<InputOTPSlot
|
||||||
|
key={firstGroupSize + i}
|
||||||
|
index={firstGroupSize + i}
|
||||||
|
className={cn(error && "border-danger")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</InputOTPGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-danger text-center" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `index.ts` barrel file (`export { OtpInput } from "./OtpInput"`) stays unchanged.
|
||||||
|
|
||||||
|
**Step 2: Verify type-check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Verify lint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (no unused imports, no deep path violations)
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
|
||||||
|
git commit -m "refactor: replace custom OtpInput with shadcn InputOTP"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Verify all consumers still compile and work
|
||||||
|
|
||||||
|
**Files:** (read-only verification, no changes expected)
|
||||||
|
|
||||||
|
- `apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx`
|
||||||
|
- `apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx`
|
||||||
|
- `apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx`
|
||||||
|
|
||||||
|
**Step 1: Full type-check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS — all 3 consumers use the same `OtpInputProps` API which hasn't changed.
|
||||||
|
|
||||||
|
**Step 2: Run portal tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @customer-portal/portal test 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 3: Final commit if any fixes were needed**
|
||||||
|
|
||||||
|
Only commit if adjustments were required. Otherwise, skip.
|
||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -233,6 +233,9 @@ importers:
|
|||||||
geist:
|
geist:
|
||||||
specifier: ^1.5.1
|
specifier: ^1.5.1
|
||||||
version: 1.5.1(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
version: 1.5.1(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||||
|
input-otp:
|
||||||
|
specifier: ^1.4.2
|
||||||
|
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.563.0
|
specifier: ^0.563.0
|
||||||
version: 0.563.0(react@19.2.4)
|
version: 0.563.0(react@19.2.4)
|
||||||
@ -5586,6 +5589,15 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 }
|
engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 }
|
||||||
|
|
||||||
|
input-otp@1.4.2:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
inquirer@8.2.7:
|
inquirer@8.2.7:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -11769,6 +11781,11 @@ snapshots:
|
|||||||
|
|
||||||
ini@4.1.1: {}
|
ini@4.1.1: {}
|
||||||
|
|
||||||
|
input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
inquirer@8.2.7(@types/node@25.2.0):
|
inquirer@8.2.7(@types/node@25.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
"@inquirer/external-editor": 1.0.3(@types/node@25.2.0)
|
"@inquirer/external-editor": 1.0.3(@types/node@25.2.0)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user