Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
237 lines
6.8 KiB
React
237 lines
6.8 KiB
React
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;
|