최상단에 배치하기

못살겠다·2023년 8월 27일
25

DropDown

목록 보기
1/3
post-thumbnail

들어가며

이번에 회사에서 디자인 시스템 프로젝트를 시작하였다.
vite, react, typescript, storybook 을 기반으로 npm으로 배포(업로드 예정)
할 수 있도록 설정을 마친 후 다양한 공통 컴포넌트를 개발하며 디자인 시스템 구축하는 중이다.
컴포넌트 중에서 사용하기 간편하면서도 신경 써야 할 부분이 많은 DropDown 컴포넌트 개발 과정에 대한 기록을 공유하고자 한다.

DropDown 컴포넌트를 구현하기 위해 필요사항 리스트를 정리해 보았다.

  1. 부모의 overflow 속성에 영향을 받지 않으면서, 최상단 고정 배치
  2. body 스크롤 방지
  3. 콘텐츠 외부 영역 클릭 시 닫힘
  4. 콘텐츠 위치 자동 변경 (콘텐츠의 남은 공간에 따라 위치 변경)
  5. 합성 컴포넌트로 구현
  6. 마운트/언마운트 시 transition 적용

보다시피 디테일하게 신경 써야 할 부분이 엄청 많다.
이번 글에서는 “부모의 overflow 속성에 영향을 받지 않으면서, 최상단 고정 배치”를 구현을 하는 과정을 상세하게 적었다.
먼저 postion을 사용하는 여러 가지 Style 방식을 알아보자.

postion 자세히 알아보기

콘텐츠 최상단 배치를 구현하기 전에 핵심 CSS 속성인 positionabsolutefixed에 대해 정리를 해봤다.
emotion 라이브러리의 styled 형식으로 스타일을 작성했으며,
컴포넌트와 스타일 컴포넌트의 혼동을 방지하기 위해 "S-dot naming convention"을 활용했다.
구성을 간결하게 들여다보기 위해, 현재는 단일 컴포넌트 내에서 요소들을 분리하지 않고 통합적인 구조를 고려해 보았다.

import * as S from './style';

const DropDown = () => {
  const [show, setShow] = useState(false);
  return (
    <S.Container>
      <button onClick={() => setShow((prev) => !prev)}>
        🍡 탕후루 과일 추가
      </button>
      {show && (
        <S.Content>
          //...콘텐츠
        <S.Content>
      )}
    <S.Container>
  )};
      
export default DropDown;

Container의 스타일은 relative로 위치 지정을 고정했다.

Container{
  position: relative;
  display: inline-flex;
}

absolute

먼저 가장 기본적으로 배치하는 방법을 알아보자.
position: absolute;는 가장 가까운 위치 지정 조상 요소에 대해 상대적으로 배치한다.
부모 요소에 position: relative; 속성을 적용하여 위치를 지정한 후 ,
이에 따라 absolute 위치 지정 메커니즘을 활용하여 topleft 속성을 통해 자식 요소를 정렬 및 배치했다.

위치 지정 요소 : static속성이  아닌  다른 속성(relative, absolute, sticky, fixed)을 가진 요소

Content{
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  z-index: 999;
  margin-top: 0.25rem;
}

❗️이슈

아래 사진과 같이 overflow: hidden;일 경우 짤리는 이슈가 있다.
단독으로 absolute 속성만 사용해서는 최상단에 배치할 수 없다.

fixed

position: fixed;는 최상단에 배치시킬 수 있지만 브라우저의 전체 화면(viewport)을 기준으로 좌표값(top, bottom, left, right)을 사용하여 위치를 이동시킨다.
스크롤링(scrolling)되는 동안에도 지정된 자리에 고정되어 움직이지 않는 특징을 가지고 있다.

Content{
  position: fixed;
  top: 0;
  left: 0;
  z-index: 999;
  margin-top: 0.25rem;
}

❗️이슈

아래 사진과 같이 overflow: hidden;의 상단에 배치 되지만 위치와 사이즈를 원하는대로 조정하기 어렵다.

fixed와 transform

그렇다면 fixed 속성이 viewport가 아닌 부모 기준이 된다면 문제는 쉽게 해결될 것이다.
fixedtransform, perspective, filter 속성이 none 아닌  조상이 있다면 그 조상이 기준이 된다.
위 속성을 활용 하면 괜찮지 않을까? 부모에 transform: rotate(0);을 사용해서 활용 해 보았다.

