본문 바로가기
개발/사이드 프로젝트

모달 컴포넌트 설계하기

by Dahna 2022. 5. 17.

지난번에 설정했던 컴포넌트 개발 규칙에 맞춰 모달 컴포넌트를 설계해 보려고 한다.

수립한 Action item은 다음과 같다.

[Action Item]

1. 컴포넌트를 설계할 때 이 컴포넌트가 어떻게 사용되어야 하는지 먼저 정의한다.

2. UI 요소와 데이터 기반 요소를 정리하고 분리하여 각각의 컴포넌트로 구성한다.

3. 컴포넌트의 의도를 정의하고 이를 검증할 수 있는 테스트 케이스를 작성한다.

4. 테스트 케이스를 통과할 수 있는 컴포넌트를 작성한다.

5. 컴포넌트를 리팩터링 하며 정리한다.

한 항목씩 진행해보자!

 

[어떻게 사용되어야 하는가?]

먼저 모달 컴포넌트는 스크린 위에 레이아웃을 씌운 것처럼 보여야 한다. 구현 방식에는 여러 가지가 있는데 css를 이용할 수도 있고, react DOM 트리에 모달을 마운트 할 root를 신규로 생성하여 상위에 보이도록 할 수도 있다.

모달은 스타일과 액션, 내부 컨텐츠 영역으로 구성된다. 내부 컨텐츠 영역은 데이터 기반 요소이고, 이를 담을 컨테이너 역할을 하는 모달 컴포넌트와 모달을 컨트롤하는 액션이 공통적으로 재사용하는 UI 요소로 분리될 수 있다.

 

[공통으로 재사용하는 UI 요소로서의 모달 컴포넌트를 정의해보자]

모달 컴포넌트의 종류는 일반 모달과 컨펌 모달로 정의했다.

일반 모달은 일반적으로 사용하는 on/off의 모달이고 컨펌 모달은 사용자의 확인을 묻는 모달로 사용자의 응답을 기다리고 전달할 수 있어야 한다.(비동기) 모달의 종류에 따른 컨트롤은 모달 핸들러에 위임한다.

모달 컴포넌트의 구성 요소는 모달을 구현하기 위한 스타일이 입혀진 컴포넌트들과 모달에 필요한 액션이 있다.

일반 모달은 state기반으로 on/off 상태를 관리한다. 부모에서 isOpen 상태를 props로 받아와 열림/닫힘을 실행한다.

 

일반 모달 - state 기반 on/off 모달

 - props: isOpen, children, handleClose
 - return: modal component

 

컨펌 모달은 메시지를 보여주고 사용자에게 응답을 기다린다. promise를 반환하는 함수를 핸들러로 제공한다. confirm이라는 핸들러 함수가 실행되면 전달받은 인자를 기반으로 컨펌 모달을 생성한 후 모달을 마운트 한다. 컨펌 모달의 데이터 요소는 메시지와 옵션을 핸들러 함수를 통해 전달받는 방식으로 구현하려고 한다.

 

컨펌 모달 - 핸들러 함수를 통해 마운트, 언마운트

- props: message, option
- return: Promise<boolean>

 

모달의 스타일은 styled-component에 위임한다. 모달 백그라운드, 모달 컨테이너, 헤더, 바디, 푸터, 기본 닫기 버튼이 필요하다.

스타일 컴포넌트 - Modal.Background, Modal.Container, Modal.Header, Modal.Body, Modal.Footer, Modal.close

 

모달은 UI상 레이아웃 상위에 위치해야 한다. 이를 구현하기 위해 DOM 트리 내에 모달 루트 래퍼 div를 만들고, 이곳에 모달을 마운트 한다. 이를 구현하기 위해서 react-dom에서는 createPortal이라는 함수를 통해 원하는 위치에 자유롭게 컴포넌트를 마운트 할 수 있는 기능을 제공한다. 이를 제어하는 portal 함수를 모달 헬퍼 함수로서 작성한다.

 

portal - 마운트 할 컴포넌트를 받아 DOM 트리 바깥에 지정된 rootId 요소에 컴포넌트를 마운트함

