본문 바로가기
어?/FE

[Next.js / TypeScript] 한 페이지에 모달 두 개 띄우기 왜 안 되지?

by 껐다 켜보셨어요? 2024. 10. 4.

왜 안 되지? 보다는 구현을 어떻게 했는지 적는 글이 될 듯

 

정확히는 한 페이지에 총 세 개의 모달 트리거 요소가 있는데 ..

이 세 개가 한 번에 뜰 일은 없고 

 

1 ) 같은 버튼을 누르는데, type에 따라 다른 모달이 떠야 함 (모달 A와 모달 B, 같은 버튼이 트리거)

2 ) 모달 위에 모달이 또 떠야 함 (모달 A에 있는 버튼 누르면 그 위에 모달 C가 노출)

위 케이스로 두 개씩 띄워야 한다.

 

기능 만드느라 정신없긴 하지만 재미있는 포인트 같아서 포스팅하러 옴

 

이 화면이 만악의 근원이다

 

컴포넌트를 어떻게든 많이 돌려 쓰려고 이것저것 공통점을 잡아서 처리하다 보면

분명히 컴포넌트 하나로 쓰는 게 맞는 것 같은데 ... 따로 쓸 걸 그랬나? 싶은 순간이 몇 번 있다

모양은 같은데 case가 너무 많을 때 ........

그래서 다른 부분마다 case 수만큼 조건문 써넣어야 할 때 ....

(ESLint에서 nested evaluation(else if ... else if ... else if ... 를 하는 행위)을 허용하지 않아서 무조건 따로 써야 함) 

.... ..

근데 따로 컴포넌트를 만들었더라면 그건 그것대로

그건 그것대로 지옥이야 

그냥 저 화면이 지옥인 걸로 

 


case 1 ) 트리거를 공유하는 서로 다른 두 개의 모달

같은 컴포넌트인데 type에 따라 다른 모달이 뜨는 경우다. 

구현하기에 뭔가 복잡해보이지만? 어쨌든 모달도 트리거가 있을 뿐 컴포넌트인 건 똑같기 때문에

조건부 렌더링을 써 준다.

트리거 로직은 통일했다.

 
              <button
                type="button"
                className="bg-blue-100 text-white text-center rounded-md pt-[1vh] pb-[1vh] mt-[1vh] active:bg-blue-500"
                onClick={buttonClickHandler}
              >
                {body === 'signature' && '서명하기'}
                {body === 'contract' && '상세보기'}
              </button>

              {body === 'signature' && ( // '서명하기' 모달
                <SignatureModal
                  isOpen={isModalOpen}
                  onRequestClose={closeModal}
                />
              )}

              {body === 'contract' && ( // '상세보기' 모달 (대여자가 요청한 계약 요청서를 봄)
                <ContractRequestModal
                  isOpen={isModalOpen}
                  onRequestClose={closeModal}
                  isFromStatusMessage
                />
              )}
 
              JSX. 모달 type은 이 단계에서 분리된다
 

 

 
  const { isModalOpen, openModal, closeModal } = useStatusMessageModel();
  const buttonClickHandler = () => {
    openModal();
  };
  
  로직. 클릭 이벤트만 관리한다. 
 
    import { useState } from 'react';

    const useStatusMessageModel = () => {
      const [isModalOpen, setIsModalOpen] = useState(false); // 기본적으로 닫힘

      const openModal = () => {
        setIsModalOpen(true);
      };

      const closeModal = () => {
        setIsModalOpen(false);
      };
 
      return {
        isModalOpen,
        openModal,
        closeModal,
      };
    };

    export default useStatusMessageModel;
 
    비즈니스 로직(hook으로 분리됨). 모달의 여닫음을 관리한다. 

 

이 경우에는 props로 넘어오는 body 항목이 무슨 내용이냐에 따라 간단하게 조건부 렌더링을 해 주면 되지만,

아래 화면이라면 어떨까 

같은 컴포넌트 내에서 이미 조건부 렌더링이 되고 있는 버튼 요소들이다.

계약 프로세스에 따라서 나타나는 버튼이 다른데 

위 화면은 contact 단계이고, 계약 조건과 계약 요청이 각각 다른 모달로 나타나야 한다. 

트리거까지 똑같이 주게 되면 충돌하거나 하나의 모달로만 나타나는 등 

결과가 좋지 않게 될 것임

이런 경우에는 어쩔 수 없다

modalType이라는 변수를 새로 만들어서, 트리거 발생 시 나타나야 할 modalType을 같이 지정해 준다

이 과정에서 정확한 값 체크를 위해 ChatModalType이라는 타입을 만들었는데

