개발새발 로그

Tooltip에 Viewport 자동 위치 조정 기능 추가하기 본문

React

Tooltip에 Viewport 자동 위치 조정 기능 추가하기

이즈흐 2024. 8. 28. 15:05

📖들어가며..

이번에는 리액트로 Tooltip을 구현해보려고 한다.

 

간혹 프로젝트에서 tooltip을 사용하곤 하는데

hover일 때와 click일 때를 구분해서 사용하기도 한다.

 

click일 때는 조건으로 내부 요소에서 클릭이 가능하게 해야한다.

즉 내부 클릭시 tooltip이 닫히면 안된다.

또한 외부 클릭시 닫혀야할 것이다.

 

그리고 tooltip이 만약 viewport에 넘어가 보이지 않을 경우에 자동으로 위치를 변경해주는 기능도 추가해보려고 한다.

만약 기본 값이 "left" 일 때 viewport의 가로가 좁혀질 경우 "right"로 바뀌는 것이다.

 

나는 styled-component를 사용하고 있어서 tooltip이 보이는 상태가 간단히 구현될 것이다.

 

🛠️Tooltip 컴포넌트 구현하기

먼저 구현하기 전에 설계 조건을 나열해보려고 한다.

1. Tooltip 사용시 {children}을 사용해서 어떠한 요소도 Tooltip을 사용가능하도록 한다.
2. Tooltip에 화살표 유무를 선택할 수 있도록 한다. (기본값 : true)
3. Tooltip에 위치를 지정하도록 한다. (기본값 : top)
4. click | hover | focus 이벤트에 tooltip이 나타날 수 있도록 한다.
5. Tooltip에 text를 넣을 수도 있고, 내부에 JSX를 넣을 수도 있어야 한다.

위 조건을 토대로 구현해보려고 한다.

 


✨Tooltip 사용시 {children}을 사용해서 어떠한 요소도 Tooltip이 가능

만약 구현한다면 사용할 때 아래와 같은 방식이 될 것이다.

Tooltip 컴포넌트로 감싼 후에 자식요소는 그대로 화면에 출력되게 할 것이다.

그리고 Tooltip에 나타날 내용은 props로 내려주려고 한다.

 

🤔과연 Tooltip에 출력할 요소를 props로 내려주는 것이 옳을까?

props로 넘겨주는 방식이 자칫하면 명령형 프로그래밍이 되지 않을까? 고민이 되었다.

그래서 tooltip에 관련된 오픈소스를 확인해보았다.

react-tooltip의 사용예시

react-tooltip 라이브러리의 사용방식인데

data 어트리뷰트를 이용해서 연결하고, 아래에서 Tooltip 컴포넌트를 선언해서 출력하는 방식이었다.

data 어트리뷰트로 연결하는 것이 참신해보였다.

 

그리고 해당 라이브러리도 content 자체는 props로 내리고 있었다.

사실 저 <ReactTooltip> 부분에 children을 할 수 있도록 넣어서 더욱 선언적으로 했다면 어땠을까 생각이 든다.

 

결론은 props로 내리는 것에 대해서는 괜찮다고 개인적으로 생각했다.

만약 이 부분이 옳지 않다면 말했던 것처럼 data 어트리뷰트로 연결하고 하단에서 <ReactTooltip>처럼 선언하고 children으로 tooltip 내부 요소를 출력하는 방법을 만들어보려고 한다.

 


 ✨Tooltip에 화살표 유무를 선택할 수 있도록 한다. (기본값 : true)

먼저 아래와 같이 props로 설정할 것 같다.

그리고 true 일때 화살표 모양의 요소를 추가해주는 스타일을 넣어줘야한다.

그래서 나는 css의 border을 활용해 화살표를 만들어준다.

tooltip의 위치가 top일 경우 화살표는 아래에 위치해야 한다.

그 대신 화살표는 tooltip의 위치에 따라 달라져야 할 것이다 

왜냐하면 tooltip위치에 따라 화살표의 위치도 당연히 바뀌어야 하기 때문이다.

top일 경우 아래에 화살표


 

 