- props: children, rootId
- return: component

 

[무엇을 테스트할 것인가?]

무엇을 테스트할 것인지 정하는 것은 TDD가 처음이라 어렵지만 모달 컴포넌트에서 테스트할 부분은 props가 잘 받아와 졌는지와 props를 기반으로 모달이 잘 렌더링 되고 있는지, 그리고 액션이 잘 동작하는지가 있을 것 같다.

일반 모달의 경우 children이 잘 렌더링 되는지, 모달이 화면에 마운트 되는지, 닫기 버튼 클릭 시 handleClose액션이 호출되며 모달이 정상적으로 언마운트되는지 확인이 필요하다.

컨펌 모달의 경우 message, option에 따라 렌더링 되고 있는지, 핸들러 함수 호출 시 모달이 마운트 되는지, 사용자 응답이 정상적으로 전달되는지, 사용자 응답이 완료되면 정상적으로 언마운트 되는지 테스트하려고 한다.

 

일반 모달

- props isOpen 상태에 맞게 모달이 마운트/언마운트 되는지
- children이 화면에 렌더링되는지
- 닫기 버튼 클릭시 handleClose 액션이 호출되며 모달 요소가 화면에서 사라지는지

컨펌 모달

- props message, option에 맞게 렌더링되는지
- 핸들러 함수 호출시 모달 마운트 되는지
- 사용자 응답이 정상적으로 전달되는지
- 사용자 응답이 완료되면 정상적으로 언마운트 되는지

 

[테스트 케이스 작성]

정리한 내용을 바탕으로 테스트 케이스를 작성했다. 일반 모달은 부모로부터 isOpen 상태를 전달받기 때문에 테스트 내에서 상태를 관리하지 않고 rerender 함수를 이용하여 props를 변경해준다. 컴포넌트 사용 시 handleClose 함수가 부모의 isOpen 상태를 변경한다.

// components/modal/default/index.test.jsx

import { render, fireEvent, screen } from "@testing-library/react";
import { Modal } from "./Modal";

describe("일반 모달 컴포넌트", () => {
  it("일반 모달 컴포넌트는 children을 렌더링하고, 닫기 버튼 클릭시 unmount되며 handleClose이 실행된다.", () => {
    const handleClose = jest.fn();

    const { getByText, getByRole, rerender } = render(
      <Modal isOpen={true} handleClose={handleClose}>
        <div>test</div>
      </Modal>
    );
    const modal = screen.queryByRole("modal");
    expect(modal).toBeInTheDocument();
    expect(getByText("test")).toBeTruthy();

    fireEvent.click(getByRole("button", { name: "close" }));

    expect(handleClose).toHaveBeenCalledTimes(1);
    rerender(<Modal isOpen={false} handleClose={handleClose} />); // update props of a rendered component
    expect(screen.queryByRole("modal")).not.toBeInTheDocument();
  });
});

 

컨펌 모달은 함수 실행을 통해 모달을 활성화한다. 리턴 받은 버튼별 응답 값을 확인하는 부분은 추가가 필요하다..!

// components/modal/confirm/index.test.jsx

import { fireEvent, screen } from '@testing-library/react'
import { confirm } from './index'

describe('컨펌 모달 컴포넌트', () => {
  it('컨펌 모달 컴포넌트는 message를 렌더링하고, confirm 함수 호출시 마운트되며, 확인 버튼을 누르면 true를 반환한 후 언마운트 된다.', async () => {
    let userResponse = confirm('ok?')

    const okButton = await screen.findByRole('button', { name: '확인' })
    expect(okButton).toBeInTheDocument()
    fireEvent.click(okButton)
    expect(okButton).not.toBeInTheDocument()
    // TODO: 버튼별 응답 확인하기
  })
})

 

[모달 컴포넌트 작성]

테스트 케이스를 만족하는 모달 컴포넌트를 작성한다.

먼저 컴포넌트를 rootId 요소에 합성하는 portal 함수를 작성한다. rootId를 검색하여 요소가 없으면 새로 생성한 후 DOM에 추가하고, 루트 요소에 전달받은 컴포넌트를 합성한다. 또한 포탈 컴포넌트가 언마운트 될 때 클린업 시켜주어야 한다.

