Files
router-lists-ui/frontend/src/components/FormField.jsx
T
denozord 58ee09b70e
Publish Docker image / build-and-push (push) Successful in 2m24s
Update frontend dependencies, enhance Vite configuration, and refactor components
- Added new dependencies including @base-ui/react, @fontsource-variable/geist, and tailwindcss for improved UI and styling.
- Updated Vite configuration to include path aliasing for easier imports.
- Refactored App.jsx to implement a theme context and improved routing structure.
- Enhanced various components (e.g., ConfirmDialog, DataTable) to utilize new UI components and improve user experience.
- Cleaned up CSS imports and ensured proper styling integration.

Made-with: Cursor
2026-04-26 13:48:59 +07:00

247 lines
7.0 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';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/**
* 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 = `${hasError ? 'border-destructive' : ''} ${hasSuccess ? 'border-green-600' : ''} ${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
value={localValue}
onValueChange={(newValue) => {
setLocalValue(newValue);
setIsDirty(true);
onChange?.(newValue);
}}
disabled={disabled}
>
<SelectTrigger id={inputId} className={inputClasses}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options?.map((option) => (
<SelectItem key={option.value} value={String(option.value)} disabled={option.disabled}>
{option.label}
</SelectItem>
))}
</SelectContent>
</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}>
{label}
{required && <span className="text-destructive ml-1" aria-label="обязательное поле">*</span>}
</Label>
)}
{renderInputWithIcon()}
{displayError && (
<div id={`${inputId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{displayError}
</div>
)}
{displaySuccess && !displayError && (
<div className="mt-1 text-sm text-green-600">
{displaySuccess}
</div>
)}
{helpText && !displayError && !displaySuccess && (
<div id={`${inputId}-help`} className="mt-1 flex items-center text-xs text-muted-foreground">
<IconAlertCircle size={14} className="mr-1" />
{helpText}
</div>
)}
</div>
);
}
export default FormField;