feat: Добавить анимации и улучшения UX/UI в компоненты приложения, включая плавные переходы, эффекты при наведении и анимации для модальных окон. Обновить карточки и таблицы с новыми анимациями для улучшения визуального восприятия и доступности.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m42s

This commit is contained in:
2025-10-02 19:17:25 +07:00
parent cd5e169bc6
commit 8952c4a897
12 changed files with 1267 additions and 143 deletions
+71
View File
@@ -0,0 +1,71 @@
import Modal from './Modal';
import { IconAlertTriangle } from '@tabler/icons-react';
/**
* ConfirmModal - модальное окно подтверждения действия
* Для критичных операций (удаление, отмена и т.д.)
*/
function ConfirmModal({
show,
onClose,
onConfirm,
title = 'Подтверждение',
message,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
variant = 'danger', // primary, danger, warning, success
icon: Icon = IconAlertTriangle,
loading = false,
showIcon = true
}) {
const handleConfirm = () => {
onConfirm?.();
};
return (
<Modal
show={show}
onClose={onClose}
title={title}
size="sm"
centered
footer={
<>
<button
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
{cancelLabel}
</button>
<button
className={`btn btn-${variant}`}
onClick={handleConfirm}
disabled={loading}
>
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />}
{confirmLabel}
</button>
</>
}
>
<div className="d-flex align-items-start">
{showIcon && Icon && (
<div className="flex-shrink-0 me-3">
<Icon className={`text-${variant}`} size={32} />
</div>
)}
<div className="flex-grow-1">
{typeof message === 'string' ? (
<p className="mb-0">{message}</p>
) : (
message
)}
</div>
</div>
</Modal>
);
}
export default ConfirmModal;
+147
View File
@@ -0,0 +1,147 @@
/**
* FormField - универсальное поле формы с валидацией
* Поддерживает иконки, подсказки, ошибки и success состояния
*/
function FormField({
label,
name,
type = 'text',
value,
onChange,
onBlur,
error,
success,
helpText,
required,
disabled,
placeholder,
icon: Icon,
className = '',
inputClassName = '',
rows, // для textarea
options, // для select
...inputProps
}) {
const inputId = `field-${name}`;
const hasError = !!error;
const hasSuccess = !!success && !error;
const isTextarea = type === 'textarea';
const isSelect = type === 'select';
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
const handleChange = (e) => {
onChange?.(e.target.value, e);
};
const handleBlur = (e) => {
onBlur?.(e);
};
const renderInput = () => {
if (isTextarea) {
return (
<textarea
id={inputId}
name={name}
className={inputClasses}
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
rows={rows || 3}
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={value}
onChange={handleChange}
onBlur={handleBlur}
required={required}
disabled={disabled}
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={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
/>
);
};
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>
)}
{Icon ? (
<div className="input-icon">
<span className="input-icon-addon">
<Icon size={18} />
</span>
{renderInput()}
</div>
) : (
renderInput()
)}
{error && (
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
{error}
</div>
)}
{hasSuccess && (
<div className="valid-feedback d-block">
{success}
</div>
)}
{helpText && !error && !success && (
<div id={`${inputId}-help`} className="form-text">
{helpText}
</div>
)}
</div>
);
}
export default FormField;
+72
View File
@@ -0,0 +1,72 @@
import Modal from './Modal';
/**
* FormModal - модальное окно с формой
* Автоматически обрабатывает submit и отображает кнопки действий
*/
function FormModal({
show,
onClose,
onSubmit,
title,
children,
submitLabel = 'Сохранить',
cancelLabel = 'Отмена',
submitIcon: SubmitIcon,
cancelIcon: CancelIcon,
loading = false,
submitVariant = 'primary',
disabled = false,
...modalProps
}) {
const handleSubmit = (e) => {
e.preventDefault();
onSubmit?.(e);
};
const handleKeyDown = (e) => {
// Ctrl+Enter или Cmd+Enter для быстрого submit
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handleSubmit(e);
}
};
return (
<Modal
show={show}
onClose={onClose}
title={title}
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
{CancelIcon && <CancelIcon className="icon me-2" />}
{cancelLabel}
</button>
<button
type="submit"
className={`btn btn-${submitVariant}`}
onClick={handleSubmit}
disabled={loading || disabled}
>
{loading && <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />}
{!loading && SubmitIcon && <SubmitIcon className="icon me-2" />}
{submitLabel}
</button>
</>
}
{...modalProps}
>
<form onSubmit={handleSubmit} onKeyDown={handleKeyDown}>
{children}
</form>
</Modal>
);
}
export default FormModal;
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react';
import { IconX } from '@tabler/icons-react';
/**
* Универсальный Modal компонент
* Использует нативный Tabler UI стиль
*/
function Modal({
show,
onClose,
title,
children,
footer,
size = 'md', // sm, md, lg, xl
backdrop = true,
keyboard = true,
scrollable = false,
centered = false,
className = ''
}) {
const modalRef = useRef(null);
const backdropRef = useRef(null);
// Управление классом modal-open на body
useEffect(() => {
if (show) {
document.body.classList.add('modal-open');
// Trap focus внутри модалки
modalRef.current?.focus();
} else {
document.body.classList.remove('modal-open');
}
return () => {
document.body.classList.remove('modal-open');
};
}, [show]);
// Обработка ESC
useEffect(() => {
if (keyboard && show) {
const handleEsc = (e) => {
if (e.key === 'Escape') onClose?.();
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}
}, [keyboard, show, onClose]);
if (!show) return null;
const handleBackdropClick = (e) => {
if (backdrop && e.target === backdropRef.current) {
onClose?.();
}
};
return (
<>
{/* Backdrop */}
<div
ref={backdropRef}
className="modal-backdrop fade show"
onClick={handleBackdropClick}
style={{ zIndex: 1050 }}
/>
{/* Modal */}
<div
className={`modal fade show d-block ${className}`}
tabIndex="-1"
role="dialog"
aria-modal="true"
style={{ zIndex: 1055 }}
ref={modalRef}
>
<div className={`modal-dialog${size !== 'md' ? ` modal-${size}` : ''}${scrollable ? ' modal-dialog-scrollable' : ''}${centered ? ' modal-dialog-centered' : ''}`}>
<div className="modal-content">
{title && (
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
aria-label="Закрыть"
/>
</div>
)}
<div className="modal-body">
{children}
</div>
{footer && (
<div className="modal-footer">
{footer}
</div>
)}
</div>
</div>
</div>
</>
);
}
export default Modal;