React Portal ile Modal ve Tooltip Yönetimi

React Portal ile Modal ve Tooltip Yönetimi
Modal, tooltip ve dropdown gibi UI elemanları, DOM hiyerarşisinde doğru şekilde konumlandırılmadığında overflow: hidden, z-index ve stacking context sorunlarına neden olabilir. React Portal, bu elemanları DOM ağacının istediğiniz noktasına render ederek bu sorunları çözer. Bu yazıda Portal kullanarak production-ready modal ve tooltip sistemleri oluşturacağız.
Portal Temelleri
createPortal, bir React elemanını DOM'daki herhangi bir düğüme render etmenizi sağlar. Bileşen, React bileşen ağacında aynı yerde kalmaya devam eder; context ve event bubbling korunur.
import { createPortal } from 'react-dom';
function Portal({ children, containerId = 'portal-root' }) {
const container = document.getElementById(containerId);
if (!container) return null;
return createPortal(children, container);
}
Temel Modal Bileşeni
import { createPortal } from 'react-dom';
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Önceki odak elemanını kaydet
previousFocusRef.current = document.activeElement;
// Modal'a odaklan
modalRef.current?.focus();
// Body scroll'u kilitle
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
// Önceki odak elemanına geri dön
previousFocusRef.current?.focus();
};
}, [isOpen]);
// ESC tuşu ile kapatma
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
className="modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
className="modal-close-btn"
onClick={onClose}
aria-label="Kapat"
>
×
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
}
Focus Trap Uygulaması
Erişilebilirlik için modal açıkken Tab tuşunun modal içinde kalması gerekir. Bu pattern'e focus trap denir.
import { useEffect, useRef } from 'react';
function useFocusTrap(isActive) {
const containerRef = useRef(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
const focusableElements = container.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [isActive]);
return containerRef;
}
// Modal'da kullanım
function AccessibleModal({ isOpen, onClose, title, children }) {
const focusTrapRef = useFocusTrap(isOpen);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div ref={focusTrapRef} className="modal-dialog" role="dialog" aria-modal="true">
<h2>{title}</h2>
{children}
<button onClick={onClose}>Kapat</button>
</div>
</div>,
document.body
);
}
Modal Yönetim Hook'u
import { useState, useCallback } from 'react';
function useModal() {
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState(null);
const open = useCallback((modalData = null) => {
setData(modalData);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
setData(null);
}, []);
return { isOpen, data, open, close };
}
// Kullanım
function ProductList() {
const deleteModal = useModal();
const editModal = useModal();
return (
<div>
{products.map((product) => (
<div key={product.id}>
<span>{product.name}</span>
<button onClick={() => editModal.open(product)}>Düzenle</button>
<button onClick={() => deleteModal.open(product)}>Sil</button>
</div>
))}
<Modal isOpen={deleteModal.isOpen} onClose={deleteModal.close} title="Silme Onayı">
<p>{deleteModal.data?.name} silinecek. Emin misiniz?</p>
<button onClick={() => handleDelete(deleteModal.data?.id)}>Sil</button>
<button onClick={deleteModal.close}>İptal</button>
</Modal>
<Modal isOpen={editModal.isOpen} onClose={editModal.close} title="Ürün Düzenle">
<EditProductForm product={editModal.data} onSave={editModal.close} />
</Modal>
</div>
);
}
Gelişmiş Tooltip Bileşeni
import { createPortal } from 'react-dom';
import { useState, useRef, useEffect, useCallback } from 'react';
function Tooltip({ children, content, placement = 'top', delay = 200 }) {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const timeoutRef = useRef(null);
const calculatePosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const positions = {
top: {
top: rect.top + scrollY - 8,
left: rect.left + scrollX + rect.width / 2,
},
bottom: {
top: rect.bottom + scrollY + 8,
left: rect.left + scrollX + rect.width / 2,
},
left: {
top: rect.top + scrollY + rect.height / 2,
left: rect.left + scrollX - 8,
},
right: {
top: rect.top + scrollY + rect.height / 2,
left: rect.right + scrollX + 8,
},
};
setCoords(positions[placement]);
}, [placement]);
const show = () => {
timeoutRef.current = setTimeout(() => {
calculatePosition();
setIsVisible(true);
}, delay);
};
const hide = () => {
clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);
return (
<>
<span
ref={triggerRef}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{children}
</span>
{isVisible &&
createPortal(
<div
className={`tooltip tooltip-${placement}`}
style={{ position: 'absolute', top: coords.top, left: coords.left }}
role="tooltip"
>
{content}
</div>,
document.body
)}
</>
);
}
// Kullanım
<Tooltip content="Bu düğme kaydet işlemi yapar" placement="top">
<button>Kaydet</button>
</Tooltip>
Dropdown Menu ile Portal
function DropdownMenu({ trigger, items }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const toggleMenu = () => {
if (!isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
});
}
setIsOpen(!isOpen);
};
// Dışarı tıklayınca kapat
useEffect(() => {
if (!isOpen) return;
const handleClick = (e) => {
if (!triggerRef.current?.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen]);
return (
<>
<button ref={triggerRef} onClick={toggleMenu}>{trigger}</button>
{isOpen &&
createPortal(
<ul
className="dropdown-menu"
style={{ position: 'absolute', top: position.top, left: position.left }}
>
{items.map((item, i) => (
<li key={i}>
<button onClick={() => { item.onClick(); setIsOpen(false); }}>
{item.label}
</button>
</li>
))}
</ul>,
document.body
)}
</>
);
}
Animasyonlu Modal
import { CSSTransition } from 'react-transition-group';
function AnimatedModal({ isOpen, onClose, children }) {
const nodeRef = useRef(null);
return createPortal(
<CSSTransition
in={isOpen}
timeout={300}
classNames="modal"
unmountOnExit
nodeRef={nodeRef}
>
<div ref={nodeRef} className="modal-overlay" onClick={onClose}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
</CSSTransition>,
document.body
);
}
/* CSS */
/*
.modal-enter { opacity: 0; }
.modal-enter-active { opacity: 1; transition: opacity 300ms; }
.modal-exit { opacity: 1; }
.modal-exit-active { opacity: 0; transition: opacity 300ms; }
*/
useEffect cleanup fonksiyonlarını doğru şekilde kullanın.
Sonuç
React Portal, modal, tooltip, dropdown ve bildirim gibi overlay bileşenlerini DOM hiyerarşisinin dışına render ederek z-index ve overflow sorunlarını ortadan kaldırır. Focus trap, ESC ile kapatma ve ARIA attribute'ları ile erişilebilir bileşenler oluşturabilirsiniz. useModal gibi custom hook'larla modal yönetimini kolaylaştırabilir, Floating UI ile tooltip konumlandırmasını profesyonelleştirebilirsiniz. Portal'ın event bubbling davranışını anlamak, karmaşık UI senaryolarında doğru çözümler üretmenizi sağlar.