고정 위치 지정은 절대 위치 지정과 비슷하지만, fixed는 요소의 컨테이닝 블록이 뷰포트의 초기 컨테이닝 블록이라는 점에서 다릅니다(transform, perspective, filter 속성이 none이 아닌  조상이 있다면 그 조상이 컨테이닝 블록이 됩니다. - MDN Web Docs

Container{
  position: relative;
  display: inline-flex;
  transform: rotate(0); /* 추가 */
}
Content{
  position: fixed;
  top: 100%;
  left: 0;
  width: 100%;
  z-index: 999;
  margin-top: 0.25rem;
}

❗️이슈

결과는 안타깝게도 absolute와 똑같다.
이는 fixed 위치 설정에서의 문제로 "fixed 깨지는 이슈" 로 유명한 현상이다.
여기서 배울 점은 하위 엘리먼트에서 fixed를 사용할 경우 createPortal을 활용하면 예상치 못한 이슈를 방지할 수 있다.

fixed와 absolute

fixed의 좌표값을 설정하지 않을 경우 absolute와 마찬가지로 가까운 위치 지정 조상 요소가 기준이 된다.
<Layer>를 추가 후 absolute속성을 활용해 수정하면 아래와 같은 코드가 된다.

{show && (
  <S.Layer>
    <S.Content>
      //...콘텐츠
    </S.Content>
  </S.Layer>
)}
Layer {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  z-index: 999;
  margin-top: 0.25rem;
}
Content {
  position: fixed;
}

❗️이슈

원하는 대로 overflow: hidden;의 상단에 배치 되면서 absolute의 부모를 기준으로 잘 위치하게 되었지만,
<Content>의 크기 또는 좌표값이 viewport가 기준이 되어 있어 위치나 크기 수정이 쉽지 않다.

위 결과만 봤을 때 최상단 고정 배치를 해결 한 것 같지만, 이 방법으로는
그럼에도 불구하고 이 방법이 언젠가는 필요할 수 있으며, fixed 속성을 더 깊게 이해하고 fixed속성의 예기치 못할 이슈에 유연하게 대처할 수 있는 방법이다.

최상단 고정 배치

위 글을 보았듯이 fixed속성을 원하는 위치에 배치시키는 것은 쉬운 일이 아니다.

먼저 "탕후루 과일 추가" 버튼의 상대 좌표값이 필요하다.
우선 좌표값을 가져 올 <Container>엘리먼트에 useRef를 추가한 후 <Content>fixed 스타일을 적용했다.

const DropDown = () => {
  const [show, setShow] = useState(false);
  const ContainerRef = useRef<HTMLDivElement>(null); //ref추가

  return (
    <S.Container ref={ContainerRef}>
      <button onClick={() => setShow((prev) => !prev)}>🍡 탕후루 과일 추가</button>
      {show && (
        <S.Content css={ContentPostion}>
			//...콘텐츠
        </S.Content>
      )}
    </S.Container>
  );
};
Content{
  position: fixed;
  margin-top: 0.25rem;
}

상대좌표 사용하기

getBoundingClientRect()

이제 getBoundingClientRect 활용해서 ContainerRef의 상대 좌표 정보를 알아보자.

getBoundingClientRect 메서드는 엘리먼트의 크기와 뷰포트에 상대적인 위치 정보를 제공하는 DOMRect 객체를 반환합니다.

아래 사진을 보면 원하는 위치에 배치하기 위해선 leftbottom값이 필요하다.
뿐만 아니라, DOMRect 객체에는 widthheight와 같은 프로퍼티도 포함되어 있어서 요소의 크기 정보도 확인할 수 있다.

offsetWidth와 차이점

getBoundingClientRect().width를 사용하면서 한가지 의문점이 있었다.
offsetWidth값이랑 뭐가 다른거지?
두 값의 차이점은 아래와 같다.

getBoundingClientRect().width

  • 렌더링된 너비를 Return
  • 소수점까지 반환

offsetWidth

  • 레이아웃 너비를 Return
  • 반올림된 정수값을 반환

렌더링된 사이즈 : 화면에 실제로 표시되는 사이즈를 나타내며, 시각적 변형이 적용된 후의 표시 사이즈이다.
레이아웃 사이즈 : 변형과 무관한 원본 요소의 사이즈를 나타낸다.

적용하기

DropDown에서는 소수점 까지 렌더링 된 너비로 정확하게 사용하고 싶기 때문에 getBoundingClientRect().width를 사용하기로 결정했다. 코드는 아래와 같다.

  const [show, setShow] = useState(false);
  const ContainerRef = useRef<HTMLDivElement>(null);

  const getContainerRect = () => {
    if (!ContainerRef.current) return;
    
    const { left, bottom, width } = ContainerRef.current.getBoundingClientRect();

    return {
      left,
      top: bottom,
      minWidth: width,
    };
  };

  return (
    <S.Container ref={ContainerRef}>
      <button onClick={() => setShow((prev) => !prev)}>🍡 탕후루 과일 추가</button>
      {show && (
        <S.Content style={getContainerRect()}>
			//...콘텐츠
        </S.Content>
      )}
    </S.Container>
  );
};

fixed가 적용된<Content>의 위치가 원하는 곳에 잘 배치됐으며, overflow: hidden;의 상단에 배치 되는 것도 확인할 수 있다.

❗️이슈

완성인 줄 알았으나 조상에 transform, perspective, filter 속성이 존재할 경우, 요소의 위치 지정 기준이 변경된다. 이러한 이슈를 portal을 활용해 해결해 볼 것이다.

Portals

위치 지정 기준이 변경되지 않도록 하려면 <Content> 요소를 계층 구조 외부의 최상단에 배치해야 한다.
DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법은 createPortal을 사용하는 것이다.

