모달을 닫자.

otter·2022년 6월 29일
0
post-thumbnail

모달을 (모달 바깥영역을 클릭했을 때) 닫자.

모달과 popover 전부 닫아버리자. 프로젝트를 하다보면 무수한 모달과 팝오버들을 만나게 된다. 모달 바깥영역을 클릭할때 모달을 닫는 기능 사실 이제 익숙하다.고 생각했다. 매번 실패했다.. 그렇다. 처음부터 스택오버플로우와 구글 검색을 통해 작업했던 부분이었고 모달이 어떻게 닫히는지는 생각조차 하지 않았다..!
열림교회는 잘 닫혀있지만 아무리 여기저기 들쑤셔도 내 모달은 닫히지 않고있었다.

지금 작업하고 있는 프로젝트의 팝오버 부분인데, 오늘 저걸 꼭 (바깥 영역을 클릭했을 때) 닫아버릴 것이다.

backdrop을 이용해보자.

일단 이걸 닫으려고 생각했을때, 가장 먼저 든 생각은 backdrop를 만드는 방법이었다.
저 팝오버가 열렸을때, 화면의 veiwport를 모두 채우는 backdrop를 만들고 backdrop에 클릭이벤트를 걸어버리면 이거 되겠는데? 싶었다.

이렇게 파란색 backdrop를 만들고, backdrop에 클릭이벤트를 걸어 팝오버의 상태를 바꿔주었다.
잘된다!

// 사실 이렇게 진행안해서 코드가 오락가락 한 부분이 있다..
// 이렇게 해보고 바로 지웠다..! 이건 아닌 것 같아..
      {isOpen && (
        <div className={styles.backdrop} onClick={() => setIsOpen(false)} />
      )}
      <nav className={styles.filterNav}>
        <div className={styles.filterWrapper}>
          <div
            className={styles.filter}
            onClick={(e) => {
              setIsOpen(true);
            }}
          >
            필터
            {isOpen && <FilterPopup />}
          </div>
             ... 중략

팝오버여서, 꼭 그냥 같은 계층에 있어주고 싶어서 이렇게 해놨는데 모달이라면 portal과 같이 사용하면서 조금 더 쉽게 구현할 수 있을 것이라는 생각이 들었다.

그런데 조금 귀찮다는 생각이 들었다. 매번 backdrop를 만들어줘야하는 것도 귀찮고 backdrop의 범위를 전체로 설정해주기 위해 부모요소의 positionrelative로 바꾸어줘야 했고, 특히 모달은 아니고 팝오버와 같은 부분이라, backdrop를 같은 계층에 두고 싶었다. 그래서 같은 계층에 두니 이벤트 버블링도 일어나 해당 문제를 해결해야했다. backdrop를 저기에 뜬금없이 올려둔건 이와같은 문제가 있었기 때문이었다.

그리고 아무튼 새로운 dom요소를 만들어야 한다는 것이 부담스러웠고 무조건 dom요소를 만들어야 한다면 커스텀훅으로 한방에 하기 불편할 것 같다라는 생각도 들었다.

마지막으론, 이도 결국 z-index를 이용해서 화면단을 조작해야 하는데 z-index의 계층이 많아진다면 해결하기 쉽지 않을 것이라는 생각도 들었다. 이를 위해 backdrop는 임시로 50, popover는 100으로 설정해 두었는데 이는 간단한 프로젝트이고 단 두개의 컴포넌트니까 어렵진 않았지만.

그런데, 이는 팝오버여서 생겼던 문제였던 것 같고 모달일때는 애초에 portal을 일반적으로 사용할 것이니 괜찮을 것 같다. portal의 상위요소를 무조건 relatvie로 걸어버리고, 상위요소도 하나밖에 없으니 전체 뷰포트에 백드롭을 두기도 편하다. 상위요소엔 이벤트가 없을 터이니 이벤트 버블링이 일어나지도 않을 거고 애초에 모달만을 모아둔 root요소를 만들거니까 큰 문제는 없을 것 같다. 그리고 백드롭을 만들어서 사용하는건 어떻게 보면 가장 직관적인 방법이라는 생각도 들었고.

ref를 활용해보자.

react에서 ref는 dom을 직접 선택하고 dom을 조작할 수 있게 해준다.

const FilterPopup = React.forwardRef<HTMLDivElement>((props, ref) => {
  return (
    <div className={styles.popupWrapper} ref={ref}>
      <header>이슈 필터</header>
      <div className={styles.filterCondition}>
        열린 이슈
        <Input
          label="열린 이슈"
          info={{
            id: 'openedIssue',
            name: 'idInput',
            type: 'checkbox',
          }}
        />
      </div>
      <div className={styles.filterCondition}>
        내가 작성한 이슈
        <Input
          label="내가 작성한 이슈"
          info={{
            id: 'writtenIssue',
            name: 'idInput',
            type: 'checkbox',
          }}
        />
      </div>
      ...중략
  );
});

이렇게 만들어둔 컴포넌트에서, 상위에 있는 ref를 전달할 것이므로 꼭 React.fowardRef를 사용해야 한다. 그렇지 않으면 오류가 날 것이다. 이렇게 상위요소에서 ref를 전달하고,

