ADD new frontend
This commit is contained in:
359
frontend/src/components/ui/Modal/Modal.tsx
Normal file
359
frontend/src/components/ui/Modal/Modal.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { forwardRef, useEffect, useRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onClose'> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
variant?: 'default' | 'centered' | 'drawer-left' | 'drawer-right' | 'drawer-top' | 'drawer-bottom';
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
preventScroll?: boolean;
|
||||
initialFocus?: React.RefObject<HTMLElement>;
|
||||
finalFocus?: React.RefObject<HTMLElement>;
|
||||
overlayClassName?: string;
|
||||
contentClassName?: string;
|
||||
portalId?: string;
|
||||
}
|
||||
|
||||
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showCloseButton?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface ModalFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
||||
isOpen,
|
||||
onClose,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
preventScroll = true,
|
||||
initialFocus,
|
||||
finalFocus,
|
||||
overlayClassName,
|
||||
contentClassName,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEscape) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, closeOnEscape, onClose]);
|
||||
|
||||
// Handle body scroll lock
|
||||
useEffect(() => {
|
||||
if (!isOpen || !preventScroll) return;
|
||||
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
};
|
||||
}, [isOpen, preventScroll]);
|
||||
|
||||
// Handle focus management
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Store the currently focused element
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Set focus to initial focus element or modal content
|
||||
const timer = setTimeout(() => {
|
||||
const focusTarget = initialFocus?.current || contentRef.current;
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// Return focus to previously focused element
|
||||
if (finalFocus?.current) {
|
||||
finalFocus.current.focus();
|
||||
} else if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, initialFocus, finalFocus]);
|
||||
|
||||
// Handle overlay click
|
||||
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (closeOnOverlayClick && event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'max-w-xs',
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
||||
},
|
||||
centered: {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
||||
},
|
||||
'drawer-left': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-start',
|
||||
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
||||
},
|
||||
'drawer-right': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-end',
|
||||
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
||||
},
|
||||
'drawer-top': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-start',
|
||||
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
||||
},
|
||||
'drawer-bottom': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-end',
|
||||
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
||||
},
|
||||
};
|
||||
|
||||
const overlayClasses = clsx(
|
||||
variantClasses[variant].overlay,
|
||||
'animate-in fade-in duration-200',
|
||||
overlayClassName
|
||||
);
|
||||
|
||||
const contentClasses = clsx(
|
||||
'relative bg-modal-bg border border-modal-border rounded-lg shadow-xl',
|
||||
'animate-in zoom-in-95 duration-200',
|
||||
variantClasses[variant].content,
|
||||
sizeClasses[size],
|
||||
{
|
||||
'rounded-none': variant.includes('drawer'),
|
||||
'rounded-t-lg rounded-b-none': variant === 'drawer-bottom',
|
||||
'rounded-b-lg rounded-t-none': variant === 'drawer-top',
|
||||
'rounded-r-lg rounded-l-none': variant === 'drawer-left',
|
||||
'rounded-l-lg rounded-r-none': variant === 'drawer-right',
|
||||
},
|
||||
contentClassName,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={overlayClasses}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
>
|
||||
<div
|
||||
ref={ref || contentRef}
|
||||
className={contentClasses}
|
||||
{...props}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded-md p-1"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
||||
title,
|
||||
subtitle,
|
||||
showCloseButton = false,
|
||||
onClose,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const classes = clsx(
|
||||
'px-6 py-4 border-b border-border-primary',
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-lg font-semibold text-text-primary"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{showCloseButton && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-4 text-text-tertiary hover:text-text-primary transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded-md p-1"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(({
|
||||
padding = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
xl: 'p-10',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
paddingClasses[padding],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id="modal-description"
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(({
|
||||
justify = 'end',
|
||||
padding = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'px-6 py-4',
|
||||
lg: 'px-8 py-5',
|
||||
xl: 'px-10 py-6',
|
||||
};
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
'flex items-center gap-3 border-t border-border-primary',
|
||||
paddingClasses[padding],
|
||||
justifyClasses[justify],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Modal.displayName = 'Modal';
|
||||
ModalHeader.displayName = 'ModalHeader';
|
||||
ModalBody.displayName = 'ModalBody';
|
||||
ModalFooter.displayName = 'ModalFooter';
|
||||
|
||||
export default Modal;
|
||||
export { ModalHeader, ModalBody, ModalFooter };
|
||||
3
frontend/src/components/ui/Modal/index.ts
Normal file
3
frontend/src/components/ui/Modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Modal';
|
||||
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
|
||||
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
|
||||
Reference in New Issue
Block a user