Skip to content

Add Dialog component #428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 4, 2025
Merged
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
97 changes: 97 additions & 0 deletions components/UI/Dialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from '../../styles/components/dialog.module.css';

export default function Dialog({
isOpen,
onClose,
title,
children,
size = 'medium',
showCloseButton = true
}) {
const dialogRef = useRef(null);
const [portalTarget, setPortalTarget] = useState(null);

// Set up portal target after component mounts (client-side only)
useEffect(() => {
setPortalTarget(document.body);
}, []);

// Handle escape key to close dialog
useEffect(() => {
const handleEscape = (event) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};

if (isOpen) {
document.addEventListener('keydown', handleEscape);
// Prevent body scroll when dialog is open
document.body.style.overflow = 'hidden';
}

return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);

// Handle click outside to close dialog
const handleBackdropClick = (event) => {
if (event.target === event.currentTarget) {
onClose();
}
};

if (!isOpen || !portalTarget) return null;

const dialogContent = (
<div className={styles.backdrop} onClick={handleBackdropClick}>
<div
ref={dialogRef}
className={`${styles.dialog} ${styles[size]}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "dialog-title" : undefined}
>
<div className={styles.header}>
{title && (
<h2 id="dialog-title" className={styles.title}>
{title}
</h2>
)}
{showCloseButton && (
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close dialog"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
)}
</div>
<div className={styles.content}>
{children}
</div>
</div>
</div>
);

// Use portal to render dialog at document root level
return createPortal(dialogContent, portalTarget);
}
219 changes: 219 additions & 0 deletions styles/components/dialog.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/* Backdrop */
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 1rem;
animation: fadeIn 0.2s ease-out;
/* Prevent any pointer events from bubbling through */
pointer-events: auto;
/* Ensure backdrop is above everything */
isolation: isolate;
}

/* Dialog container */
.dialog {
background: var(--card-bg);
/* border-radius: 8px; */
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
/* Prevent any layout shifts */
transform: translateZ(0);
/* Ensure dialog is above backdrop */
position: relative;
z-index: 1;
}

/* Size variants */
.small {
width: 400px;
}

.medium {
width: 600px;
}

.large {
width: 800px;
}

.xlarge {
width: 1000px;
}

/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.25rem 0 1.25rem;
border-bottom: 1px solid var(--card-border);
/* min-height: 60px; */
/* Prevent layout shifts */
flex-shrink: 0;
}

.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--card-text);
line-height: 1.5;
}

/* Close button */
.closeButton {
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
border-radius: 6px;
color: var(--card-text);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
/* Prevent button from causing layout shifts */
flex-shrink: 0;
}

.closeButton:hover {
background-color: var(--card-bg);
color: var(--card-text);
}

.closeButton:focus {
outline: 2px solid var(--accent-icon);
outline-offset: 2px;
}

/* Content area */
.content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
/* Prevent content from causing layout shifts */
min-height: 0;
}

/* Animations - optimized to prevent blinking */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95) translateZ(0);
}
to {
opacity: 1;
transform: translateY(0) scale(1) translateZ(0);
}
}

/* Responsive design */
@media (max-width: 640px) {
.backdrop {
padding: 0.5rem;
}

.dialog {
width: 100%;
max-width: 100%;
max-height: 95vh;
}

.header {
padding: 1rem 1rem 0 1rem;
min-height: 50px;
}

.content {
padding: 1rem;
}

.title {
font-size: 1.125rem;
}
}

/* Dark mode support */
@media (prefers-color-scheme: dark) {
.dialog {
background: var(--card-bg);
border: 1px solid var(--card-border);
}

.title {
color: var(--card-text);
}

.header {
border-bottom-color: var(--card-border);
}

.closeButton {
color: var(--card-text);
}

.closeButton:hover {
background-color: var(--card-bg);
color: var(--card-text);
}
}

/* Focus management */
.dialog:focus {
outline: none;
}

/* Scrollbar styling for content */
.content::-webkit-scrollbar {
width: 6px;
}

.content::-webkit-scrollbar-track {
background: var(--card-bg);
border-radius: 3px;
}

.content::-webkit-scrollbar-thumb {
background: var(--card-text);
border-radius: 3px;
}

.content::-webkit-scrollbar-thumb:hover {
background: var(--card-text);
}

/* Additional fixes to prevent blinking */
.backdrop * {
/* Prevent any child elements from causing reflows */
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}

/* Ensure dialog stays in place during animations */
.dialog {
will-change: transform, opacity;
}