Files
klz-cables.com/components/forms/FormInput.tsx
2025-12-29 18:18:48 +01:00

178 lines
5.0 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import FormLabel from './FormLabel';
import FormError from './FormError';
/**
* FormInput Component
* Base input component with all HTML5 input types, validation states, icons, and clear button
*/
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix' | 'suffix'> {
label?: string;
error?: string | string[];
helpText?: string;
required?: boolean;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
showClear?: boolean;
iconPosition?: 'prefix' | 'suffix';
containerClassName?: string;
inputClassName?: string;
onClear?: () => void;
}
export const FormInput: React.FC<FormInputProps> = ({
label,
error,
helpText,
required = false,
prefix,
suffix,
showClear = false,
iconPosition = 'prefix',
containerClassName,
inputClassName,
onClear,
disabled = false,
value = '',
onChange,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const hasError = !!error;
const showError = hasError;
const handleClear = useCallback(() => {
if (onChange) {
const syntheticEvent = {
target: { value: '', name: props.name, type: props.type },
currentTarget: { value: '', name: props.name, type: props.type },
} as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
if (onClear) {
onClear();
}
}, [onChange, onClear, props.name, props.type]);
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const baseInputClasses = cn(
'w-full px-3 py-2 border rounded-md transition-all duration-200',
'bg-neutral-light text-text-primary',
'placeholder:text-text-light',
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
'disabled:opacity-60 disabled:cursor-not-allowed',
{
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
'border-primary ring-2 ring-primary': isFocused && !hasError,
'border-danger ring-2 ring-danger/20': hasError,
'pl-10': prefix && iconPosition === 'prefix',
'pr-10': (suffix && iconPosition === 'suffix') || (showClear && value),
},
inputClassName
);
const containerClasses = cn(
'flex flex-col gap-1.5',
containerClassName
);
const iconWrapperClasses = cn(
'absolute top-1/2 -translate-y-1/2 flex items-center pointer-events-none text-text-secondary',
{
'left-3': iconPosition === 'prefix',
'right-3': iconPosition === 'suffix',
}
);
const clearButtonClasses = cn(
'absolute top-1/2 -translate-y-1/2 right-2',
'p-1 rounded-md hover:bg-neutral-dark transition-colors',
'text-text-secondary hover:text-text-primary',
'focus:outline-none focus:ring-2 focus:ring-primary'
);
const showPrefix = prefix && iconPosition === 'prefix';
const showSuffix = suffix && iconPosition === 'suffix';
const showClearButton = showClear && value && !disabled;
return (
<div className={containerClasses}>
{label && (
<FormLabel htmlFor={inputId} required={required}>
{label}
</FormLabel>
)}
<div className="relative">
{showPrefix && (
<div className={iconWrapperClasses}>
{prefix}
</div>
)}
<input
id={inputId}
className={baseInputClasses}
value={value}
onChange={onChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
aria-invalid={hasError}
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
required={required}
{...props}
/>
{showSuffix && (
<div className={cn(iconWrapperClasses, 'right-3 left-auto')}>
{suffix}
</div>
)}
{showClearButton && (
<button
type="button"
onClick={handleClear}
className={clearButtonClasses}
aria-label="Clear input"
disabled={disabled}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
{helpText && (
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
{helpText}
</p>
)}
{showError && (
<FormError errors={error} id={`${inputId}-error`} />
)}
</div>
);
};
FormInput.displayName = 'FormInput';
export default FormInput;