// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2025 Pierre Fitness Intelligence
// ABOUTME: Reusable Modal component with Pierre design system styling
// ABOUTME: Features smooth animations, gradient accent bar, and accessible focus management
import React, { useEffect, useCallback, useRef } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
footer?: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
footer,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
const handleEscape = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape' && closeOnEscape) {
onClose();
}
},
[onClose, closeOnEscape]
);
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (closeOnOverlayClick && event.target === event.currentTarget) {
onClose();
}
};
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
// Focus the modal when it opens
setTimeout(() => {
modalRef.current?.focus();
}, 0);
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
ref={modalRef}
tabIndex={-1}
className={`${sizeClasses[size]} w-full bg-white rounded-xl shadow-xl overflow-hidden animate-scale-in`}
>
{/* Gradient accent bar */}
<div className="h-1 w-full bg-gradient-pierre-horizontal" />
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-pierre-gray-200">
{title && (
<h2 id="modal-title" className="text-lg font-semibold text-pierre-gray-900">
{title}
</h2>
)}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="p-2 text-pierre-gray-400 hover:text-pierre-gray-600 hover:bg-pierre-gray-100 rounded-lg transition-colors"
aria-label="Close modal"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
)}
{/* Content */}
<div className="px-6 py-4">{children}</div>
{/* Footer */}
{footer && (
<div className="px-6 py-4 bg-pierre-gray-50 border-t border-pierre-gray-200">{footer}</div>
)}
</div>
</div>
);
};
// Convenience component for modal actions
export interface ModalActionsProps {
children: React.ReactNode;
className?: string;
}
export const ModalActions: React.FC<ModalActionsProps> = ({ children, className = '' }) => {
return <div className={`flex items-center justify-end gap-3 ${className}`}>{children}</div>;
};