All files / src/components/ui/Modal index.tsx

97.51% Statements 196/201
83.33% Branches 30/36
100% Functions 3/3
97.51% Lines 196/201

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 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 2021x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x     1x       1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 12x 7x 7x 12x 1x 1x 79x 79x 79x 79x 77x 64x 64x 64x 64x 64x 64x 45x 64x 64x 64x 64x 64x 64x 64x 64x 64x 79x 79x 79x 79x 77x 64x 77x 13x 13x 77x 77x 77x 77x 79x 79x 79x 66x 66x 66x 66x 66x 66x 66x 66x 66x 2x 1x 1x 66x 66x 66x 66x 66x 66x 66x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 79x 64x 64x 12x 12x 12x 12x 12x 12x 64x 64x 62x 62x 62x 62x 62x 62x 62x 62x 62x 64x 64x 79x 79x 79x 79x 79x 79x 79x 79x 79x 5x 5x 5x 79x 79x 79x 79x 79x 1x 1x  
'use client';
 
import React, { useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/core';
import { Icon } from '@/components/ui/icons';
import { Button } from '@/components/ui/Button';
import { ModalProps } from '@/types/ui';
 
/**
 * Traps focus within a container element
 */
function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
  if (!container) return;
 
  const focusableSelectors = [
    'button:not([disabled])',
    '[href]',
    'input:not([disabled])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ].join(', ');
 
  const focusable = container.querySelectorAll<HTMLElement>(focusableSelectors);
 
  if (focusable.length === 0) return;
 
  const first = focusable[0];
  const last = focusable[focusable.length - 1];
 
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault();
    last.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault();
    first.focus();
  }
}
 
/**
 * Modal Component
 *
 * An accessible, animated modal dialog with backdrop.
 * Supports keyboard navigation and focus trapping.
 *
 * @example
 * ```tsx
 * const [isOpen, setIsOpen] = useState(false);
 *
 * <Modal
 *   open={isOpen}
 *   onClose={() => setIsOpen(false)}
 *   title="Confirm Action"
 *   footer={
 *     <>
 *       <Button variant="ghost" onClick={() => setIsOpen(false)}>Cancel</Button>
 *       <Button variant="primary">Confirm</Button>
 *     </>
 *   }
 * >
 *   <p>Are you sure you want to proceed?</p>
 * </Modal>
 * ```
 */
export const Modal: React.FC<ModalProps> = ({
  open,
  onClose,
  title,
  size = 'md',
  showCloseButton = true,
  closeOnBackdrop = true,
  closeOnEscape = true,
  children,
  footer}) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);
 
  // Handle keyboard events (Escape and Tab for focus trap)
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    if (e.key === 'Escape' && closeOnEscape) {
      onClose();
    }
    if (e.key === 'Tab') {
      trapFocus(e, modalRef.current);
    }
  }, [closeOnEscape, onClose]);
 
  // Focus management and keyboard event handling
  useEffect(() => {
    if (!open) return;
 
    // Store current focus to restore later
    previousActiveElement.current = document.activeElement as HTMLElement;
 
    // Focus the modal
    setTimeout(() => {
      modalRef.current?.focus();
    }, 0);
 
    document.addEventListener('keydown', handleKeyDown);
 
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Restore focus when modal closes
      previousActiveElement.current?.focus();
    };
  }, [open, handleKeyDown]);
 
  // Prevent body scroll when modal is open
  useEffect(() => {
    if (open) {
      document.body.style.overflow = 'hidden';
    } else {
      document.body.style.overflow = '';
    }
 
    return () => {
      document.body.style.overflow = '';
    };
  }, [open]);
 
  if (!open) return null;
 
  const sizeStyles = {
    sm: 'max-w-md',
    md: 'max-w-lg',
    lg: 'max-w-2xl',
    xl: 'max-w-4xl',
    full: 'max-w-[95vw] max-h-[95vh]'};
 
  const handleBackdropClick = (e: React.MouseEvent) => {
    if (closeOnBackdrop && e.target === e.currentTarget) {
      onClose();
    }
  };
 
  return (
    <div
      className="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4"
      role="dialog"
      aria-modal="true"
      aria-labelledby={title ? 'modal-title' : undefined}
    >
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fadeIn"
        onClick={handleBackdropClick}
        aria-hidden="true"
      />
 
      {/* Modal Content */}
      <div
        ref={modalRef}
        tabIndex={-1}
        className={cn(
          'relative bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full animate-slideUp focus:outline-none',
          sizeStyles[size]
        )}
      >
        {/* Header */}
        {(title || showCloseButton) && (
          <div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
            {title && (
              <h2
                id="modal-title"
                className="text-xl font-semibold text-gray-900 dark:text-gray-100"
              >
                {title}
              </h2>
            )}
            {showCloseButton && (
              <Button
                onClick={onClose}
                variant="ghost"
                size="sm"
                className="ml-auto p-2"
                aria-label="Close modal"
              >
                <Icon name="close" size={20} />
              </Button>
            )}
          </div>
        )}
 
        {/* Body */}
        <div className="p-6 overflow-y-auto max-h-[calc(95vh-200px)]">
          {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>
  );
};
 
export default Modal;