İçeriğe geç
react

React Portal ile Modal ve Tooltip Yönetimi

Tarık Tunç
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"
          >
            &times;
          </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
  );
}
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>
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
        )}
    </>
  );
}
İpucu: Production projelerinde tooltip ve popover konumlandırması için Floating UI (eski adıyla Popper.js) kütüphanesini kullanmayı düşünün. Viewport sınırlarını, scroll'u ve resize'ı otomatik olarak yönetir.

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; }
*/
Uyarı: Portal kullanırken memory leak'lere dikkat edin. Bileşen unmount olduğunda event listener'ları ve timeout'ları temizlemeyi unutmayın. 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.

Kaynaklar

İlgili Yazılar