html dialog 태그로 만든 Drawer에서 에러가 났을 때 토스트가 보이지 않는 상황이 있었습니다.
이유는 dialog는 최상위 계층 (Top layer)으로 열리기 때문인데요. topLayer는 페이지의 다른 모든 콘텐츠 레이어 위의 존재하는 레이어입니다. 그래서 토스트가 TopLayer에 가려져 보이지 않던 것이었습니다.
https://developer.mozilla.org/ko/docs/Glossary/Top_layer
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을 한 번도 이용해 보지 않아서 생각하기 어려웠습니다.
...
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>
);
}
포탈에 사용할 엘리먼트 아이디에 대한 타입
export type ToastContentId =
| 'toast-content'
| 'drawer-category-toast-content'
| 'drawer-alarm-toast-content';
export type DrawerToastContentId = Exclude<ToastContentId, 'toast-content'>;
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>
);
});
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