
오늘은 커스텀 Select를 구현한다면 언젠가 한 번은 직면하게 될 문제에 대해 다뤄보고자 한다.
Options는 보통 position: absolute로 설정하여 구현하는데, 이 속성으로 인해 Options의 height가 아무리 변해도 부모의 height는 변하지 않는다. 따라서, 아래와 같이 Options가 묻히는 경우가 발생한다.

어떻게 해결할 수 있을까?
간단하다. position: absolute를 버리면 된다.
Options css를 다음과 같이 설정한다.
.Options {
position: fixed;
}
이제 Select의 위치를 받아와서 Options에 설정해주기만 하면 된다.
const [optionsPosition, setOptionsPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const selectRef = useRef<HTMLDivElement | null>(null);
const updateOptionsPosition = () => {
if (selectRef.current) {
const rect = selectRef.current.getBoundingClientRect();
setOptionsPosition({
top: rect.bottom,
left: rect.left,
width: rect.width,
});
}
};
위와 같이 해주고, 적당히 ref를 설정한 뒤, select를 toggle할 때마다 updateOptionsPosition을 실행해주면 된다.
그리고, 다음과 같이 Options에 style을 입혀준다.
<div
className={styles.Options}
style={{
top: `${optionsPosition.top + 4}px`,
left: `${optionsPosition.left}px`,
width: `${optionsPosition.width}px`,
}}>
</div>

잘 나오는 모습을 볼 수 있다.
끝!
,,,이면 좋겠지만 그렇지 않다. 우리에겐 스크롤이라는 변수가 존재한다.

스크롤을 해도 Options의 위치가 변하지 않는 문제가 있다.
이를 해결하기 위해, Event listener를 설정해줘야 한다.
단순히 window.addEventListener를 한다고 해결되는 것은 아니고, 부모 중 스크롤 가능한 요소들을 찾아 모두 Event listener을 설정해야 한다.
useEffect(() => {
const scrollableElements = new Set<Element>();
const findScrollableParents = (node: Element | null) => {
if (!node) return;
const style = window.getComputedStyle(node);
const overflowY = style.overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') {
scrollableElements.add(node);
}
findScrollableParents(node.parentElement);
};
const addScrollListeners = () => {
window.addEventListener('scroll', updateOptionsPosition);
scrollableElements.forEach((element) => {
element.addEventListener('scroll', updateOptionsPosition);
});
};
const removeScrollListeners = () => {
window.removeEventListener('scroll', updateOptionsPosition);
scrollableElements.forEach((element) => {
element.removeEventListener('scroll', updateOptionsPosition);
});
};
if (isOptionOpen) {
findScrollableParents(selectRef.current);
addScrollListeners();
updateOptionsPosition();
} else {
removeScrollListeners();
}
return () => removeScrollListeners();
}, [isOptionOpen]);
이제 원하는대로 잘 동작한다.

뭔가 쓸데없이 열심히 삽질한 것 같기도 하지만, 여튼 해결하긴 했다.
(더 좋은 방법이 있다면 댓글로 알려주시면 감사하겠습니다.)

진짜 끝!