116 lines
2.9 KiB
TypeScript
116 lines
2.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { FocusTrap } from './focus-trap';
|
|
import { X } from 'lucide-react';
|
|
import { Button } from './button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface ModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
title?: string;
|
|
closeOnOverlayClick?: boolean;
|
|
closeOnEscape?: boolean;
|
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
className?: string;
|
|
}
|
|
|
|
const sizeClasses = {
|
|
sm: 'max-w-sm',
|
|
md: 'max-w-md',
|
|
lg: 'max-w-lg',
|
|
xl: 'max-w-xl',
|
|
full: 'max-w-full mx-4',
|
|
};
|
|
|
|
/**
|
|
* Composant Modal réutilisable avec overlay, fermeture, et gestion du focus.
|
|
*/
|
|
export function Modal({
|
|
open,
|
|
onClose,
|
|
children,
|
|
title,
|
|
closeOnOverlayClick = true,
|
|
closeOnEscape = true,
|
|
size = 'md',
|
|
className,
|
|
}: ModalProps) {
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Empêcher le scroll du body quand le modal est ouvert
|
|
useEffect(() => {
|
|
if (open) {
|
|
document.body.style.overflow = 'hidden';
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
};
|
|
}
|
|
}, [open]);
|
|
|
|
// Gérer le clic sur l'overlay
|
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
// Ne pas rendre le modal s'il n'est pas ouvert
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
onClick={handleOverlayClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={title ? 'modal-title' : undefined}
|
|
>
|
|
{/* Overlay */}
|
|
<div className="fixed inset-0 bg-black/50 transition-opacity" />
|
|
|
|
{/* Modal Content */}
|
|
<FocusTrap active={open} onEscape={closeOnEscape ? onClose : undefined}>
|
|
<div
|
|
ref={modalRef}
|
|
className={cn(
|
|
'relative z-50 w-full bg-background rounded-lg shadow-xl',
|
|
'transform transition-all',
|
|
sizeClasses[size],
|
|
className
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
{(title || closeOnEscape) && (
|
|
<div className="flex items-center justify-between p-6 border-b">
|
|
{title && (
|
|
<h2
|
|
id="modal-title"
|
|
className="text-2xl font-semibold leading-none tracking-tight"
|
|
>
|
|
{title}
|
|
</h2>
|
|
)}
|
|
{closeOnEscape && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className="ml-auto"
|
|
aria-label="Fermer"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="p-6">{children}</div>
|
|
</div>
|
|
</FocusTrap>
|
|
</div>
|
|
);
|
|
}
|
|
|