✨Tooltip에 위치를 지정하도록 한다. (기본값 : top)

위처럼 direction 값을 받아서 Tooltip의 초기 위치를 지정해주려고 한다.

 

direction값은 styled-component를 이용해서 위치를 각각 지정해준다.

top ❘ bottom ❘ left ❘ right에 따라 위치가 지정됨


click | hover | focus 이벤트에 tooltip이 나타날 수 있도록 한다.

click | hover | focus 이벤트로도 tooltip이 나와야할 것이다.

위처럼 어떤 이벤트로 tooltip이 나오는지 props로 넘겨주었다.

 

Tooltip 컴포넌트 내부에서는 props 값을 통해서 이벤트를 등록한다.

기존에 addEventListener을 이용했는데 리액트 이벤트인 on 이벤트로 리팩토링했다.

이유는 해당 포스팅에 작성했다.

 

 


tooltip의 열림과 닫힘useState 상태로 관리되게 했다.

tooltip을 trigger하는 요소를 클릭하면 해당 함수가 실행돼서 tooltip이 열린다.

 

근데 여기서 stopPropagation()을 사용해줘야 한다.

만약 stopPropagation()을 하지 않는다면 window에 등록된 이벤트가 이벤트 버블링으로 실행돼 다시 닫히게 될 것이다.

 

그리고 tooltip의 내부에도 위처럼 stopPropagation()을 실행시켜줘야 한다.

그래야 tooltip을 클릭해도 tooltip이 닫히지 않을 것이다.

 

🤔click 이벤트에서의 예외 처리

click 이벤트에서는 몇가지 예외 상황이 존재한다.

1. tooltip이 열린 이후 외부를 클릭하면 tooltip이 사라져야 한다.
2. tooltip 내부를 클릭했을 때는 tooltip이 닫히면 안된다.

위 조건을 만족하기 위해서 아래와 같이 코드를 추가했다.

window에 이벤트를 등록해서 tooltip의 상태를 닫히게 하는 콜백함수를 발생시킨다.

 

외부를 클릭하면 닫히는 이벤트 window에 등록했다.

이렇게 하게되면 주의해야 할 점이 있는데 이벤트 버블링 문제이다

 

tooltip의 클릭 이벤트에 stopPropagation추가


tooltip의 열림과 닫힘 useState 상태로 관리되게 했다.

tooltip을 trigger하는 요소를 클릭하면 해당 함수가 실행돼서 tooltip이 열린다.

 

근데 여기서 stopPropagation()을 사용해줘야 한다.

만약 stopPropagation()을 하지 않는다면 window에 등록된 이벤트가 이벤트 버블링으로 실행돼 다시 닫히게 될 것이다.

 

tooltip내부에도 stopPropagation 추가

그리고 tooltip의 내부에도 위처럼 stopPropagation()을 실행시켜줘야 한다.

그래야 tooltip을 클릭해도 tooltip이 닫히지 않을 것이다.

 


 

✨Tooltip에 text를 넣을 수도 있고, 내부에 JSX를 넣을 수도 있어야 한다.

위 코드처럼 props로 컴포넌트를 전달해 tooltip 내부에서 클릭이나 활동이 가능하게 해야 할 것이다.

 

props로 tooltip 내부 컨텐츠를 전달할 때 두 가지 타입이 가능하게 했다.

 

그러면 Tooltip 컴포넌트는 컴포넌트와 텍스트 둘 다 받을 수 있게 된다.


🤔컴포넌트를 props에 내려줄 때 타입은 무엇으로 지정해야할까?

위에서 본 것처럼 컴포넌트를 props로 내릴 때는 타입을 무엇으로 지정해야할까?

 

조사한 바에 따르면 아래와 같이 3가지 타입으로 컴포넌트를 props로 내릴 수 있다고 한다.

더 자세한 타입의 종류는 해당 링크에 나와있다.

3가지 타입에 대한 설명은 해당 링크에 자세히 나와있다.

 

결론을 간단히 요약하면 아래와 같다.

🎈ReactNode 

ReactElement의 superset입니다.

