[21/07/19] 업체 탐색 설명 모달 리팩토링

드랍다운 리스트

드랍다운 리스트 포지셔닝 변경

return (
    <>
      {type === 'quantity' ? (
        <Wrapper focus={focus[type]} onClick={onClickFilterItem}>
          {current}
          {focus[type] && (
            <DropDownList>
              {list.map((l) => (
                <DropDownItem key={l} onClick={(e) => onClick({ e, value: l })}>
                  {l}
                </DropDownItem>
              ))}
            </DropDownList>
          )}
          <Icon icon="Arrow" size={10} />
        </Wrapper>
      ) : (
        <Wrapper focus={focus[type]} onClick={onClickFilterItem}>
          {current}

          {focus[type] && (
            <DropDownList>
              {type === 'hashtags' && list && '*중복 선택 가능'}
              {list ? (
                list.map((l) => (
                  <DropDownItem
                    key={l.name}
                    onClick={(e) => onClick({ e, value: l })}
                  >
                    {l.name}
                  </DropDownItem>
                ))
              ) : (
                <None select={select}></None>
              )}
            </DropDownList>
          )}
          <Icon icon="Arrow" size={10} />
        </Wrapper>
      )}
    </>
  );

매우 가독성이 떨어지는 코드이다. DropDownListFilterItem의 자식컴포넌트로 들어가있는 모습을 확인할 수 있는데, 이는 드랍다운의 위치를 잡아주기위하여 그렇게 한것이다.
하지만 이렇게 자식으로 들어가게되면 FilterItemoverflow-x:scroll을 추가해줘야하는 경우 드랍다운이 밑으로 가려지게됨.

그래서 DropDownListFilterItem의 자식이 아닌 형제로 두기로 결정함. 이렇게 되면

위 빨간 사각형을 컨테이너 블록으로 인식하여 위치가 제대로 잡히지 않게되는데, 이를 제대로 구현하기위해 우선 getBoundingClientRect 메소드를 이용하기로 하였다.

 const onClickFilterItem = (e) => {
    const { x, y } = e.currentTarget.getBoundingClientRect();
    setPosition({ x, y });
    setFocus((prev) => {
      Object.keys(prev).forEach((key) => {
        if (key !== type) prev[key] = false;
      });
      return {
        ...prev,
        [type]: !prev[type],
      };
    });
  };

getClientBoundingRect는 뷰포트로부터 x,y 좌표값을 구해준다. position이 뷰 포트기준이라면 top:y,left:x 값을 줌으로서 위치를 기존과 같이 FilterItem 바로 밑에 드랍다운이 켜지도록 할 수 있다.

하지만 그렇게 하기위해서 우선 컨테이너 블록을 뷰포트로 인식하게 해야한다. 지금의 코드는

///Filter
position:relative;

///DropDownItem(Filter의 직계자식)
position:absolute;

이기 때문에 컨테이너 블록을 뷰포트가 아닌 Filter로 인식하게된다. 따라서 뷰포트로 부터의 좌표값을 구해도 Filter의 뷰포트로부터 좌표를 빼준다음 top,left 프로퍼티에 값을 넣어줘야하는 복잡함이 생김.

그렇기에 이 쓸모없는 구조를 바꾸고자 뷰포트를 부모로 인식하는 position:fixed를 사용하고자 하였다. DropDownListposition:fixed를 주었지만 제대로 동작하지않았다. 그 이유는 다음과 같다

단, 요소의 조상 중 하나가 transform, perspective, filter 속성 중 어느 하나라도 none이 아니라면 (CSS Transforms 명세 참조) 뷰포트 대신 그 조상을 컨테이닝 블록으로 삼습니다.

const Wrapper = styled.div`
// ExplorePick Component 
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
//...

Filter의 바로위 조상 ExplorePick 컴포넌트가 transform 속성을 사용고 있었기 때문. 뷰포트 대신 ExplorePick을 컨테이너 블록으로 삼아버림. 위 코드는 모달을 정 중앙에 위치하게 하기위한 코드인데 이 코드를 다음과 같이 바꾸었다.

// ExplorePick Component
const Wrapper = styled.div`
  position: fixed;
// auto auto를 하게되면 위아래좌우가 같은 비율로 마진값을 갖게됨.
  margin: auto auto;

// 위치를 0 0 0 0 으로 잡고
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;

// 명시된 너비와 높이값을 갖고있다면
// 정중앙에 오게된다.
  width: 80%;
  height: 307px;
  min-width: 266px;
  min-height: 307px;

이렇게 하면 transform 속성없이 모달 내부의 content를 정중앙에 배치할 수 있고, 앞선 DropDownList도 컨테이너 블록을 뷰포트로 삼고 배치할수있게된다. 다음은 완성된 FilterItem이다.

