Uecko_ERP/packages/rdx-ui/src/components/form/TextField.tsx
2025-10-02 18:30:46 +02:00

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>
)}
/>
);
}