472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
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
|
|
* <TextField
|
|
* name="email"
|
|
* label="Email"
|
|
* description="Usa tu email de trabajo"
|
|
* typePreset="email"
|
|
* placeholder="tú@empresa.com"
|
|
* icon={<Mail className="h-4 w-4" />}
|
|
* iconPosition="left"
|
|
* maxLength={120}
|
|
* showCounter
|
|
* clearable
|
|
* />
|
|
*
|
|
* // Teléfono con normalización (mantiene + y dígitos), prefix clicable
|
|
* <TextField
|
|
* name="mobile"
|
|
* label="Móvil"
|
|
* description="Incluye prefijo internacional"
|
|
* typePreset="phone"
|
|
* placeholder="+34 600 000 000"
|
|
* prefix={<span className="text-xs">+34</span>}
|
|
* onPrefixClick={() => {
|
|
* // Alternar prefijo, o abrir selector de país...
|
|
* }}
|
|
* clearable
|
|
* />
|
|
*
|
|
* // Número decimal con normalización (',' → '.'), suffix clicable para unidad
|
|
* <TextField
|
|
* name="price"
|
|
* label="Precio"
|
|
* description="Con IVA"
|
|
* typePreset="number"
|
|
* placeholder="0.00"
|
|
* suffix={<span className="text-xs">EUR</span>}
|
|
* onSuffixClick={() => {
|
|
* // Cambiar moneda, etc.
|
|
* }}
|
|
* inputMode="decimal" // si quieres sobreescribir
|
|
* icon={<Hash className="h-4 w-4" />}
|
|
* iconPosition="left"
|
|
* />
|
|
*
|
|
* // Password (sin normalizaciones)
|
|
* <TextField
|
|
* name="password"
|
|
* label="Contraseña"
|
|
* typePreset="password"
|
|
* placeholder="••••••••"
|
|
* autoComplete="new-password"
|
|
* />
|
|
*
|
|
*/
|
|
|
|
/** Presets de comportamiento */
|
|
type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password";
|
|
|
|
type Normalizer = (value: string) => string;
|
|
|
|
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
|
control: Control<TFormValues>;
|
|
name: FieldPath<TFormValues>;
|
|
label?: string;
|
|
placeholder?: string;
|
|
description?: string;
|
|
required?: boolean;
|
|
readOnly?: boolean;
|
|
className?: string;
|
|
inputClassName?: string;
|
|
|
|
typePreset?: TextFieldTypePreset;
|
|
|
|
icon?: React.ReactNode; // Icono con tamaño: <MailIcon className="h-[18px] w-[18px]" />
|
|
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<HTMLInputElement>["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<HTMLInputElement>["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<TFormValues extends FieldValues>({
|
|
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<TFormValues>) {
|
|
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<HTMLInputElement>) {
|
|
const raw = e.target.value;
|
|
const next = effectiveNormalizeOnChange ? effectiveNormalizeOnChange(raw) : raw;
|
|
field.onChange(next);
|
|
}
|
|
|
|
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
|
|
if (effectiveTransformOnBlur) {
|
|
const next = effectiveTransformOnBlur(e.target.value ?? "");
|
|
if (next !== e.target.value) {
|
|
field.onChange(next);
|
|
}
|
|
}
|
|
field.onBlur();
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
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 (
|
|
<FormField
|
|
control={control}
|
|
name={name}
|
|
render={({ field }) => (
|
|
<FormItem className={cn("space-y-0", className)}>
|
|
{label && (
|
|
<div className='mb-1 flex justify-between gap-2'>
|
|
<div className='flex items-center gap-2'>
|
|
<FormLabel
|
|
htmlFor={name}
|
|
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
|
>
|
|
{label}
|
|
</FormLabel>
|
|
{required && (
|
|
<span className='text-xs text-destructive'>{t("common.required")}</span>
|
|
)}
|
|
</div>
|
|
{/* Punto “unsaved” */}
|
|
{fieldState.isDirty && (
|
|
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<FormControl>
|
|
<div className={cn("relative")}>
|
|
{/* Prefix clicable (si tiene onClick) */}
|
|
{hasPrefix && (
|
|
<button
|
|
type={onPrefixClick ? "button" : undefined}
|
|
onClick={onPrefixClick}
|
|
tabIndex={onPrefixClick ? 0 : -1}
|
|
className={cn(
|
|
"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
|
!onPrefixClick && "pointer-events-none"
|
|
)}
|
|
aria-label='prefix'
|
|
>
|
|
{prefix}
|
|
</button>
|
|
)}
|
|
|
|
{/* Icono decorativo */}
|
|
{hasIcon && (
|
|
<span
|
|
aria-hidden='true'
|
|
className={cn(
|
|
"pointer-events-none absolute top-1/2 -translate-y-1/2",
|
|
isLeftIcon ? "left-3" : "right-3"
|
|
)}
|
|
>
|
|
{icon}
|
|
</span>
|
|
)}
|
|
|
|
<Input
|
|
id={name}
|
|
type={effectiveType}
|
|
inputMode={effectiveInputMode}
|
|
autoComplete={effectiveAutoComplete}
|
|
placeholder={rest.placeholder}
|
|
value={field.value ?? ""}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
ref={field.ref}
|
|
disabled={disabled}
|
|
readOnly={readOnly}
|
|
aria-invalid={invalid || undefined}
|
|
aria-describedby={cn(describedById, errorId)}
|
|
aria-errormessage={errorId}
|
|
aria-busy={(showValidatingSpinner && isValidating) || undefined}
|
|
maxLength={maxLength}
|
|
{...rest}
|
|
className={cn(
|
|
"placeholder:font-normal placeholder:italic bg-background",
|
|
inputPadding,
|
|
invalid && "border-destructive focus-visible:ring-destructive",
|
|
valid && showSuccessWhenValid && "border-green-500 focus-visible:ring-green-500",
|
|
// Si hay suffix interactivo y spinner/check, reserva padding derecho
|
|
(hasSuffix || showValidatingSpinner || valid) && "pr-10",
|
|
inputClassName
|
|
)}
|
|
/>
|
|
|
|
{/* Suffix clicable */}
|
|
{hasSuffix && (
|
|
<button
|
|
type={onSuffixClick ? "button" : undefined}
|
|
onClick={onSuffixClick}
|
|
tabIndex={onSuffixClick ? 0 : -1}
|
|
className={cn(
|
|
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
|
!onSuffixClick && "pointer-events-none"
|
|
)}
|
|
aria-label='suffix'
|
|
>
|
|
{suffix}
|
|
</button>
|
|
)}
|
|
|
|
{/* Spinner de validación */}
|
|
{showValidatingSpinner && isValidating && (
|
|
<span className='absolute right-2 top-1/2 -translate-y-1/2'>
|
|
<Loader2Icon className='h-4 w-4 animate-spin' />
|
|
</span>
|
|
)}
|
|
|
|
{/* Check de válido */}
|
|
{showSuccessWhenValid && valid && !isValidating && !invalid && (
|
|
<span className='absolute right-2 top-1/2 -translate-y-1/2 text-green-600'>
|
|
<CheckIcon className='h-4 w-4' />
|
|
</span>
|
|
)}
|
|
|
|
{/* Botón clear */}
|
|
{clearable && !disabled && (field.value ?? "") !== "" && (
|
|
<button
|
|
type='button'
|
|
aria-label='Borrar'
|
|
onClick={handleClear}
|
|
className='absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 hover:bg-muted'
|
|
>
|
|
<XIcon className='h-4 w-4' />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
|
|
<div className='mt-1 flex items-start justify-between'>
|
|
<FormDescription
|
|
id={describedById}
|
|
className={cn("text-xs truncate", !description && "invisible")}
|
|
>
|
|
{description || "\u00A0"}
|
|
</FormDescription>
|
|
|
|
{showCounter && typeof maxLength === "number" && (
|
|
<p className='text-xs text-muted-foreground'>
|
|
{valueLength} / {maxLength}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<FormMessage id={errorId} />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
}
|