Publish Docker image / build-and-push (push) Successful in 2m24s
- 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
247 lines
7.0 KiB
React
247 lines
7.0 KiB
React
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;
|