- 프로젝트: loplat UI
- 키워드: portal, createPortal, modal, createElement
- 상황
- 여러 프로젝트에서 Modal을 사용할 일이 많아 loplat UI에서 컴포넌트를 제공해주고자 한다.
- 렌더링부에서 container DOM과 children content만 선언하여 쉽게 react portal 기능을 사용할 수 있도록 하고자 한다.
// 원하는 형태 <Portal container={ref}> <div>children</div> </Portal> <Modal open={open}> <div>children</div> </Modal>
- 해결 과정
- 먼저 container와 chlidren을 prop으로 받아 createPortal을 실행하는 Portal 컴포넌트를 만든다.
import React from 'react'; import ReactDOM from 'react-dom'; export interface PortalProps { children: React.ReactElement; container: Element | null; } export function Portal({ children, container }: PortalProps): React.ReactPortal | null { if (!container) return null; return ReactDOM.createPortal(children, container); }
- Modal 컴포넌트는 loplat UI 자체적으로 동적 container를 만들어주는 것이 사용자 입장에서 편하다고 판단했다.
이 때, useRef를 사용하면 리렌더링이 일어나지 않아 버그가 발생할 수 있으므로 setState와 DOM API(document.getElementById)를 사용하였다.
export function Modal({ open, onClose, children }: ModalProps): React.ReactElement | null { const portalId = useMemo(() => `loplat-ui-modal__${generateUniqueId()}`, []); const [container, setContainer] = useState<Element | null>(null); useEffect(() => { // 자체적으로 container 생성 const newContainer = document.createElement('div'); newContainer.setAttribute('id', portalId); document.body.appendChild(newContainer); // trigger rerender setContainer(newContainer); // container 제거 return () => { const containerDOM = document.getElementById(portalId); containerDOM?.remove(); }; }, [portalId]) if (!open) return null; return ( <Portal container={container}> <ModalWrapper> <div className="background" onClick={onClose} /> <div className="content">{children}</div> </ModalWrapper> </Portal> ); } const ModalWrapper = styled.div` position: fixed; top: 0; bottom: 0; left: 0; right: 0; & > .background { width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.4); } & > .content { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } `;
- background click 뿐만 아니라 ESC 버튼으로도 Modal을 끌 수 있도록 코드를 추가한다.
// add eventListeners function onKeyDownESC(e: KeyboardEvent) { if (e.key === 'Escape') { onClose(); } } document.addEventListener('keydown', onKeyDownESC); return () => { document?.removeEventListener('keydown', onKeyDownESC); }
- storybook을 통해 Portal과 Modal을 사용해본다.
// Portal const Template: ComponentStory<typeof Portal> = (args: PortalProps) => { const container = useRef<HTMLDivElement>(null); const [show, setShow] = useState(false); const toggle = () => { setShow(!show); }; return ( <> <Button onClick={toggle}>Toggle Portal</Button> <Headline weight="regular">Main</Headline> <div id="parent" ref={container} /> {show && ( <Portal container={container.current}> <Headline weight="bold">Portal</Headline> </Portal> )} </> ); }; // Modal const Template: ComponentStory<typeof Modal> = (args: ModalProps) => { const [open, setOpen] = useState(false); const toggle = () => { setOpen(!open); }; const close = () => { setOpen(false); }; return ( <> <Button onClick={toggle}>Open Modal</Button> <Modal open={open} onClose={close}> <Help title="샘플 텍스트입니다." text="풀밭에 싸인 청춘을 끝에 듣기만 하여도 만물은 그들은 것이다. 이상은 소금이라 든 풀밭에 있는가?" /> </Modal> </> ); };
- 메인 div[id="root"] 에서 벗어나 body tag 아래에 새로운 div portal[id="loplat-ui-modal__..."] 이 생긴 것을 확인했다!
- 먼저 container와 chlidren을 prop으로 받아 createPortal을 실행하는 Portal 컴포넌트를 만든다.
- known issues
- 웹접근성을 위해 Modal 컴포넌트에 aria-labelledby, aria-describedby와 같은 prop을 추가하고, Modal 외부는 focus가 불가능하도록 개선해야한다.
- 웹접근성을 위해 Modal 컴포넌트에 aria-labelledby, aria-describedby와 같은 prop을 추가하고, Modal 외부는 focus가 불가능하도록 개선해야한다.
'프론트엔드 > React' 카테고리의 다른 글
스탬프 투어 미션 구현하기(feat. firestore, redux saga) (0) | 2022.03.07 |
---|---|
노래맞히기 게임 구현하기(feat. holwer.js, classList) (0) | 2022.03.06 |
Toast 컴포넌트 구현하기 (1) | 2022.01.26 |
리액트에서 달팽이 모양으로 움직이는 애니메이션 만들기 (0) | 2022.01.19 |
SVGR id collision issue 해결하기 (0) | 2021.11.30 |