ReactNode는 ReactElement일 수 있고 ReactFragment, string, number, ReactNode의 Array, null, undefined, boolean 등의 좀 더 유연한 타입 정의라고 할 수 있습니다.

그래서 보통  어떤 props을 받을 건데, 구체적으로 어떤 타입이 올지 알 수 없거나, 어떠한 타입도 모두 받고 싶다면 ReactNode로 지정해주는 것이 좋다.

 

아래와 같은 상황에서 사용한다.

 

🎈ReactElement 

ReactElement는 type과 props를 가진 객체다.

React.createElement를 호출하면 이런 타입의 객체가 리턴됩니다.

interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> 
{ type: T; props: P; key: Key | null; }

단순하게 리액트 컴포넌트를 JSON 형태로 표현해놨다고 생각하면 됩니다. 

 

 

 

🎈JSX.Element

JSX.Element는 ReactElement의 특정 타입이라고 생각하면 된다.

JSX.Element는 props와 type이 any인 제네릭 타입을 가진 ReactElement다.

JSX는 다양한 라이브러리에서 각자의 방식으로 JSX를 구현할 수 있기 때문에 존재한다.

 

즉 JSX.Element는 React.Element의 type, props 타입을 any로 두어 React.Element보다 타입을 느슨하게 검사하는 타입이다.

 

🤔ReactElement 와 JSX.Element의 차이?

현재까지는 JSX.Element은 props와 type이 any여서 타입 추론이 불가능하지만 

ReactElement는 제네릭으로 타입을 지정할 수 있어 prosp와 type에 대한 자동완성이 지원된다. 라는 점만 이해했다.

또 다른 곳에서는 아래와 같이 설명하기도 한다.

차이가 거의 없다.
JSX.Element는 ReactElement 인터페이스를 상속받은 인터페이스이다.
내부 구조나 제약 타입이 별도로 존재하지 않아 완전히 동일하다고 봐도 무방하다.

 

 

나는 ReactElement를 사용하기로 결정했다.

내부에서 props를 사용하는 것은 아니어서 ReactElement를 사용할 필요는 없지만

그렇다고 JSX.Element를 사용할 이유도 없었다.

JSX는 다양한 라이브러리에서 각자의 방식으로JSX를 구현할 수 있기 때문에 존재한다고 하기 때문이다.

 

그래서 가장 기본적인 ReactElement 타입을 사용했다.


 

 

🛠️Tooltip 컴포넌트에 Viewport에 따라 위치 자동 조정 기능 추가하기

Tooltip이 만약 Viewport에서 벗어나 보이지 않게 된다면 어떻게 해야할까?

위 같은 문제는 빈번하게 발생할 수 있다.

 

이를 위해서는 위치가 자동으로 조정될 수 있도록 해야했다.

 

해당 기능을 만들기 위해서는 아래와 같은 조건이 필요하다.

1.현재 뷰포트의 크기를 알아야한다.
2.현재 Tooltip을 갖고있는 요소의 bottom, right 위치를 알아야한다.
  - 그래야 Tooltip의 시작 위치를 알 수 있고, heigth 또는 width를 더하면 가장 끄트머리의 위치를 알 수 있다
  - 그래서 이게 뷰포트에서 넘치면 바꿔주면 되는 것이다.
3.현재 Tooltip의 width와 height를 알아야 한다.

 

 

 

✨현재 뷰포트의 크기 구하기

나는 Context API를 이용해서 현재 Viewport의 크기를 알 수 있도록 했다.

나중에 다른 곳에서도 사용할 수 있도록 하기 위해서이다.

 

먼저 뷰포트의 크기를 알기 위해서는 resize와 scroll이벤트를 통해서 현재 뷰포트의 크기를 가져와야한다.

(resize나 scroll이벤트로 인해서 뷰포트가 변경되기 때문)

 

나는 useSyncExternalStore을 이용해서 현재 뷰포트의 값을 갖고오려고 한다.

그리고 ResizeObserver을 이용해서 resize이벤트를 활용할 때 성능저하게 되는 문제를 해결하려고 한다.