소스코드는 이 링크를 참고하여 작성하였다.

// components/modal/portal.tsx

import { useLayoutEffect, useState } from 'react'
import { createPortal } from 'react-dom'

type Props = {
  children: React.ReactChild
  rootId: string
}
export const defaultModalRootId = 'modal-root'
export const confirmModalRootId = 'confirm-modal-root'

export const createRootContainer = (rootId: string): HTMLElement => {
  const rootMount = document.createElement('div')
  rootMount.setAttribute('id', rootId)
  document.body.appendChild(rootMount)
  return rootMount
}
export const Portal: React.FC<Props> = ({ children, rootId }: Props) => {
  const [rootElement, setRootElement] = useState<HTMLElement | null>(null)

  useLayoutEffect(() => {
    let root = document.getElementById(rootId)
    let systemCreated = false
    if (!root) {
      systemCreated = true
      root = createRootContainer(rootId)
    }
    setRootElement(root)

    return () => {
      if (systemCreated && root?.parentNode) {
        root.parentNode.removeChild(root)
      }
    }
  }, [rootId])

  if (rootElement === null) return null
  return createPortal(children, rootElement)
}

 

[일반 모달] 테스트 케이스를 만족하는 최소 모달 컴포넌트를 작성했다. role로 modal을 가진 div 요소를 작성하고, 포탈 컴포넌트에 전달한다. 기본 닫기 버튼을 작성했다.

// components/modal/default/Modal.tsx

import React from 'react'
import { Portal, defaultModalRootId } from '../portal'

export interface ModalProps {
  isOpen: boolean
  children?: React.ReactChild
  handleClose: Function
}

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  children,
  handleClose,
}) => {
  if (!isOpen) return null

  return (
    <Portal rootId={defaultModalRootId}>
      <div role="modal">
        {children}
        <button
          className="float-right"
          name="close"
          onClick={() => handleClose()}
        >
          close
        </button>
      </div>
    </Portal>
  )
}

 

[컨펌 모달] 테스트 케이스를 만족하는 최소 컨펌 모달을 작성했다. 컨펌 모달은 프라미스를 반환하고 컴포넌트 제어권을 가진 헬퍼 함수를 제공하는 index파일과 ConfirmModal 파일로 구성했다. 헬퍼 함수 confirm은 컨펌 모달의 메시지와 옵션을 전달받으며 함수가 실행되면 모달을 마운트 할 루트 div 요소를 생성하고, 요소를 React 엘리먼트를 렌더링 할 수 있는 root로 감싼다. 컨펌 모달 컴포넌트를 리액트 요소로 생성하고, 전달받은 메시지와 옵션을 props로 전달한다. 이렇게 생성한 컨펌 모달 컴포넌트를 미리 만들어둔 포탈 컴포넌트에 합성하고, 루트에 렌더링 한다. react 18 버전 기준으로 작성했다. 참고 코드는 여기

// components/modal/confirm/index.ts

import React from 'react'
import { createRoot } from 'react-dom/client'
import { ConfirmModal } from './ConfirmModal'
import { Portal, confirmModalRootId, createRootContainer } from '../portal'

// https://github.com/serrexlabs/react-confirm-box

type ClassNames = {
  container?: string
  buttons?: string
  confirmButton?: string
  cancelButton?: string
}

export type Options = {
  labels?: { confirmable: string; cancellable: string }
  classNames?: ClassNames
  closeOnOverlayClick?: boolean
  render?: (
    message: string,
    onConfirm: () => void,
    onCancel: () => void,
  ) => Element
  children?: React.ReactNode
}

