Files
router-lists-ui/frontend/src/components/FormField.jsx
T

237 lines
6.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react';
/**
* FormField - универсальное поле формы с валидацией
* Объединяет функциональность FormField и ValidatedInput
* Поддерживает: иконки, подсказки, ошибки, success состояния, debounce валидацию
*/
function FormField({
label,
name,
type = 'text',
value,
onChange,
onBlur,
onValidate, // функция валидации (опционально): (value) => { valid: boolean, message: string }
error,
success,
helpText,
required,
disabled,
placeholder,
autoFocus,
icon: Icon,
className = '',
inputClassName = '',
rows, // для textarea
options, // для select
debounceMs = 300, // debounce для валидации
showValidationIcon = true,
...inputProps
}) {
const inputId = `field-${name}`;
const [localValue, setLocalValue] = useState(value || '');
const [validation, setValidation] = useState({ valid: null, message: '' });
const [isDirty, setIsDirty] = useState(false);
const timerRef = useRef(null);
const hasExternalError = !!error;
const hasValidationError = isDirty && validation.valid === false;
const hasError = hasExternalError || hasValidationError;
const hasExternalSuccess = !!success && !hasExternalError;
const hasValidationSuccess = isDirty && validation.valid === true && !hasExternalError;
const hasSuccess = hasExternalSuccess || hasValidationSuccess;
const isTextarea = type === 'textarea';
const isSelect = type === 'select';
// Синхронизация с внешним value
useEffect(() => {
setLocalValue(value || '');
}, [value]);
// Валидация с debounce
useEffect(() => {
if (!isDirty || !onValidate) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
const result = onValidate(localValue);
setValidation(result);
}, debounceMs);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [localValue, isDirty, onValidate, debounceMs]);
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
const handleChange = (e) => {
const newValue = e.target.value;
setLocalValue(newValue);
setIsDirty(true);
onChange?.(newValue, e);
};
const handleBlur = (e) => {
setIsDirty(true);
onBlur?.(e);
};
const renderInput = () => {
if (isTextarea) {
return (
<textarea
id={inputId}
name={name}
className={inputClasses}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
rows={rows || 3}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
/>
);
}
if (isSelect) {
return (
<select
id={inputId}
name={name}
className={inputClasses.replace('form-control', 'form-select')}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
required={required}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
>
{options?.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}
return (
<input
id={inputId}
name={name}
type={type}
className={inputClasses}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
/>
);
};
// Рендер с иконкой (слева или справа для валидации)
const renderInputWithIcon = () => {
const hasValidationIcon = showValidationIcon && isDirty && validation.valid !== null;
if (Icon) {
// Иконка слева (переданная через prop)
return (
<div className="input-icon">
<span className="input-icon-addon">
<Icon size={18} />
</span>
{renderInput()}
{hasValidationIcon && (
<span className="input-icon-addon" style={{ right: 0, left: 'auto' }}>
{validation.valid === true ? (
<IconCheck size={20} className="text-success" />
) : (
<IconX size={20} className="text-danger" />
)}
</span>
)}
</div>
);
}
if (hasValidationIcon) {
// Только иконка валидации справа
return (
<div className="input-icon">
{renderInput()}
<span className="input-icon-addon">
{validation.valid === true ? (
<IconCheck size={20} className="text-success" />
) : (
<IconX size={20} className="text-danger" />
)}
</span>
</div>
);
}
return renderInput();
};
const displayError = error || (hasValidationError ? validation.message : null);
const displaySuccess = success || (hasValidationSuccess ? validation.message : null);
return (
<div className={`mb-3 ${className}`}>
{label && (
<label htmlFor={inputId} className="form-label">
{label}
{required && <span className="text-danger ms-1" aria-label="обязательное поле">*</span>}
</label>
)}
{renderInputWithIcon()}
{displayError && (
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
{displayError}
</div>
)}
{displaySuccess && !displayError && (
<div className="valid-feedback d-block">
{displaySuccess}
</div>
)}
{helpText && !displayError && !displaySuccess && (
<div id={`${inputId}-help`} className="form-text">
<IconAlertCircle size={14} className="me-1" />
{helpText}
</div>
)}
</div>
);
}
export default FormField;