Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 0 additions & 113 deletions PR-environment-variables.md

This file was deleted.

108 changes: 0 additions & 108 deletions PR-redux-toolkit-setup.md

This file was deleted.

144 changes: 144 additions & 0 deletions app/components/common/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use client';

import { useEffect, useRef, useCallback, ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg';
}

const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
};

export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);

const getFocusableElements = useCallback(() => {
if (!modalRef.current) return [];
const focusableSelectors = [
'button:not([disabled])',
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
];
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors.join(', ')));
}, []);

// Handle Escape key and focus trapping
useEffect(() => {
if (!isOpen) return;

// Save current active element to restore it later
previousActiveElement.current = document.activeElement as HTMLElement;

// Focus the first focusable element in the modal
const timer = setTimeout(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0]!.focus();
} else if (modalRef.current) {
modalRef.current.focus();
}
}, 100);

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}

if (e.key === 'Tab') {
const focusable = getFocusableElements();
if (focusable.length === 0) return;

const firstElement = focusable[0]!;
const lastElement = focusable[focusable.length - 1]!;

if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};

document.addEventListener('keydown', handleKeyDown);
// Prevent background scrolling when modal is open
document.body.style.overflow = 'hidden';

return () => {
clearTimeout(timer);
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
// Restore focus to the element that was focused before the modal opened
previousActiveElement.current?.focus();
};
}, [isOpen, onClose, getFocusableElements]);

// Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};

if (!isOpen) return null;

return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{/* Backdrop with fade transition */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300 opacity-100" />

{/* Modal content with scale transition */}
<div
ref={modalRef}
tabIndex={-1}
className={`relative w-full ${sizeClasses[size]} bg-white dark:bg-gray-800 rounded-xl shadow-2xl transform transition-all duration-300 scale-100 opacity-100 max-h-[90vh] overflow-auto`}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close modal"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>

{/* Modal title */}
{title && (
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="modal-title" className="text-xl font-semibold text-gray-900 dark:text-white">
{title}
</h2>
</div>
)}

{/* Modal content */}
<div className={`${title ? 'px-6 py-4' : 'p-6'}`}>
{children}
</div>
</div>
</div>,
document.body
);
}
3 changes: 2 additions & 1 deletion app/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Export all common components
export { default as Spinner } from './Spinner';
export { default as FullPageLoader } from './FullPageLoader';
export { default as ButtonSpinner } from './ButtonSpinner';
export { default as ButtonSpinner } from './ButtonSpinner';
export { default as Modal } from './Modal';
Loading