// ...
// FilterItem
function FilterItem({ select, current, list, onClick, type, focus, setFocus }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const onClickFilterItem = (e) => {
    
    // 뷰포트로 부터 좌표를 구한후
    const { x, y } = e.currentTarget.getBoundingClientRect();
    // 이를 상태에 집어넣는다.
    setPosition({ x, y });
    setFocus((prev) => {
      Object.keys(prev).forEach((key) => {
        if (key !== type) prev[key] = false;
      });
      return {
        ...prev,
        [type]: !prev[type],
      };
    });
  };

  return (
    <>
      <Wrapper focus={focus[type]} onClick={onClickFilterItem}>
        {current}
        <Icon icon="Arrow" size={10} />
      </Wrapper>
      {focus[type] && (
        // 그 상태값을 켜지는 드랍다운리스트에 전달해준다.
        <DropDownList
          position={position}
          list={list}
          onClick={onClick}
          select={select}
          type={type}
        ></DropDownList>
      )}
    </>
  );
}

드랍다운 리스트 분리하기(리팩토링)

// 종류나 해시태그 필터를 선택하였을때 품목이나 종류가 선택되지 않았으면
// 보여주는 컴포넌트
function None({ select }) {
  
  // useRef는 재 렌더링 시 새롭게 계산한다.
  const needType = useRef(select.item ? '종류' : '품목');
  return (
    <NoneWrapper>
      <NoneText>
        {needType.current}을 먼저 선택해야 해시태그를 선택할 수 있어요!
      </NoneText>
      <Button
        variant="primary"
        fontWeight="400"
        borderRadius="10px"
        width="70px"
        height="38px"
        fontSize={'14px'}
      >
        {needType.current} 선택
      </Button>
    </NoneWrapper>
  );
}
None.propTypes = {
  select: PropTypes.object,
};

function DropDownList({ position, list, onClick, type, select }) {
  
  // list가 없다면 None컴포넌트를 보여준다. DropDown 컴포넌트는 위치를 잡게 해주는 컴포넌트
  if (!list) {
    return (
      <DropDown top={position.y} left={position.x}>
        <None select={select}></None>
      </DropDown>
    );
  }
  return (
    // list도 있는 경우 다음의 DropDownList를 렌더링한다.
    
    <DropDown top={position.y} left={position.x}>
      {type === 'hashtags' && list && <span>*중복 선택 가능</span>}
      {list.map((l) => (
        <DropDownItem key={l} onClick={(e) => onClick({ e, value: l })}>
          {type === 'quantity' ? l : l.name}
        </DropDownItem>
      ))}
    </DropDown>
  );
}
DropDownList.propTypes = {
  position: PropTypes.object,
  list: PropTypes.array,
  onClick: PropTypes.func,
  type: PropTypes.string,
  select: PropTypes.object,
};

export default DropDownList;

드랍다운 리스트 외부를 클릭하면 꺼지게 하기

우선 드랍다운리스트는 필터아래 필터아이템컴포넌트에 속해있다. 즉, 필터아래 직계 자식이다. 외부를 클릭하면 꺼지게 하는 기능은 이벤트 버블링을 이용하여 구현하였다.

// Filter 컴포넌트
  useEffect(() => {
    // outside click => off dropdownlist
    const pageClickEvent = (e) => {
      if (filterRef.current !== null && !filterRef.current.contains(e.target)) {
        setFocus({
          item: false,
          kind: false,
          hashtags: false,
          quantity: false,
        });
      }
    };
    if (Object.values(focus).includes(true)) {
      window.addEventListener('click', pageClickEvent);
      return () => {
        window.removeEventListener('click', pageClickEvent);
      };
    }
  }, [focus, filterRef]);

이벤트는 타겟에서 시작하여 window까지 버블링되어 진행된다. 클릭하여 focus[type]true, 즉 드랍다운리스트가 켜지게되면 window에 클릭 이벤트 핸들러를 걸어준다. 이 핸들러는 내가 누른 요소가 Filter컴포넌트의 자식인지 아닌지를 체크한다.
Filter 컴포넌트의 외부를 누르면 Filter컴포넌트의 자식이 아니게 되므로 focus의 모든 프로퍼티 값을 false로 바꾸어 드랍다운 리스트가 꺼지게한다.

처음에는 window에 이벤트 핸들러를 걸어줄 생각을 못하고 ExplorePick에 이벤트 핸들러를 걸어주어 위 로직을 수행하려고 하였다. 하지만 이렇게 되면 focus 드랍다운을 켜주는 상태값을 ExplorePick에서 관리해주어야하는데, 이는 컴포넌트의 의미 상 맞지 않고 아토믹 디자인 패턴에도 어긋나게된다.(확장성에 좋지 않다고 판단하였음)

그래서 ExplorePick에 핸들러를 주기보단 window에 핸들러를 걸어 처리하도록하였다.


위 디자인처럼 style을 추가해주기 위해 span으로 감싼 뒤 다음의 선택자로 스타일링

 & > span:first-child + ${DropDownItem} {
    /* span이 첫번째 자식인 경우 그 DropDownItem은 다음 스타일을 입힌다.*/

    border-top: 1px solid ${(props) => props.theme.whiteColor_3};
  }

굳이 span으로 감싸지 않아도 스타일링을 할 수는 있으나, 태그로 감싸는게 명확해서

좋은 웹페이지 즐겨찾기