scroll 이벤트는 window에 등록하고, 추후 디바운싱이나 스로틀링을 추가하려고 한다.

 

모든 코드를 자세하게 보여주기보다 중요한 부분만 기록하려고 한다.

1. Context 만들기 & useSyncExternalStore 사용

 

먼저 뷰포트의 크기를 가져오고 저장하는 Context를 만들었다.

Rect의 타입은 아래와 같다.

 

🤔useSyncExternalStore은 뭐고, 왜 사용하는 걸까요?

useSyncExternalStore은 external store를 subscribe하는 리액트 훅.
외부 상태관리 라이브러리들이 tearing 문제에 대비하기 위해 추가된 훅이라고 보면 된다.

 

즉 외부 스토어의 상태를 React의 상태와 동기화해서 변경사항을 즉시 반영하도록 하는 것이다.

그렇게 되면 외부 상태를 안전하게 구독하고, 메모리 누수, 비동기 업데이트로 인한 문제를 방지하는데 도움을 준다.

 

✍️useSyncExternalSore 사용방법

 

그래서 나는 아래와 같은 로직을 사용했다.

간단히 설명하면 아래와 같다.

1. window에 scroll이벤트를 등록한다.
2. ResizeObserver을 통해 document.body를 감시한다.
3. 이때 변경이 일어나면 현재 Rect 값을 바꿔야한다.
4. document.scrollingElement값을 이용해 현재 Rect를 가져오는 getBoundingClientRect() 메서드를 사용하고, Rect 상태를 변경해준다.
5.변경이 일어나지 않았다면 객체 그대로를 다시 반환한다. (사용방법 이미지에서 말한 주의점)

 

그리고 해당 데이터를 가져와서 아래와 같이 활용했다.

간단하게 설명하면 아래와 같다.

1. 현재 뷰포트의 크기를 가져온다.
2. Tooltip의 trigger 요소와 Tooltip 요소의 ref를 받아온다.
3. 기존의 위치 값을 가져온다.
4. Tooltip의 trigger 요소와 Tooltip 요소의 크기를 가져온다.
자동 위치 계산 로직 설명
1. position이 'top' 또는 'bottom'인 경우 wrapperRect.bottom과 targetRect.height를 더한 값이 뷰포트의 높이(viewportRect.height)보다 작은지 확인한다.
1-1. 만약 작다면, 타겟 요소를 하단에 배치할 수 있으므로 'bottom'을 설정하고, 그렇지 않으면 'top'을 설정한다.

2. position이 'left' 또는 'right'인 경우, wrapperRect.right와 targetRect.width를 더한 값이 뷰포트의 너비(viewportRect.width)보다 작은지 확인한다.
2-1. 만약 작다면, 타겟 요소를 우측에 배치할 수 있으므로 'right'를 설정하고, 그렇지 않으면 'left'를 설정한다.

🤔왜  wrapperRect.bottomtargetRect.height 를 사용하는 건가요?

wrapperRect.bottom

wrapperRect.bottom은 래퍼 요소의 하단 경계 위치를 의미한다.

즉, 래퍼 요소의 아래쪽 끝이 뷰포트 내에서 어디에 위치하는지를 알려준다.

targetRect.height

targetRect.height는 타겟 요소의 높이를 나타낸다.

이 값은 타겟 요소가 추가로 차지할 공간을 나타낸다.

 

🤔왜 하단 경계만 생각하나요? tooltip이 위에 있을 경우에는 처리안해도 되나요?

브라우저 창이 resize 될 때 위쪽과 왼쪽은 줄여도 뷰포트에 영향이 대부분 가지않는다.

(이상하게 말했는데 그냥 위쪽과 왼쪽은 줄여도 요소가 그대로 따라가게 된다.)

 


최종적으로 아래와 같이 데이터를 가져오고, 등록해주면 된다.

기존의 direction에 자동 위치가 조정되는 viewportDirection을 그대로 넣어주면 된다.

tooltip에서 코드 수정도 최소화된다.

 

 

📷시연 GIF

1. Tooltip의 click | hover | focus 기능

2. 위치 자동 조정 기능

728x90
반응형
LIST