export const confirm = async (
  message: string,
  options?: Options,
): Promise<boolean> => {
  // 마운트 될 요소를 찾고, 없으면 생성
  let mount = document.getElementById(confirmModalRootId)
  if (!mount) {
    mount = createRootContainer(confirmModalRootId)
  }

  if (mount) {
    const root = createRoot(mount)
    return new Promise((resolve) => {
      const ConfirmModalEl = React.createElement(ConfirmModal, {
        resolver: (response) => {
          resolve(response)
          root.unmount()
        },
        message,
        options,
      })
      // 포탈에 컨펌모달을 합성
      const PortalEl = React.createElement(
        Portal,
        { rootId: confirmModalRootId, children: ConfirmModalEl },
        ConfirmModalEl,
      )

      root.render(PortalEl)
    })
  }
  return new Promise(() => {})
}

컨펌 모달의 몸체가 되는 컴포넌트를 작성해준다.

// components/modal/confirm/ConfirmModal.tsx

import React, { useState } from 'react'
import { XIcon } from '@heroicons/react/outline'
import { Options } from '.'

type ModalProps = {
  message: string
  resolver: (decision: boolean) => void
  options?: Options
}

export const ConfirmModal: React.FC<ModalProps> = ({
  resolver,
  message,
  options,
}) => {
  const [isOpen, setIsOpen] = useState(true)

  const onConfirm = () => {
    setIsOpen(false)
    resolver(true)
  }
  const onCancel = () => {
    setIsOpen(false)
    resolver(false)
  }

  return isOpen ? (
    <>
      <div role="confirm-modal">
        <h3>{message}</h3>
        <button className="float-right" onClick={onCancel}>
          <XIcon className="h-6 w-6" />
        </button>
      </div>
      <div>{options?.children}</div>
      <div>
        <button type="button" onClick={onCancel}>
          취소
        </button>
        <button type="button" onClick={onConfirm}>
          확인
        </button>
      </div>
    </>
  ) : null
}

이렇게 테스트를 만족하는 기본 컴포넌트들을 모두 작성했다.

 

[리팩터링]

이제 styled-component를 작성하여 모달에 스타일을 적용해주면 된다.

// components/modal/style.tsx

import { LayoutProps } from '@/type'
import styled from 'styled-components'
import tw from 'twin.macro'
import { XIcon } from '@heroicons/react/outline'

export const Modal = ({ children }: LayoutProps) => {
  return <div>{children}</div>
}

const ModalBackground = styled.div`
  ${tw`fixed inset-0 z-[21] flex items-center justify-center overflow-y-auto overflow-x-hidden outline-none focus:outline-none`}
`
interface ContentProps {
  size?: 'small' | 'medium' | 'large'
}
const ModalContainer = styled.div<ContentProps>`
  ${tw`relative my-6    bg-white rounded-lg`}
  ${(props) =>
    props.size === 'small'
      ? tw`w-auto w-10/12 sm:max-w-[50%] lg:max-w-[30%]`
      : tw`mx-auto sm:my-8 w-11/12 sm:max-w-screen-lg`}
`
const ModalContent = styled.div`
  ${tw`relative flex w-full flex-col rounded-lg border-0 bg-white shadow-xl outline-none focus:outline-none`}
`
const ModalHeader = styled.div`
  ${tw`border-gray-200 p-5`}
`
const ModalTitle = styled.h3`
  ${tw`text-lg font-medium leading-6 text-gray-900`}
`
const ModalBody = styled.div`
  ${tw`relative flex-auto p-5 sm:p-6 sm:pb-4 text-sm text-gray-600`}
`
const ModalFooter = styled.div`
  ${tw`flex items-center justify-end rounded-b  py-3 px-5`}
`
const ModalBackArea = styled.div`
  ${tw`fixed inset-0 z-[20] bg-black opacity-25`}
`
const ModalCloseButton = styled.button`
  ${tw`absolute top-4 right-4 hover:border-2 hover:border-blue-300 hover:rounded`}
`
interface CloseButtonProps {
  className?: string
  name?: string
  onClick: () => void
}

const CloseButton = ({ className, name, onClick }: CloseButtonProps) => {
  return (
    <ModalCloseButton
      aria-label="close"
      className={className}
      name={name}
      onClick={onClick}
    >
      <XIcon className="h-6 w-6" />
    </ModalCloseButton>
  )
}
Modal.Background = ModalBackground
Modal.Container = ModalContainer
Modal.Content = ModalContent
Modal.Header = ModalHeader
Modal.Title = ModalTitle
Modal.Body = ModalBody
Modal.Footer = ModalFooter
Modal.BackArea = ModalBackArea
Modal.CloseButton = CloseButton

