이번에 회사에서 디자인 시스템 프로젝트를 시작하였다.
vite
, react
, typescript
, storybook
을 기반으로 npm
으로 배포(업로드 예정)
할 수 있도록 설정을 마친 후 다양한 공통 컴포넌트를 개발하며 디자인 시스템 구축하는 중이다.
컴포넌트 중에서 사용하기 간편하면서도 신경 써야 할 부분이 많은 DropDown 컴포넌트 개발 과정에 대한 기록을 공유하고자 한다.
DropDown 컴포넌트를 구현하기 위해 필요사항 리스트를 정리해 보았다.
overflow
속성에 영향을 받지 않으면서, 최상단 고정 배치body
스크롤 방지보다시피 디테일하게 신경 써야 할 부분이 엄청 많다.
이번 글에서는 “부모의 overflow
속성에 영향을 받지 않으면서, 최상단 고정 배치”를 구현을 하는 과정을 상세하게 적었다.
먼저 postion
을 사용하는 여러 가지 Style 방식을 알아보자.
콘텐츠 최상단 배치를 구현하기 전에 핵심 CSS 속성인 position
중 absolute
와 fixed
에 대해 정리를 해봤다.
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;
}
먼저 가장 기본적으로 배치하는 방법을 알아보자.
position: absolute;
는 가장 가까운 위치 지정 조상 요소에 대해 상대적으로 배치한다.
부모 요소에 position: relative;
속성을 적용하여 위치를 지정한 후 ,
이에 따라 absolute
위치 지정 메커니즘을 활용하여 top
및 left
속성을 통해 자식 요소를 정렬 및 배치했다.
위치 지정 요소 :
static
속성이 아닌 다른 속성(relative
,absolute
,sticky
,fixed
)을 가진 요소
Content{
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 999;
margin-top: 0.25rem;
}
아래 사진과 같이 overflow: hidden;
일 경우 짤리는 이슈가 있다.
단독으로 absolute
속성만 사용해서는 최상단에 배치할 수 없다.
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
속성이 viewport가 아닌 부모 기준이 된다면 문제는 쉽게 해결될 것이다.
fixed
는 transform
, 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
와 마찬가지로 가까운 위치 지정 조상 요소가 기준이 된다.
<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
활용해서 ContainerRef
의 상대 좌표 정보를 알아보자.
getBoundingClientRect
메서드는 엘리먼트의 크기와 뷰포트에 상대적인 위치 정보를 제공하는DOMRect
객체를 반환합니다.
아래 사진을 보면 원하는 위치에 배치하기 위해선 left
와 bottom
값이 필요하다.
뿐만 아니라, DOMRect
객체에는 width
와 height
와 같은 프로퍼티도 포함되어 있어서 요소의 크기 정보도 확인할 수 있다.
getBoundingClientRect().width
를 사용하면서 한가지 의문점이 있었다.
❓ offsetWidth
값이랑 뭐가 다른거지?
두 값의 차이점은 아래와 같다.
getBoundingClientRect().width
offsetWidth
렌더링된 사이즈 : 화면에 실제로 표시되는 사이즈를 나타내며, 시각적 변형이 적용된 후의 표시 사이즈이다.
레이아웃 사이즈 : 변형과 무관한 원본 요소의 사이즈를 나타낸다.
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
을 활용해 해결해 볼 것이다.
위치 지정 기준이 변경되지 않도록 하려면 <Content>
요소를 계층 구조 외부의 최상단에 배치해야 한다.
DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법은 createPortal
을 사용하는 것이다.
일반적으로는 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
함수는 주어진 id
매개변수를 사용하여 Portal 컨테이너 엘리먼트를 body
의 자식 노드 리스트 중 마지막 자식으로 생성하거나 기존 컨테이너를 반환한다.
DOM을 직접 변경하고 DOM이 다시 칠해지기 전에 효과가 동기적으로 실행되기를 원하므로,
<Portal>
컴포넌트에서는 useLayoutEffect
Hook을 사용하는 것이 더 합리적이다.
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
속성에 영향을 받지 않으면서, 최상단 고정 배치”의 기능은 잘 구현하였으나
아래와 같은 이슈가 있다.
fixed
속성으로 인해 화면에 고정된다.다음 글에서 1번 이슈를 해결하기 위해 ”body
스크롤 방지기능“을 추가할 예정이다.
많관부..
DropDown하나 만드는데 이렇게까지 해야하나 ?
아직 1단계를 처리했을 뿐인데 프론트엔드를 시작하려는 사람이 보면 벌써부터 머리가 아플지도 모른다.
하지만 이런 디테일한 과정을 원인을 파악하면서 하다 보면 재밌는 거 같다.
훈수는 환영합니다! 🙇🏻♂️
래퍼런스
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
전 이렇게 해결했습니당 드롭다운외에 다른 영역을 클릭해도 알아서 꺼지도록!
저도 위와 같은 dropdown 문제를 해결하기 위해 위와 같은 방법을 사용했었는데요. portal을 통해 dropdown을 body에 위치하게 하는 건 생각 못했네요. 너무 좋은 생각인 같아요.
scroll 문제같은 경우에는 body 외에서도 드롭다운의 부모에서도 scroll이 생길 수 있어서 부모중에 scroll이 있는 container를 찾아서 scroll event시에 위치를 조정해주는 로직을 짰습니다