178 lines
5.0 KiB
TypeScript
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; |