export type ChatModalType =
  | 'condition'   // 계약 조건
  | 'request'  // 계약 요청
  | 'received'  // 수령 확인
  | 'paid'  // 송금
  | undefined;

이렇게 다섯 가지의 데이터만 들어올 수 있다. 오타가 나거나 하면 바로 오류로 잡힌다

 
  import { useState } from 'react';
  import { ChatModalType } from '@/types/message/chat/chatModalType';

  const useProcessButtonEventModal = () => {
    const [modalOpen, setModalOpen] = useState(false);
    const [modalType, setModalType] = useState<ChatModalType>(undefined);

    const modalTrigger = (name: ChatModalType) => {
        // 버튼 요소에 들어가는 onClick 이벤트. 여기에서 modalType 값을 받고 DOM tree에 살려둘 모달 컴포를 지정함 
      // 계약조건, 계약요청(대여자), 수령확인, 송금(대여자) 버튼 클릭 시 모달 노출
      setModalType(name);    // 모달을 먼저 지정한 후
      setModalOpen(true);     // 모달을 열어야 한다
    };

    const modalClose = () => {
      setModalOpen(false);
      setModalType(undefined);
    };

    return { modalType, modalOpen, modalClose, modalTrigger };
  };

  export default useProcessButtonEventModal;

그리고 모달 관련 액션을 이렇게 hook으로 잡아 준다. 

 
  const { modalType, modalOpen, modalClose, modalTrigger } =
    useProcessButtonEventModal();
 

모달이 노출되는 컴포넌트에서는 비즈니스 로직을 이렇게 훅 형태로만 불러와서

  {/* 계약 프로세스와 사용자 역할(파라메터 값)에 따라 노출되는 버튼 결정 */}
      <button
        type="button"
        className={buttonStyle}
        onClick={() => modalTrigger('condition')}
      >
        <Clipboard />
        <span>&nbsp;{hasContract ? '계약조건' : '반납방법'}</span>
      </button>
      { modalType === 'condition' && (    // modalType을 활용한 조건부 렌더링
        <ContractConditionModal
          isOpen={modalOpen}
          onRequestClose={modalClose}
        />
      )}
      {!isOwner &&
        process >= PROCESSTYPES.CONTACT &&
        process <= PROCESSTYPES.ACCEPTED_PACK && (
          <>
            <button
              type="button"
              className={buttonStyle}
              disabled={process >= PROCESSTYPES.REQUESTED}
              onClick={() => modalTrigger('request')}
            >
              <AddMessage />
              <span>
                &nbsp;
                {process === PROCESSTYPES.CONTACT ? '계약요청' : '요청완료'}
              </span>
            </button>
          </>
        )}
      {!isOwner &&
        process === PROCESSTYPES.CONTACT &&
        modalType === 'request' && (     // modalType을 활용한 조건부 렌더링 
          <ContractRequestModal
            isOpen={modalOpen}
            onRequestClose={modalClose}
            isFromStatusMessage={false}
          />
        )}
 

modalType을 추가로 활용해 조건부 렌더링을 해 준다. 

모달 트리거는 여전히 modalOpen 값만 사용하는데,

modalType 조건부 렌더링을 통해 애초에 모달 컴포넌트를 하나씩만 띄움으로써

모달 충돌을 막는 방식이다. 

이러면 트리거는 하나로 쭉 유지할 수 있다.


case 2 )  모달 위에 모달

화면은 좀 못생겼지만.

모달 위에 모달을 띄울 때 신경써야 할 건 css z-index 정도면 된다 (나중에 뜨는 모달이 더 위로 올라오도록)

구현하면서 문제가 있긴 했다.

두 번째 모달의 나타나기 트리거가 첫 번째 모달의 form에 달린 이벤트였기 때문인데

이 이벤트 종류가 처음에는 onFocus였다. 

form에서 거는 이벤트는 onFocus가 국룰이지만,

onFocus는 Blur되지 않는 이상 계속 .. 계속 유지되는 이벤트다. 

그러면, 모달이 닫히지 않는 문제가 생긴다

두 번째 모달이 첫 번째 모달을 완전히 덮고 있기 때문에,

사용자가 클릭으로 Blur를 시킬 수가 없기 때문이다. 

모달 닫기 버튼을 눌러도 잠깐 값이 false로 바뀌었다가 다시 true가 된다. 

계속 모달이 켜져 있는 상태다. 

그래서 onClick으로 바꾸었다. 

이걸 해결하느라 시간이 좀 걸렸다. ㅋㅋ

