- Add comprehensive TDD documentation to CLAUDE.md with coverage requirements and examples - Extract reusable UI components to frontend/src/components/ui/ (Modal, FormInput, Button, Alert, etc.) - Add shared constants (schedulePresets) and utility hooks (useCrudMutation, useFormValidation) - Update frontend/CLAUDE.md with component documentation and usage examples - Refactor CreateTaskModal to use shared components and constants - Fix test assertions to be more robust and accurate across all test files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
133 lines
3.5 KiB
TypeScript
133 lines
3.5 KiB
TypeScript
import React, { useEffect, useCallback } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
|
|
|
|
interface ModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title?: string | React.ReactNode;
|
|
children: React.ReactNode;
|
|
footer?: React.ReactNode;
|
|
size?: ModalSize;
|
|
showCloseButton?: boolean;
|
|
closeOnOverlayClick?: boolean;
|
|
closeOnEscape?: boolean;
|
|
className?: string;
|
|
contentClassName?: string;
|
|
/** If true, prevents body scroll when modal is open */
|
|
preventScroll?: boolean;
|
|
}
|
|
|
|
const sizeClasses: Record<ModalSize, string> = {
|
|
sm: 'max-w-sm',
|
|
md: 'max-w-md',
|
|
lg: 'max-w-lg',
|
|
xl: 'max-w-xl',
|
|
'2xl': 'max-w-2xl',
|
|
'3xl': 'max-w-3xl',
|
|
'4xl': 'max-w-4xl',
|
|
'5xl': 'max-w-5xl',
|
|
'6xl': 'max-w-6xl',
|
|
full: 'max-w-full mx-4',
|
|
};
|
|
|
|
export const Modal: React.FC<ModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
title,
|
|
children,
|
|
footer,
|
|
size = 'md',
|
|
showCloseButton = true,
|
|
closeOnOverlayClick = true,
|
|
closeOnEscape = true,
|
|
className = '',
|
|
contentClassName = '',
|
|
preventScroll = true,
|
|
}) => {
|
|
// Handle escape key
|
|
const handleEscape = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
if (closeOnEscape && e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
},
|
|
[closeOnEscape, onClose]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.addEventListener('keydown', handleEscape);
|
|
if (preventScroll) {
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
if (preventScroll) {
|
|
document.body.style.overflow = '';
|
|
}
|
|
};
|
|
}, [isOpen, handleEscape, preventScroll]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
|
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const modalContent = (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm"
|
|
onClick={handleOverlayClick}
|
|
>
|
|
<div
|
|
className={`bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col ${className}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
{(title || showCloseButton) && (
|
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
|
{title && (
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{title}
|
|
</h3>
|
|
)}
|
|
{showCloseButton && (
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ml-auto"
|
|
aria-label="Close modal"
|
|
>
|
|
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className={`flex-1 overflow-y-auto p-6 ${contentClassName}`}>
|
|
{children}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
{footer && (
|
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Use portal to render modal at document body level
|
|
return createPortal(modalContent, document.body);
|
|
};
|
|
|
|
export default Modal;
|