refactor: Extract reusable UI components and add TDD documentation
- 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>
This commit is contained in:
132
frontend/src/components/ui/Modal.tsx
Normal file
132
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user