동적인 Portal컴포넌트

일반적으로는 portal을 구현할 때, tree의 부모 컴포넌트를 정적으로, 기존의 최상단 요소인 "root"의 형제 관계로 미리 설정하는 것이 보편적이다.
그러나 NPM으로 배포할 경우에는 이러한 설정을 사전에 추가할 수 없기 때문에, 동적인 방식으로 해당 요소를 추가하도록 구현했다.

//Portal.tsx
interface PortalProps {
  children: ReactNode;
  id: string;
}

function createContainer(id: string) {
  if (document.getElementById(id)) return document.getElementById(id) as HTMLDivElement;
  else {
    const newElement = document.createElement('div');
    newElement.setAttribute('id', id);
    document.body.appendChild(newElement);
    return newElement;
  }
}

const Portal = ({ children, id = 'portal' }: PortalProps) => {
  const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);

  useLayoutEffect(() => {
    setContainerElement(createContainer(id));
    return () => {
      createContainer(id)?.remove();
    };
  }, [id]);

  return containerElement ? createPortal(children, containerElement) : null;
};

export default Portal;

createContainer 함수

createContainer함수는 주어진 id매개변수를 사용하여 Portal 컨테이너 엘리먼트를 body의 자식 노드 리스트 중 마지막 자식으로 생성하거나 기존 컨테이너를 반환한다.

Portal 컴포넌트

DOM을 직접 변경하고 DOM이 다시 칠해지기 전에 효과가 동기적으로 실행되기를 원하므로,
<Portal>컴포넌트에서는 useLayoutEffect Hook을 사용하는 것이 더 합리적이다.

useEffect와 useLayoutEffect의 차이

  • useEffect는 컴포넌트들이 render 와 paint 된 후 실행된다. 비동기적(asynchronous)
  • useLayoutEffect는 컴포넌트들이 render 된 후 실행되며, 그 이후에 paint 가 된다. 동기적(synchronous)

클린업 함수

기존 컨테이너를를 찾을 수 없는 경우 DOM을 직접 변경하고 본문에 빈 div를 추가하기 때문에,
<Portal>컴포넌트가 마운트 해제될 때 동적으로 추가된 빈 div를 DOM에서 제거한다.

적용하기

const DropDown = () => {
  const [show, setShow] = useState(false);
  const ContainerRef = useRef<HTMLDivElement>(null);

  const getContainerRect = () => {
    if (!ContainerRef.current) return;
    const { left, bottom, width } = ContainerRef.current.getBoundingClientRect();

    return {
      left,
      top: bottom,
      minWidth: width,
    };
  };

  return (
    <S.Container ref={ContainerRef}>
      <button onClick={() => setShow((prev) => !prev)}>🍡 탕후루 과일 추가</button>
      {show && (
        <Portal id='dropdown'> //추가
          <S.Content style={getContainerRect()}>
			//...콘텐츠
          </S.Content>
        </Portal>
      )}
    </S.Container>
  );
};

export default DropDown;

결과

<div id="dropdown">body의 계층 구조의 하단에 생성되면서 콘텐츠가 최상단에 잘 위치하게 되었다.

“부모의 overflow 속성에 영향을 받지 않으면서, 최상단 고정 배치”의 기능은 잘 구현하였으나
아래와 같은 이슈가 있다.

❗️ 이슈

  1. 화면 스크롤이나 viewport 크기 변경 시 fixed 속성으로 인해 화면에 고정된다.
  2. 콘텐츠가 절대적으로 아래쪽에 배치되므로 화면 하단에 배치할 경우 일부가 잘려 보인다.

다음 글에서 1번 이슈를 해결하기 위해 ”body 스크롤 방지기능“을 추가할 예정이다.
많관부..

마치며

DropDown하나 만드는데 이렇게까지 해야하나 ?
아직 1단계를 처리했을 뿐인데 프론트엔드를 시작하려는 사람이 보면 벌써부터 머리가 아플지도 모른다.
하지만 이런 디테일한 과정을 원인을 파악하면서 하다 보면 재밌는 거 같다.
훈수는 환영합니다! 🙇🏻‍♂️

래퍼런스

4개의 댓글

comment-user-thumbnail
2023년 8월 31일

저도 위와 같은 dropdown 문제를 해결하기 위해 위와 같은 방법을 사용했었는데요. portal을 통해 dropdown을 body에 위치하게 하는 건 생각 못했네요. 너무 좋은 생각인 같아요.
scroll 문제같은 경우에는 body 외에서도 드롭다운의 부모에서도 scroll이 생길 수 있어서 부모중에 scroll이 있는 container를 찾아서 scroll event시에 위치를 조정해주는 로직을 짰습니다

1개의 답글
comment-user-thumbnail
2023년 9월 5일

https://velog.io/@jmyoon8/%EB%93%9C%EB%A1%AD%EB%8B%A4%EC%9A%B4-%EB%A7%8C%EB%93%A4%EA%B8%B0
전 이렇게 해결했습니당 드롭다운외에 다른 영역을 클릭해도 알아서 꺼지도록!

1개의 답글