const IssuesFilter = () => {

  const [isOpen, setIsOpen] = useState(false);
  const popupRef = useRef<HTMLDivElement>(null); // 3
  const closeHandler = (e) => {
    if (!popupRef.current?.contains(e.target)) {
      setIsOpen(false);
    }
  };

  // click으로 하니까 안되고, mousedown으로 하니까 됬다..?
  useEffect(() => { // 4
    document.addEventListener('click', closeHandler);
    return () => {
      document.removeEventListener('click', closeHandler);
    };
  }, []);

  return (
    <>
      <nav className={styles.filterNav}>
        <div className={styles.filterWrapper} onClick={() => setIsOpen(true)}> // 1
          <div
            className={styles.filter}
            onClick={(e) => {ropagation();
              e.stopPropagation();
              // 이벤트 버블링때문에 안된거였음.
              setIsOpen(true);
            }}
          >
            필터
            {isOpen && <FilterPopup ref={popupRef} />} // 2
          </div>

이런 방식으로 진행해준다. 그러니까,

  1. filterWrapper를 클릭하면 이 popover가 열린다.
  2. popover가 열리면 FilterPopup이 렌더링 된다.
  3. 이때, FilterPopupref를 걸어 두었으니,
    popupRef.current라는 객체가 만들어 질것이고, 이 객체는 FilterPopup의 최상위 부모요소를 가리킨다.
  4. 그리고, 이젠 거의 잊어가고 있던 contains 메서드를 활용해 자식요소를 가지고 있는지 확인하는 이벤트를 걸어준다.
  5. 이때, 클릭이벤트가 실행되면 클릭된 target과 ref의 자식요소인지 비교후 true, false를 출력하는데 위의 코드처럼 작성되면 이벤트 버블링이 일어난다.

하위에있는 요소를 클릭했을 때 -> 위에 있는 filterWrapper에 걸려있는 click 이벤트도 실행되어 버린다. 따라서 우리의 isOpen은 잠시 false였다가 true로 바뀐다. 닫히지 않는다..

이를 막기 위해, e.stopPropagation을 해도 되고 또는 document에 이벤트를 걸 때 mousedown을 활용해도 된다.

mousedown을 이용하면 왜 될까?

덧붙여 말하면, mousedown이 사용 가능한 이유는 다음과 같다.
clickmousedown -> mouseup이 합쳐진 이벤트인데, mousedown만을 걸어버리면 click은 하지 않은것이므로 click에 대한 이벤트는 실행되지 않는다. 따라서 상위 요소에는 click를 걸어놨으니까 이벤트 버블링도 일어나지 않을 것이다. (난 여기 클릭한게 아니니까!)

개인적으로, 이렇게 바꾸어주는 것도 좋은 방법이라는 생각도 들었지만 click을 사용하는 것이 조금 더 직관적일 것이라는 생각이 들어 (다른 것은 다 click인데 이것만 왜 mousedown 한거야? 이런 생각 들지 않을까? ) 싶어 이벤트 전파를 막아주는 e.stopPropagation을 사용해서 이벤트 버블링을 막았다.

커스텀훅으로 만들어 보자.

그런데 위와 같이 ref를 이용하는 방식으로 한다면, 모든 popover와 모달에 ref를 하나씩 만들어줘야 한다. isOpen, setIsOpen은 어쩔 수 없겠다 하지만서도 괜히 귀찮은데 이거 커스텀훅으로 만들 수 있지 않을까?

import React, { useState, useRef, useEffect } from 'react';

const useClose = () => {
  const [isOpen, setIsOpen] = useState(false);

  const popupRef = useRef<HTMLDivElement>(null);
	// 일반적으로 popup이나 modal은 div로 감싸져 있을 것이므로,
  
  const closeHandler = (e: MouseEvent) => {
    if (!popupRef.current?.contains(e.target as Node)) {
      setIsOpen(false);
    }
  };

  useEffect(() => {
    if (!isOpen) return;
    	// isOpen이 아닐 경우에만 이벤트 핸들러를 달도록 변경했다.
    document.addEventListener('click', closeHandler);
    return () => {
      document.removeEventListener('click', closeHandler);
    };
  }, [isOpen]);

  return {
    popupRef,
    isOpen,
    setIsOpen,
  };
};

export default useClose;

이렇게 커스텀훅을 작성해 두니, 필요할때마다 커스텀훅을 불러서 사용할 수 있었다. 이번 미션같은 경우에는 한페이지에 popover가 5개는 되는 것 같은데, 상대적으로 편하게 작업할 수 있었다.

모달만 닫으려고 했는데

react로 작성하다보면 은근 이벤트 버블링같은 거 까먹는다. 바닐라 자바스크립트로 작성할땐 매번 꼼꼼하게 여기 버블링일어나겠다 이게 딱 보였는데 이게 컴포넌트 단위로 나눠지니까 이벤트의 순서도 잘 보이지 않고..
언제나 이벤트 버블링을 견지하고 있어야겠다는 생각이 들었다.

Ref

https://white-salt.tistory.com/25
https://rrecoder.tistory.com/146

profile
http://otter-log.world 로 이사했어요!

0개의 댓글