이유
기존 DOM 구조 상에서는 DatePicker와 SelectBox 컴포넌트 최상위 div 하위에 팝업이 포함돼있었다.
그러나 DatePicker와 SelectBox를 다른 요소의 하위로 넣는 경우, 팝업 또한 상위 요소 범위를 벗어나지 못한다는 단점이 존재했다.
그렇기 때문에 DOM구조에서 독립적으로 분리해 팝업이 열렸을 때 최상위에서 잘림없이 보여지게 하기 위해 재구현했다.
이때 모달에 많이 사용하는 createPortal을 사용했다.
createPortal
부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링할 수 있게 한다.
createPortal(children, domNode, key?)
- children : React 자식 요소 (jsx, 문자열, fragment, 배열 등)
- domNode : DOM 엘리멘트 (children을 배치하려는 DOM 위치)
- key ?: 고유한 문자열 또는 숫자
즉, children을 domNode 안에 배치한다.
💥 portal은 DOM 노드의 물리적 배치만 변경할 뿐 여전히 이벤트 등은 React 트리를 따른다.
기본 구현
다만 createPortal을 사용할 경우 DOM 구조 상으로 부모 컴포넌트(DatePicker, SelectBox 등) 하위에 위치해있지 않기 때문에 위치를 직접 계산해줘야 한다.
⬇
getBoundingClientRect()
getBoundingClientRect 메서드는 뷰포트 기준으로 엘리멘트의 위치와 크기 등을 반환해준다.
if (pickerRef.current)
console.log(pickerRef.current.getBoundingClientRect());
1. popup 위치를 담을 변수 생성
// 달력 popup 위치
const [location, setLocation] = useState({
top: 0,
left: 0,
});
2. createPortal로 popup 감싸주기
{isOpen &&
createPortal(
<PopupBox
ref={calendarRef}
$top={location.top}
$left={location.left}
$isclose={isClose}
>
<DatepickerCalendar
startDate={date}
minDate={minDate}
maxDate={maxDate}
limitDateList={limitDateList}
selectStartDate={(date) => handleClick(date)}
/>
</PopupBox>,
document.body
)}
이때 createPortal 내부의 최상위 div에 위치 값을 넘겨준다.
또한 외부 영역 클릭 이벤트 때 사용할 ref도 바인딩해줬다.
3. 위치 갱신
useEffect(() => {
if (pickerRef.current) {
const rect = pickerRef.current.getBoundingClientRect();
setLocation({
top: rect.top + rect.height + 4,
left: rect.left + rect.width / 2,
});
}
}, [pickerRef.current]);
getBoundingClientRect 메서드를 통해 받아온 DatePickerWrapper의 위치 정보를 이용해 popup을 띄울 위치를 갱신한다.
외부 영역 클릭 시 팝업 닫기 수정
const handleClickOutside = (e: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(e.target as Node) &&
calendarRef.current &&
!calendarRef.current.contains(e.target as Node) &&
isOpenRef.current
) {
handlePopup();
}
};
캘린더 영역도 이제 DOM 구조 상 외부가 됐다.
calendarRef로 조건문에 넣어 해당하지 않는 부분만 동작하게끔 했다.
이때 useState로 정의된 isClose 변수를 사용할 경우 handleClickOutside 함수가 이전의 변수 값을 기억하고 있기 때문에 원하는 결과가 나오지 않는다.
그렇기때문에 최신 상태를 기억하고 클로저의 영향을 받지 않는 ref를 사용해 조건문을 구성했다
useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
datePicker 위치에 따라 popup 위/아래에 띄우기
💡 position 속성에서 right 보단 left를 bottom 보다 top을 우선 순위를 갖는다.
${({ $isUpper, $bottom, $top }) =>
$isUpper
? css`
bottom: ${$bottom ? `${$bottom}px` : 0};
`
: css`
top: ${$top ? `${$top}px` : 0};
`}
위치 계산
const getLocation = () => {
if (pickerRef.current) {
const rect = pickerRef.current.getBoundingClientRect();
if (window.visualViewport)
if (rect.top < window.visualViewport?.height - rect.bottom)
setLocation({
top: rect.top + rect.height + 4,
bottom: 0,
left: rect.left + rect.width / 2,
isUpper: false,
});
else
setLocation({
top: 0,
bottom:
window.visualViewport?.height - rect.bottom + rect.height + 4,
left: rect.left + rect.width / 2,
isUpper: true,
});
}
};
useEffect(() => {
window.addEventListener("resize", getLocation);
return () => window.removeEventListener("resize", getLocation);
}, []);
처음 마운트 시 window에 resize 이벤트 발생 시 getLocation 함수를 실행하도록 설정했다.
다만 resize 이벤트가 발생한 만큼 getLocation 함수가 많이 실행된다.
→ debounce로 resize 시 한 번만 함수가 실행하도록 횟수를 줄였다.
const getLocation = debounce(() => {
if (pickerRef.current && window.visualViewport) {
const rect = pickerRef.current.getBoundingClientRect();
if (rect.top <= window.visualViewport?.height - rect.bottom)
setLocation({
top: rect.top + rect.height + 4,
bottom: 0,
left: rect.left + rect.width / 2,
isUpper: false,
});
else
setLocation({
top: 0,
bottom: window.visualViewport?.height - rect.bottom + rect.height + 4,
left: rect.left + rect.width / 2,
isUpper: true,
});
}
}, 100);
스크롤 발생 시에도 위치 재계산
근데 이렇게 window에 스크롤이 있는 게 아니라 내부 요소에 scroll이 있으면 window.addEventListener("scroll", getLocation)으로는 이벤트를 잡아내지 못한다.
그러나 나는 어제 모던 자바스크립트 40장 이벤트를 완독한 사람.
addEventListener의 3번째 인자로 true를 주면 하위로 전파되는 이벤트도 캐치할 수 있다는 것을 알고 있지.
useEffect(() => {
getLocation();
window.addEventListener("resize", getLocation);
window.addEventListener("scroll", getLocation, true); // 하위 요소의 이벤트 캡처링 위해 3번째 인자로 true 전달
return () => {
window.removeEventListener("resize", getLocation);
window.removeEventListener("scroll", getLocation, true);
};
}, []);
결과
'React' 카테고리의 다른 글
[ React ] CommonTable 편집모드 (데이터 변경, 행 추가, 삭제) (0) | 2024.08.29 |
---|---|
[ React ] CommonTable 컴포넌트 기본 구현 (정렬, 체크박스, 스크롤 등) (0) | 2024.07.16 |
[ React ] SelectBox 컴포넌트 키보드 이벤트 추가 | onKeyDown (0) | 2024.07.11 |
[ React ] input 태그의 placeholder로 icon 사용하기 (0) | 2024.07.10 |
[ React ] 검색 가능한 SelectBox 컴포넌트 구현 (0) | 2024.07.04 |