본문 바로가기

프론트엔드/React

선언형 Portal, Modal 컴포넌트 구현하기

  • 프로젝트: loplat UI
  • 키워드: portal, createPortal, modal, createElement
  • 상황
    1. 여러 프로젝트에서 Modal을 사용할 일이 많아 loplat UI에서 컴포넌트를 제공해주고자 한다.
    2. 렌더링부에서 container DOM과 children content만 선언하여 쉽게 react portal 기능을 사용할 수 있도록 하고자 한다.
        // 원하는 형태
      <Portal container={ref}>
        <div>children</div>
      </Portal>
      
      <Modal open={open}>
        <div>children</div>
      </Modal>
  • 해결 과정
    1. 먼저 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);
      }
    2. 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%);
        }
      `;
    3. background click 뿐만 아니라 ESC 버튼으로도 Modal을 끌 수 있도록 코드를 추가한다.
        // add eventListeners
      function onKeyDownESC(e: KeyboardEvent) {
        if (e.key === 'Escape') {
          onClose();
        }
      }
      document.addEventListener('keydown', onKeyDownESC);
      
      return () => {
        document?.removeEventListener('keydown', onKeyDownESC);
      }​
    4. 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>
          </>
        );
      };
    5. 메인 div[id="root"] 에서 벗어나 body tag 아래에 새로운 div portal[id="loplat-ui-modal__..."] 이 생긴 것을 확인했다!
      loplat-ui-modal div

 

  • known issues
    1. 웹접근성을 위해 Modal 컴포넌트에 aria-labelledby, aria-describedby와 같은 prop을 추가하고, Modal 외부는 focus가 불가능하도록 개선해야한다.