vj-ui-components
Version:
A collection of beautiful, customizable React UI components including versatile navigation with dual layout support (sidebar/top), stylish input fields with icon support, advanced search with recommendations and autocomplete, elegant modals with animation
519 lines (481 loc) • 15.4 kB
JSX
import React, { useState, useEffect, useRef } from "react";
import { IconX, IconMaximize, IconMinimize } from "@tabler/icons-react";
const Modal = ({
// Basic props
isOpen = false,
onClose,
onConfirm,
onCancel,
// Content props
title = "",
children,
footer,
// Theming props
primaryColor = "#2563eb",
secondaryColor = "#1e40af",
backgroundColor = "rgba(255, 255, 255, 0.95)",
backdropColor = "rgba(0, 0, 0, 0.5)",
textColor = "#1f2937",
borderColor = "rgba(255, 255, 255, 0.2)",
// Size and styling props
size = "md", // "xs", "sm", "md", "lg", "xl", "full"
variant = "default", // "default", "glassmorphism", "minimal", "card"
borderRadius = "16px",
className = "",
// Behavior props
closeOnBackdropClick = true,
closeOnEscape = true,
showCloseButton = true,
showMaximizeButton = false,
preventBodyScroll = true,
// Animation props
animationDuration = 300,
enterAnimation = "fadeScale", // "fade", "fadeScale", "slideUp", "slideDown", "slideLeft", "slideRight"
exitAnimation = "fadeScale",
// Button props
confirmText = "Confirm",
cancelText = "Cancel",
showConfirmButton = false,
showCancelButton = false,
confirmButtonVariant = "primary",
cancelButtonVariant = "secondary",
// Advanced props
backdrop = true,
fullScreen = false,
centered = true,
scrollable = true,
headerClassName = "",
bodyClassName = "",
footerClassName = "",
...rest
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const modalRef = useRef(null);
const backdropRef = useRef(null);
// Handle modal open/close
useEffect(() => {
if (isOpen) {
setIsVisible(true);
// Small delay to ensure the DOM is ready before starting animation
const animationTimer = setTimeout(() => {
setIsAnimating(true);
}, 10);
if (preventBodyScroll) {
document.body.style.overflow = 'hidden';
}
return () => clearTimeout(animationTimer);
} else {
setIsAnimating(false);
if (preventBodyScroll) {
document.body.style.overflow = '';
}
const timer = setTimeout(() => setIsVisible(false), animationDuration);
return () => clearTimeout(timer);
}
}, [isOpen, preventBodyScroll, animationDuration]);
// Cleanup effect
useEffect(() => {
return () => {
if (preventBodyScroll) {
document.body.style.overflow = '';
}
};
}, [preventBodyScroll]);
// Handle escape key
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && closeOnEscape && isOpen) {
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [isOpen, closeOnEscape]);
// Handle backdrop click
const handleBackdropClick = (e) => {
if (e.target === backdropRef.current && closeOnBackdropClick) {
handleClose();
}
};
// Handle close
const handleClose = () => {
if (onClose) {
onClose();
}
};
// Handle confirm
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
};
// Handle cancel
const handleCancel = () => {
if (onCancel) {
onCancel();
}
};
// Toggle maximize
const toggleMaximize = () => {
setIsMaximized(!isMaximized);
};
// Get size styles
const getSizeStyles = () => {
if (fullScreen || isMaximized) {
return {
width: '100vw',
height: '100vh',
maxWidth: 'none',
maxHeight: 'none',
margin: 0,
borderRadius: 0,
};
}
switch (size) {
case "xs":
return {
width: '90%',
maxWidth: '300px',
maxHeight: '90vh',
};
case "sm":
return {
width: '90%',
maxWidth: '400px',
maxHeight: '90vh',
};
case "lg":
return {
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
};
case "xl":
return {
width: '90%',
maxWidth: '1200px',
maxHeight: '90vh',
};
case "full":
return {
width: '95vw',
height: '95vh',
maxWidth: 'none',
maxHeight: 'none',
};
default: // md
return {
width: '90%',
maxWidth: '600px',
maxHeight: '90vh',
};
}
};
// Get variant styles
const getVariantStyles = () => {
const baseStyles = {
borderRadius: fullScreen || isMaximized ? 0 : borderRadius,
boxShadow: variant === 'minimal'
? '0 4px 20px rgba(0, 0, 0, 0.15)'
: '0 20px 60px rgba(0, 0, 0, 0.3)',
border: variant === 'glassmorphism'
? `1px solid ${borderColor}`
: 'none',
};
switch (variant) {
case "glassmorphism":
return {
...baseStyles,
background: `linear-gradient(135deg, ${backgroundColor}, rgba(255, 255, 255, 0.8))`,
backdropFilter: 'blur(20px)',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.2)',
};
case "minimal":
return {
...baseStyles,
background: '#ffffff',
border: '1px solid #e5e7eb',
};
case "card":
return {
...baseStyles,
background: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '12px',
};
default:
return {
...baseStyles,
background: backgroundColor,
};
}
};
// Get animation styles
const getAnimationStyles = () => {
const duration = `${animationDuration}ms`;
const animation = isAnimating ? enterAnimation : exitAnimation;
const animations = {
fade: {
opacity: isAnimating ? 1 : 0,
transition: `opacity ${duration} ease-in-out`,
},
fadeScale: {
opacity: isAnimating ? 1 : 0,
transform: isAnimating ? 'scale(1)' : 'scale(0.95)',
transition: `all ${duration} cubic-bezier(0.4, 0, 0.2, 1)`,
},
slideUp: {
opacity: isAnimating ? 1 : 0,
transform: isAnimating ? 'translateY(0)' : 'translateY(20px)',
transition: `all ${duration} cubic-bezier(0.4, 0, 0.2, 1)`,
},
slideDown: {
opacity: isAnimating ? 1 : 0,
transform: isAnimating ? 'translateY(0)' : 'translateY(-20px)',
transition: `all ${duration} cubic-bezier(0.4, 0, 0.2, 1)`,
},
slideLeft: {
opacity: isAnimating ? 1 : 0,
transform: isAnimating ? 'translateX(0)' : 'translateX(20px)',
transition: `all ${duration} cubic-bezier(0.4, 0, 0.2, 1)`,
},
slideRight: {
opacity: isAnimating ? 1 : 0,
transform: isAnimating ? 'translateX(0)' : 'translateX(-20px)',
transition: `all ${duration} cubic-bezier(0.4, 0, 0.2, 1)`,
},
};
return animations[animation] || animations.fadeScale;
};
// Get button styles
const getButtonStyles = (buttonVariant) => {
const baseStyles = {
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
fontSize: '0.875rem',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
};
switch (buttonVariant) {
case "primary":
return {
...baseStyles,
background: `linear-gradient(135deg, ${primaryColor}, ${secondaryColor})`,
color: 'white',
};
case "secondary":
return {
...baseStyles,
background: 'transparent',
color: textColor,
border: `1px solid ${borderColor}`,
};
default:
return baseStyles;
}
};
if (!isVisible) return null;
const sizeStyles = getSizeStyles();
const variantStyles = getVariantStyles();
const animationStyles = getAnimationStyles();
return (
<div
ref={backdropRef}
onClick={handleBackdropClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1050,
display: 'flex',
alignItems: centered ? 'center' : 'flex-start',
justifyContent: 'center',
padding: centered ? '20px' : '20px 20px 0',
background: backdrop ? backdropColor : 'transparent',
opacity: isAnimating ? 1 : 0,
transition: `opacity ${animationDuration}ms ease-in-out`,
overflow: scrollable ? 'auto' : 'hidden',
}}
className={`ui-modal-backdrop ${className}`}
{...rest}
>
<div
ref={modalRef}
style={{
...sizeStyles,
...variantStyles,
...animationStyles,
position: 'relative',
display: 'flex',
flexDirection: 'column',
color: textColor,
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
overflow: 'hidden',
}}
className="ui-modal-content"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton || showMaximizeButton) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px 24px',
borderBottom: variant === 'minimal' ? '1px solid #e5e7eb' : 'none',
backgroundColor: variant === 'glassmorphism' ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
}}
className={`ui-modal-header ${headerClassName}`}
>
<h2
style={{
margin: 0,
fontSize: '1.25rem',
fontWeight: '600',
color: textColor,
flex: 1,
}}
>
{title}
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{showMaximizeButton && (
<button
onClick={toggleMaximize}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
color: textColor,
opacity: 0.7,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.target.style.opacity = '1';
e.target.style.background = 'rgba(0, 0, 0, 0.1)';
}}
onMouseLeave={(e) => {
e.target.style.opacity = '0.7';
e.target.style.background = 'none';
}}
>
{isMaximized ? <IconMinimize size={18} /> : <IconMaximize size={18} />}
</button>
)}
{showCloseButton && (
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
color: textColor,
opacity: 0.7,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.target.style.opacity = '1';
e.target.style.background = 'rgba(220, 38, 38, 0.1)';
e.target.style.color = '#dc2626';
}}
onMouseLeave={(e) => {
e.target.style.opacity = '0.7';
e.target.style.background = 'none';
e.target.style.color = textColor;
}}
>
<IconX size={18} />
</button>
)}
</div>
</div>
)}
{/* Body */}
<div
style={{
flex: 1,
padding: title ? '24px' : '32px 24px',
overflow: scrollable ? 'auto' : 'visible',
minHeight: 0,
}}
className={`ui-modal-body ${bodyClassName}`}
>
{children}
</div>
{/* Footer */}
{(footer || showConfirmButton || showCancelButton) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '12px',
padding: '20px 24px',
borderTop: variant === 'minimal' ? '1px solid #e5e7eb' : 'none',
backgroundColor: variant === 'glassmorphism' ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
}}
className={`ui-modal-footer ${footerClassName}`}
>
{footer ? (
footer
) : (
<>
{showCancelButton && (
<button
onClick={handleCancel}
style={getButtonStyles(cancelButtonVariant)}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
{cancelText}
</button>
)}
{showConfirmButton && (
<button
onClick={handleConfirm}
style={getButtonStyles(confirmButtonVariant)}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
{confirmText}
</button>
)}
</>
)}
</div>
)}
</div>
</div>
);
};
export default Modal;