원래 저 달력이 dropdown으로 나타나는 컴포넌트인데 그거 뜯어서 모달로 만든다고 고생 좀 했다 꿍얼꿍얼

 
            <div className="flex mb-1">희망 대여 기간</div>
            {/* DateRangePicker */}
            <div className="flex">
              <ContractDurationInput selected={range} onSelect={setRange} />
                // range는 Date 타입의 from과 to 두 개의 변수로 이루어진 DateRange
                // setRange는 range와 useState로 묶인 setter
            </div>
 

이 컴포넌트를 로드하면

 
import { Dispatch, SetStateAction, useState } from 'react';
import { DateRange } from 'react-day-picker';

import { format } from 'date-fns';
import { IoCalendarClearOutline } from 'react-icons/io5';
import Input from '@/components/shared/Input';
import InputCalendarModal from '@/components/modal/InputCalendarModal';

type ContractDurationInputParams = {
  selected: DateRange | undefined;
  onSelect: Dispatch<SetStateAction<DateRange | undefined>>;
};

const ContractDurationInput = ({
  selected,
  onSelect,
}: ContractDurationInputParams) => {
  const [calendarModalOpen, setCalendarModalOpen] = useState(false);
  const onRequestClose = () => {
    setCalendarModalOpen(false);
  };

  return (
    <div className="flex gap-2 relative">
      <Input
        placeholder="대여 날짜"
        value={selected ? format(selected.from!, 'yyyy-MM-dd') : ''}
        width="108px"
        height="32px"
        icon={<IoCalendarClearOutline className="w-4 h-4 mb-[2px]" />}
        readOnly
        onClick={
          () => setCalendarModalOpen(true)
          // onFocus => onClick으로 변경한 이유
          // 날짜 선택 캘린더를 별도의 모달로 구현했는데
          // onFocus에서 캘린더모달을 열어놓도록 지정하면
          // Focus가 되어있는 동안에는 캘린더가 계속 열려 있게 됨(닫기 눌러도 안 닫힘)
        }
      />
      <p>~</p>
      <Input
        placeholder="반납 날짜"
        value={selected ? format(selected.to!, 'yyyy-MM-dd') : ''}
        width="108px"
        height="32px"
        icon={<IoCalendarClearOutline className="w-4 h-4 mb-[2px]" />}
        readOnly
        onClick={() => setCalendarModalOpen(true)}
      />

      <InputCalendarModal   // 모달 ~~ 
        isOpen={calendarModalOpen}
        selected={selected}
        onSelect={onSelect}
        isClose={onRequestClose}
      />
    </div>
  );
};

export default ContractDurationInput;

이렇게 나오는데

Input이 onClick을 걸어야 하는 태그고

날짜 선택 캘린더는 모달로 나오도록 추가로 만들었다

 
import { DateRange } from 'react-day-picker';
import { Dispatch, SetStateAction } from 'react';
import ReactModal from 'react-modal';
import DateRangePicker from '../shared/DateRangePicker';

type InputCalendarModalParams = {
  isOpen: boolean;
  selected: DateRange | undefined;
  onSelect: Dispatch<SetStateAction<DateRange | undefined>>;
  isClose: () => void;
};

const modalStyle: ReactModal.Styles = {
  overlay: {
    position: 'fixed',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    backgroundColor: 'rgba(0, 0, 0, 0)', // 투명한 배경
    zIndex: 2000, // ***** 첫 번째 모달보다 위에 올라오도록
  },
  content: {
    position: 'absolute',
    top: '50%', // 수직 중앙
    left: '50%', // 수평 중앙
    transform: 'translate(-50%, -50%)', // 모달을 자신의 크기만큼 위로 및 왼쪽으로 이동
    width: '350px',
    height: '430px',
    padding: '20px',
    border: '1px solid #ccc',
    background: '#fff',
    overflow: 'auto',
    borderRadius: '10px',
    outline: 'none',
  },
};

const InputCalendarModal = ({
  isOpen,
  selected,
  onSelect,
  isClose,
}: InputCalendarModalParams) => {
  return (
    <ReactModal
      isOpen={isOpen}
      onRequestClose={isClose} // 닫기 트리거를 캘린더와 똑같이 준다 
      contentLabel="request"
      ariaHideApp={false}
      style={modalStyle}
    >
      <div className="flex justify-center items-center">
        <DateRangePicker        // 캘린더 컴포넌트
          selected={selected}
          onSelect={onSelect}
          onClose={isClose} // 닫기 트리거를 모달과 똑같이 준다
        />
      </div>
    </ReactModal>
  );
};

export default InputCalendarModal;

 

댓글