"use client"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { forwardRef, useEffect, useId, useRef, useState, type InputHTMLAttributes, type ReactNode, } from "react"; import { cn } from "@/lib/utils"; export type InputClassNames = { root?: string; label?: string; field?: string; input?: string; leftIcon?: string; rightIcon?: string; successIcon?: string; errorMessage?: string; }; export interface InputProps extends Omit< InputHTMLAttributes, "value" | "defaultValue" | "onChange" > { label?: string; value?: string; defaultValue?: string; onChange?: (value: string) => void; /** Truthy error triggers a shake, red border and (if a string) a message. */ error?: string | boolean; success?: boolean; leftIcon?: ReactNode; rightIcon?: ReactNode; className?: string; classNames?: InputClassNames; } export const Input = forwardRef(function Input( { label, value: valueProp, defaultValue, onChange, onFocus, onBlur, error, success, leftIcon, rightIcon, className, classNames, disabled, id: idProp, type, ...rest }, ref, ) { const reactId = useId(); const id = idProp ?? reactId; const reduce = useReducedMotion(); const controlled = valueProp !== undefined; const [internal, setInternal] = useState(defaultValue ?? ""); const value = controlled ? (valueProp ?? "") : internal; const [focused, setFocused] = useState(false); const fieldRef = useRef(null); const hasError = Boolean(error); const errorMessage = typeof error === "string" ? error : null; // Right edge shows the success check, otherwise the caller's right icon. const rightSlot = success ? null : rightIcon; // Shake the field when an error appears. useEffect(() => { if (!fieldRef.current || reduce || !hasError) return; animate( fieldRef.current, { x: [0, -6, 6, -4, 4, -2, 0] }, { duration: 0.45 }, ); }, [hasError, reduce]); const handleChange = (next: string) => { if (!controlled) setInternal(next); onChange?.(next); }; return (
{label ? ( ) : null}
{leftIcon ? ( {leftIcon} ) : null} handleChange(e.target.value)} onFocus={(event) => { setFocused(true); onFocus?.(event); }} onBlur={(event) => { setFocused(false); onBlur?.(event); }} className={cn( "peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none", "placeholder:text-muted-foreground/60", leftIcon ? "pl-10" : "pl-3.5", rightSlot || success ? "pr-10" : "pr-3.5", disabled && "cursor-not-allowed", classNames?.input, )} /> {success ? ( ) : rightSlot ? ( {rightSlot} ) : null}
{errorMessage ? ( {errorMessage} ) : null}
); });