Dialog 태그 위로 토스트 보이도록 하기 (feat.TopLayer, createPortal)

보투게더·2023년 11월 9일
0

문제가 되는 상황

html dialog 태그로 만든 Drawer에서 에러가 났을 때 토스트가 보이지 않는 상황이 있었습니다.

이유는 dialog는 최상위 계층 (Top layer)으로 열리기 때문인데요. topLayer는 페이지의 다른 모든 콘텐츠 레이어 위의 존재하는 레이어입니다. 그래서 토스트가 TopLayer에 가려져 보이지 않던 것이었습니다.

https://developer.mozilla.org/ko/docs/Glossary/Top_layer

예시 상황(codeSandBox)

https://codesandbox.io/p/sandbox/shadcn-ui-toast-dialog-issue-vp9dl6?file=%2FREADME.md


해결 방법

z-index를 10000으로 줘보기도 하고 position:fixed, absolute 등 다양한 방법을 시도했지만 전혀 통하지 않았습니다. 어떻게 해야 하나 고민을 하고 저와 비슷한 문제를 겪는 사람을 찾으러 구글 검색을 했습니다.

그리고 비슷한 문제를 겪는 사람들을 찾아서 힌트를 얻었습니다.

https://github.com/shadcn-ui/ui/issues/75

제가 선택한 해결 방법은 createPortal을 이용해서 다이어로그 내부에서 생성되도록 하는 것입니다. 어찌 생각하면 단순한데 Portal을 한 번도 이용해 보지 않아서 생각하기 어려웠습니다.

원하는 동작

  1. 평소에는 일반적으로 토스트가 생성된다.
  2. Dialog를 열었을 때는 Dialog 내부에 토스트가 생성된다.
  3. Dialog를 닫았을 때는 다시 일반적으로 토스트가 생성된다.

구현 방법

  1. 전역으로 관리하는 ToastProvider에 createPortal 코드를 이용합니다.
  2. html에 div를 새로 생성해 주었습니다. (생략해도 무방함)
  3. Dialog 컴포넌트 내부에서 토스트가 생길 곳을 지정할 엘리먼트 아이디를 타입으로 생성합니다.
  4. Dialog 안에 엘리먼트 아이디를 받아서 지정한 div 태그를 생성합니다.
  5. Dialog를 열고 닫을 때 토스트 생성할 엘리먼트 아이디를 useContext를 사용하여 전역으로 변경합니다.
    5-1. Dialog를 열을 때 내부에 있는 id로 토스트 생성할 id를 바꿔줍니다.
    5-2. Dialog를 닫을 때 일반적으로 토스트가 생성되는 id로 바꿔줍니다.

ToastProvider.tsx

...

export default function ToastProvider({ children }: PropsWithChildren) {
  const [toastList, setToastList] = useState<ToastInfo[]>([]);
  const [toastElementId, setToastElementId] = useState<ToastContentId>('toast-content');
  const toastContentEl = document.getElementById(toastElementId);
  
  const setElementId = useCallback((id: ToastContentId) => {
    setToastElementId(id);
  }, []);

 ...

  return (
    <ToastContext.Provider value={{ addMessage, setElementId }}>
      {toastContentEl && createPortal(<ToastContainer toastList={toastList} />, toastContentEl)} // << 이 부분에서 createPortal을 이용해 생성하는 위치를 변경합니다.
      {children}
    </ToastContext.Provider>
  );
}

types/toast.ts

포탈에 사용할 엘리먼트 아이디에 대한 타입

export type ToastContentId =
  | 'toast-content'
  | 'drawer-category-toast-content'
  | 'drawer-alarm-toast-content';

export type DrawerToastContentId = Exclude<ToastContentId, 'toast-content'>;

Drawer.tsx

interface DrawerProps extends PropsWithChildren {
  handleDrawerClose: () => void;
  width: string;
  placement: 'left' | 'right';
  toastContentId: DrawerToastContentId; // 생성할 아이디를 받습니다.
}

const ARIA_MESSAGE =
  '사용자 정보 및 카테고리 정보가 있는 사이드바가 열렸습니다. 사이드바 닫기 버튼을 누르거나 ESC를 누르면 닫을 수 있습니다.';

export default forwardRef(function Drawer(
  { handleDrawerClose, width, placement, toastContentId, children }: DrawerProps,
  ref: ForwardedRef<HTMLDialogElement>
) {
  const handleCloseClick = (event: MouseEvent<HTMLDialogElement>) => {
    const modalBoundary = event.currentTarget.getBoundingClientRect();

    if (
      modalBoundary.left > event.clientX ||
      modalBoundary.right < event.clientX ||
      modalBoundary.top > event.clientY ||
      modalBoundary.bottom < event.clientY
    ) {
      handleDrawerClose();
    }
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDialogElement>) => {
    if (event.currentTarget.open && event.key === 'Escape') {
      event.preventDefault();

      handleDrawerClose();
    }
  };

  return (
    <S.Dialog
      tabIndex={1}
      aria-label={ARIA_MESSAGE}
      aria-modal={true}
      ref={ref}
      $placement={placement}
      $width={width}
      onKeyDown={handleKeyDown}
      onClose={handleCloseClick}
      onClick={handleCloseClick}
    >
      <S.ToastWrapper id={toastContentId} $placement={placement} /> // << 생성할 아이디를 가진 div를 선언해 줍니다.
      <S.CloseButton onClick={handleDrawerClose}>사이드바 닫기버튼</S.CloseButton>
      {children}
    </S.Dialog>
  );
});

useDrawer.tsx

import { useContext, useEffect, useRef } from 'react';

import { DrawerToastContentId } from '@type/toast';

import { ToastContext } from './context/toast';

export const useDrawer = (placement: 'left' | 'right', toastElementId: DrawerToastContentId) => {
  const drawerRef = useRef<HTMLDialogElement>(null);
  const { setElementId } = useContext(ToastContext);

  const openDrawer = () => {
    if (!drawerRef.current) return;

    setElementId(toastElementId); // << 열을 때 토스트 생성 위치를 Dialog 내부로 바꿔줍니다.
    drawerRef.current.showModal();
    drawerRef.current.style.transform = 'translateX(0)';
  };

  const closeDrawer = () => {
    if (!drawerRef.current) return;

    drawerRef.current.style.transform =
      placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)';
    setElementId('toast-content'); // << 닫을 때 평소에 사용되는 토스트 생성 위치로 변경합니다.

    setTimeout(() => {
      if (!drawerRef.current) return;

      drawerRef.current.close();
    }, 300);
  };

  useEffect(() => {
    if (!drawerRef.current) return;

    drawerRef.current.style.transform =
      placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)';
  }, [placement]);

  return { drawerRef, openDrawer, closeDrawer };
};

해결된 상황

참고자료

https://github.com/shadcn-ui/ui/issues/75 (힌트를 얻은 사이트)
https://react-ko.dev/reference/react-dom/createPortal (리엑트 공식문서)
https://developer.mozilla.org/ko/docs/Glossary/Top_layer

profile
Fun from Choice! 오늘도 즐거운 한 표

0개의 댓글