모달의 닫기 버튼을 아이콘으로 수정했기 때문에 테스트도 수정해준다.

// components/modal/default/index.test.jsx

...
// 버튼을 찾는 부분을 수정해준다.
 fireEvent.click(getByRole('button', { name: /close/i }))
...

다시 돌아와서 모달 컴포넌트에 styled-component를 적용해준다.

// components/modal/default/Modal.tsx

...
import { Modal as StyledModal } from '../style'

...

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  children,
  handleClose,
}) => {
  if (!isOpen) return null

  return (
    <Portal rootId={defaultModalRootId}>
      <StyledModal>
        <StyledModal.Background role="modal">
          <StyledModal.Container>
            <StyledModal.Header>
              <StyledModal.CloseButton
                name="close"
                onClick={() => handleClose()}
              />
            </StyledModal.Header>
            <StyledModal.Body>{children}</StyledModal.Body>
          </StyledModal.Container>
        </StyledModal.Background>
        <StyledModal.BackArea />
      </StyledModal>
    </Portal>
  )
}

컨펌 모달도 작성해준다.

//components/modal/confirm/ConfirmModal.tsx

...
import { Modal } from '../style'
import { Button } from '@/components'
...

  return isOpen ? (
    <Modal>
      <Modal.Background role="confirm-modal">
        <Modal.Container size="small">
          <Modal.Header>
            <Modal.Title>{message}</Modal.Title>
            <Modal.CloseButton onClick={() => onCancel()} />
          </Modal.Header>
          <Modal.Body>{options?.children}</Modal.Body>
          <Modal.Footer>
            <Button type="button" onClick={onCancel} color="red">
              취소
            </Button>
            <Button type="button" onClick={onConfirm} color="blue">
              확인
            </Button>
          </Modal.Footer>
        </Modal.Container>
      </Modal.Background>
      <Modal.BackArea />
    </Modal>
  ) : null

모든 코드를 작성해주면 각 파일의 경로는 이렇게 된다.

- \ components
    - \ buttons
        -...
    - \ modals
        -\ confirm
            - ConfirmModal.tsx // 컨펌 모달의 몸체
            - index.test.jsx 
            - index.ts // 컨펌 모달 헬퍼 함수
        -\ default
            - index.test.jsx
            - Modal.tsx
        - index.ts // 파일 components에서 접근가능하도록 export
        - portal.tsx
        - style.tsx

 

[작성 후기]

이렇게 컴포넌트에서 공통 UI 요소를 먼저 정의하고, 컴포넌트를 이루는 요소들의 용도를 고려하며 개발을 진행해 보았다. 하면서 부족한 점을 많이 느꼈고 사실 액션 아이템을 순차적으로 진행하다가 다시 뒤로 돌아가면서 여러 번 오가는 과정을 경험했다. 좀 더 숙련돼서 순차적으로 개발할 수 있으면 좋겠다. 적용해보니 결과적으로 모듈별로 용도가 분리되고 서로 의도에 맞게 의존성을 가지게 된 것 같아서 재사용과 유지보수가 편리할 것 같다. 또한 TDD를 처음으로 경험해봤는데 브라우저를 열지 않고도 개발을 할 수 있어서 좋았고, 다른 부분(예를 들면 스타일)을 수정할 때 기존의 코드가 정상 작동하는지 확인이 가능해서 무척 편리했다. 이렇게 도메인과 UI 요소를 분리하는 연습을 해보니 전체적으로 줄일 수 있는 모듈의 개수가 많다는 것을 깨달았다. 앞으로 공부해서 채워 넣을 부분이 많지만 열심히 해봐야겠다는 생각이 들었다!

전체 코드는 여기에서! 프로젝트 소스코드 내 도메인과 모달이 붙어있는 파일들은 정리할 예정!

댓글