All files / frontend/src/components Modal.tsx

72.22% Statements 65/90
90% Branches 9/10
66.66% Functions 2/3
72.22% Lines 65/90

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 1301x 1x                     1x 4x 4x   4x 4x   3x     3x 3x   1x 1x 4x   4x 4x                                                         4x 4x 4x   4x   3x 3x 3x 3x 3x 3x   3x 3x   3x 3x 3x 3x 3x     3x 3x 3x 3x 3x   3x 3x 3x 3x 3x 3x 3x 3x 3x 3x     3x 3x 3x 3x 3x 3x 3x   3x 3x     3x 3x 3x     3x         4x 4x 4x   4x   1x  
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
 
interface ModalProps {
    isOpen: boolean;
    onClose: () => void;
    title: string;
    children: React.ReactNode;
    footer?: React.ReactNode;
    size?: 'sm' | 'md' | 'lg' | 'xl';
}
 
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, footer, size = 'md' }) => {
    const modalRef = useRef<HTMLDivElement>(null);
    const previousFocusRef = useRef<HTMLElement | null>(null);
 
    useEffect(() => {
        if (isOpen) {
            // Store the element that had focus before the modal opened
            previousFocusRef.current = document.activeElement as HTMLElement;
            
            // Focus the modal
            modalRef.current?.focus();
        } else {
            // Return focus to the previously focused element
            previousFocusRef.current?.focus();
        }
    }, [isOpen]);
 
    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            if (e.key === 'Escape' && isOpen) {
                onClose();
            }
 
            // Focus trap: keep focus within modal
            if (e.key === 'Tab' && isOpen && modalRef.current) {
                const focusableElements = modalRef.current.querySelectorAll(
                    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
                );
                const firstElement = focusableElements[0] as HTMLElement;
                const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
 
                if (e.shiftKey) {
                    // Shift+Tab: if at first element, move to last
                    if (document.activeElement === firstElement) {
                        e.preventDefault();
                        lastElement?.focus();
                    }
                } else {
                    // Tab: if at last element, move to first
                    if (document.activeElement === lastElement) {
                        e.preventDefault();
                        firstElement?.focus();
                    }
                }
            }
        };
 
        document.addEventListener('keydown', handleKeyDown);
        return () => document.removeEventListener('keydown', handleKeyDown);
    }, [isOpen, onClose]);
 
    if (!isOpen) return null;
 
    const sizeClasses = {
        sm: 'max-w-sm',
        md: 'max-w-md',
        lg: 'max-w-lg',
        xl: 'max-w-xl',
    };
 
    return (
        <>
            {/* Backdrop */}
            <div
                className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
                onClick={onClose}
                aria-hidden="true"
            />
 
            {/* Modal */}
            <div 
                className="fixed inset-0 flex items-center justify-center z-50 p-4"
                role="dialog"
                aria-modal="true"
                aria-labelledby="modal-title"
            >
                <div
                    ref={modalRef}
                    className={classNames(
                        'bg-oxford-blue rounded-lg shadow-2xl w-full',
                        'border border-neon-blue border-opacity-20',
                        'animate-slideIn',
                        sizeClasses[size]
                    )}
                    onClick={(e) => e.stopPropagation()}
                    tabIndex={-1}
                >
                    {/* Header */}
                    <div className="px-6 py-4 border-b border-neon-blue border-opacity-20 flex items-center justify-between">
                        <h2 id="modal-title" className="text-xl font-bold text-white">{title}</h2>
                        <button
                            onClick={onClose}
                            className="p-1 hover:bg-sea-green hover:bg-opacity-20 rounded transition-colors text-french-gray"
                            aria-label="Close modal"
                        >
                            ✕
                        </button>
                    </div>
 
                    {/* Content */}
                    <div className="px-6 py-4 max-h-96 overflow-y-auto">
                        {children}
                    </div>
 
                    {/* Footer */}
                    {footer && (
                        <div className="px-6 py-4 border-t border-neon-blue border-opacity-20 flex justify-end gap-3">
                            {footer}
                        </div>
                    )}
                </div>
            </div>
        </>
    );
};
 
export default Modal;