import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, Input, } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form"; import { useTranslation } from "../../locales/i18n.ts"; import { CommonInputProps } from "./types.js"; /** * * // Email con normalización y contador * } * iconPosition="left" * maxLength={120} * showCounter * clearable * /> * * // Teléfono con normalización (mantiene + y dígitos), prefix clicable * +34} * onPrefixClick={() => { * // Alternar prefijo, o abrir selector de país... * }} * clearable * /> * * // Número decimal con normalización (',' → '.'), suffix clicable para unidad * EUR} * onSuffixClick={() => { * // Cambiar moneda, etc. * }} * inputMode="decimal" // si quieres sobreescribir * icon={} * iconPosition="left" * /> * * // Password (sin normalizaciones) * * */ /** Presets de comportamiento */ type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password"; type Normalizer = (value: string) => string; type TextFieldProps = CommonInputProps & { control: Control; name: FieldPath; label?: string; placeholder?: string; description?: string; required?: boolean; readOnly?: boolean; className?: string; inputClassName?: string; typePreset?: TextFieldTypePreset; icon?: React.ReactNode; // Icono con tamaño: iconPosition?: "left" | "right"; // 'left' por defecto /** Addons laterales (pueden ser clicables, a diferencia de `icon`) */ prefix?: React.ReactNode; suffix?: React.ReactNode; onPrefixClick?: () => void; onSuffixClick?: () => void; /** UX extra */ clearable?: boolean; submitOnEnter?: boolean; disabledWhileSubmitting?: boolean; showSuccessWhenValid?: boolean; showValidatingSpinner?: boolean; /** Transformaciones */ transformOnBlur?: (value: string) => string; normalizeOnChange?: (value: string) => string; /** Contador de caracteres (si usas maxLength) */ showCounter?: boolean; /** Forzar type/inputMode/autocomplete si no quieres los del preset */ forceType?: React.HTMLInputTypeAttribute; forceInputMode?: React.HTMLAttributes["inputMode"]; forceAutoComplete?: string; }; /* ---------- Helpers de presets ---------- */ function presetInputType(p: TextFieldTypePreset): React.HTMLInputTypeAttribute { switch (p) { case "password": return "password"; case "number": return "text"; // usamos text + normalización para control fino (decimales, signos) case "email": return "email"; case "phone": return "tel"; case "text": return "text"; default: return "text"; } } function presetInputMode( p: TextFieldTypePreset ): React.HTMLAttributes["inputMode"] { switch (p) { case "phone": return "tel"; case "number": return "decimal"; case "email": return "email"; default: return undefined; } } function presetAutoComplete(p: TextFieldTypePreset): string | undefined { switch (p) { case "email": return "email"; case "phone": return "tel"; case "password": return "current-password"; default: return undefined; } } function presetTransformOnBlur(p: TextFieldTypePreset): ((v: string) => string) | undefined { switch (p) { case "email": return (v) => v.trim().toLowerCase(); case "text": return undefined; case "password": return undefined; case "phone": return undefined; case "number": return undefined; default: return undefined; } } /** Normalizador “suave” para números: permite signo inicial y un solo separador decimal '.' */ function normalizeNumber(value: string): string { // Sustituye comas por punto y elimina caracteres inválidos let v = value.replace(/,/g, "."); // Mantén solo dígitos, un punto y signo inicial v = v .replace(/[^\d.+-]/g, "") .replace(/(?!^)-/g, "") // solo un signo al inicio .replace(/(\..*)\./g, "$1"); // solo un punto return v; } /** Normalizador para teléfonos: mantiene dígitos y '+' inicial */ function normalizePhone(value: string): string { let v = value.replace(/[^\d+]/g, ""); v = v.replace(/(?!^)\+/g, ""); // '+' solo al inicio return v; } function presetNormalizeOnChange(p: TextFieldTypePreset): Normalizer | undefined { switch (p) { case "phone": return normalizePhone; case "number": return normalizeNumber; default: return undefined; } } /* ---------- Componente ---------- */ export function TextField({ control, name, label, description, required, readOnly, className, inputClassName, typePreset = "text", icon, iconPosition = "left", prefix, suffix, onPrefixClick, onSuffixClick, clearable = false, submitOnEnter = false, disabledWhileSubmitting = true, showSuccessWhenValid = false, showValidatingSpinner = true, transformOnBlur, normalizeOnChange, showCounter = false, forceType, forceInputMode, forceAutoComplete, maxLength, ...rest }: TextFieldProps) { const { t } = useTranslation(); const { isSubmitting, isValidating } = useFormState({ control, name }); const { field, fieldState } = useController({ control, name }); // Presets → defaults (permiten override por props explícitas) const effectiveType = forceType ?? presetInputType(typePreset); const effectiveInputMode = forceInputMode ?? presetInputMode(typePreset); const effectiveAutoComplete = forceAutoComplete ?? presetAutoComplete(typePreset); const effectiveTransformOnBlur = transformOnBlur ?? presetTransformOnBlur(typePreset); const effectiveNormalizeOnChange = normalizeOnChange ?? presetNormalizeOnChange(typePreset); const hasIcon = Boolean(icon); const isLeftIcon = iconPosition === "left"; const hasPrefix = prefix != null; const hasSuffix = suffix != null; // padding a partir de adornos const inputPadding = cn( hasIcon && isLeftIcon && "pl-10", hasIcon && !isLeftIcon && "pr-10", hasPrefix && "pl-10", hasSuffix && "pr-10" ); const invalid = fieldState.invalid && (fieldState.isTouched || fieldState.isDirty); const valid = !fieldState.invalid && (fieldState.isTouched || fieldState.isDirty) && field.value != null && String(field.value).length > 0; const disabled = (disabledWhileSubmitting && isSubmitting) || rest.disabled || readOnly; const describedById = description ? `${name}-desc` : undefined; const errorId = fieldState.error ? `${name}-err` : undefined; function handleChange(e: React.ChangeEvent) { const raw = e.target.value; const next = effectiveNormalizeOnChange ? effectiveNormalizeOnChange(raw) : raw; field.onChange(next); } function handleBlur(e: React.FocusEvent) { if (effectiveTransformOnBlur) { const next = effectiveTransformOnBlur(e.target.value ?? ""); if (next !== e.target.value) { field.onChange(next); } } field.onBlur(); } function handleKeyDown(e: React.KeyboardEvent) { if (submitOnEnter && e.key === "Enter") { const form = (e.currentTarget as HTMLInputElement).form; if (form) form.requestSubmit(); } } function handleClear() { field.onChange(""); } const valueLength = (field.value?.length ?? 0) as number; return ( ( {label && ( {label} {required && ( {t("common.required")} )} {/* Punto “unsaved” */} {fieldState.isDirty && ( {t("common.modified")} )} )} {/* Prefix clicable (si tiene onClick) */} {hasPrefix && ( {prefix} )} {/* Icono decorativo */} {hasIcon && ( {icon} )} {/* Suffix clicable */} {hasSuffix && ( {suffix} )} {/* Spinner de validación */} {showValidatingSpinner && isValidating && ( )} {/* Check de válido */} {showSuccessWhenValid && valid && !isValidating && !invalid && ( )} {/* Botón clear */} {clearable && !disabled && (field.value ?? "") !== "" && ( )} {description || "\u00A0"} {showCounter && typeof maxLength === "number" && ( {valueLength} / {maxLength} )} )} /> ); }
{valueLength} / {maxLength}