본문 바로가기

개발/React

쉽고 유연하게 Tooltip 컴포넌트 만들기 (floating-ui를 활용한)

벌써 신뢰가 가는 메인 화면이다.

 

개요

 

프로덕트를 개발하다 보면 특정 요소나 새로운 기능에 대해 UX를 해치지 않는 선에서 부연 설명이 필요한 경우가 있습니다. 이러한 경우를 위해 Tooltip이라는 UI 구성요소를 자주 사용하는데, 오늘은 이러한 Tooltip을 라이브러리를 활용하여 어떻게 빠르고 유연하게 만들 수 있는지 알아보겠습니다.

접근

 

먼저 직접 구현하는 것도 방법이지만 보다 잘 만들어진 라이브러리를 활용하는 것도 좋겠다는 생각을 했고 이를 활용하면서도 여러 방면으로 유연한 컴포넌트를 설계하는 것을 목표로 검색을 시작했습니다.

 

일단 잘 만들어진 라이브러리에서는 어떤 방식으로 만들었는지 참고해 보기로 하고 개인적으로 유연하게 잘 만든 UI 라이브러리로 생각하고 있는 Radix-ui는 어떻게 만들었는지 살펴보았습니다.

 

Radix-ui/React-Tooltip의 import 부분

 

실제 Tooltip 컴포넌트에서 Tooltip의 핸들링에 대해서는 radix-ui에서 직접 만든 react-popper를 활용하는 것으로 보였습니다. 바로 react-popper로 넘어가 보겠습니다.

 

Radix-ui/React-Popper의 import 부분

 

 import 해서 사용하는 부분을 먼저 들여다보니 이름부터 Tooltip 컴포넌트의 코어를 담당할 것만 같은 이름을 가진 속성들을 가지고 오는 부분이 보입니다. 바로 floating-ui입니다. 실제 해당 컴포넌트의 구현부를 보면 Tooltip을 만들 때 가장 고민하는 Positioning에 대한 부분을 담당하는 로직에는 floating-ui로부터 가지고 온 인터페이스를 사용하는 것을 확인하여 저희가 만들 Tooltip 컴포넌트에 해당 라이브러리를 사용해 보는 것을 고려해보기로 했습니다.

 

 그렇다면 라이브러리를 선정하는 가장 중요한 요소 중 하나인 weekly download와 Last Publish를 확인해 보기로 했습니다. 실제 어느 정도의 weekly download 수와 가까운 시일의 Last Publish가 있어야 해당 라이브러리의 생태계가 활발하다는 반증이고 혹시 이슈가 있을 때에도 빠르게 대응이 가능하겠다는 믿음이 있어서였습니다.

 

Radix-ui에서 사용 중인 @floating-ui/react-dom을 먼저 살펴보았습니다.

 

훌륭한 라이브러리임의 반증

 

압도적으로 높은 다운로드 수와 3일 전의 Last Publish Date를 가지고 있는 것을 보니 훌륭한 라이브러리임이 수치로 증명되었습니다. 재미있는 점은 Google에 검색해 보면 국내 사용자들이 활용한 글은 거의 보이지 않았다는 점입니다. 그럼에도 불구하고 높은 다운로드 수를 가지고 있는 점에서 흥미가 생겨 해당 라이브러리를 둘러보기로 했습니다.

 

공식 문서에서는 React와 함께 사용하는 경우에는 @floating-ui/react를 사용하는 것을 안내해주고 있었고, 어떠한 인터렉션도 제공하지 않는 경량화 형태가 Radix에서 사용하고 있는 @floating-ui/react-dom이었습니다.(https://www.npmjs.com/package/@floating-ui/react)

 

이 역시도 weekly download 100만 이상이며, @floating-ui/react를 사용하는 경우 기본적인 Positioning 이외에도 hover나 focus와 같은 인터렉션에 대한 추가 핸들러를 제공해주고 있었습니다. @floating-ui/react-dom에서는 제공해주지 않는 Tooltip 사용 시 이와 결합하여 위치를 잡을 수 있는 화살표 UI를 제공해주고 있어 @floating-ui/react를 사용하기로 했습니다. 실제로 npm 내 둘의 Unpacked Size 차이는 655kB vs 47.9 kB로 @floating-ui/react-dom 이 훨씬 작기 때문에 저희와 같은 니즈가 없다면 @floating-ui/react-dom을 사용하시는 걸 추천드립니다.

 

실제 Radix-ui에서도 확인했다시피 해당 라이브러리를 활용하는 것 중 가장 마음에 드는 부분은 Headless 하다는 점이었습니다. 해당 라이브러리가 실제 UI까지 제공하기보다는 UI를 구성할 수 있는 요소들을 상황에 맞게 제공해 줌으로써 사용자가 자신의 니즈에 맞게 유연한 컴포넌트를 설계할 수 있다는 점에서 좋았기 때문에 이를 활용해 보기로 했습니다.

 

사용방식

 

공식 문서 : React | Floating UI

 

공식 문서에는 활용 예시도 잘 나와있고 내용도 잘 정리되어 있습니다. 먼저 기본적인 사용 방법을 확인해 보겠습니다.

 

import { useFloating } from '@floating-ui/react'

function App() {
  const {refs, floatingStyles} = useFloating();

  return (
    <>
      <button ref={refs.setReference}>Button</button>
      <div ref={refs.setFloating} style={floatingStyles}>
        Tooltip
      </div>
    </>
  );
}

 

기본적으로 툴팁의 Positioning을 지정해 주기 위해서는 floating-ui에서 제공해 준 useFloating hook을 사용하면 됩니다.

이를 사용하면 반환받는 기본 요소인 refsfloatingStyles에 대해 알아보겠습니다.

 

  • refs : refs는 여러 가지 값을 가지고 있지만 그중 기본적인 요소만 확인해 보겠습니다.
    • setReference : 툴팁의 위치를 잡기 위한 대상을 지정하는 ref입니다.
    • setFloating : 실제 툴팁으로 사용할 컴포넌트를 지정하는 ref입니다.
  • floatingStyles : 지정된 툴팁의 위치를 잡기위한 대상과 지정된 툴팁으로 사용할 컴포넌트를 가지고 실제 그려질 위치에 대한 style을 계산하여 반환해 줍니다.

 

위에서 소개한 요소들은 기본적인 부분들이고 실제로 더 디테일하게 툴팁에 대한 스타일을 지정할 수 있는 옵션들도 있습니다.

먼저 이를 파악하기 가장 용이한 실제 인터페이스를 확인해 보겠습니다.

 

useFloating({
  // options
});

interface UseFloatingOptions {
  placement?: Placement;
  strategy?: 'fixed' | 'absolute';
  transform?: boolean;
  middleware?: Array<Middleware | undefined | null | false>;
  open?: boolean;
  onOpenChange?(
    open: boolean,
    event?: Event,
    reason?: OpenChangeReason,
  ): void;
  elements?: {
    reference?: ReferenceElement | null;
    floating?: FloatingElement | null;
  };
  whileElementsMounted?(
    reference: ReferenceElement,
    floating: FloatingElement,
    update: () => void,
  ): () => void;
  nodeId?: string;
}

 

위 내용을 하나씩 살펴보겠습니다.

 

  • placement : 대상 ref를 기준으로 어느 위치에 플로팅 대상을 위치시킬 것인지에 대한 값을 넘겨줍니다. (ex. top, bottom, top-start, bottom-end)
  • strategy : 툴팁을 ‘absolute속성으로 위치시킬 것인지fixed속성으로 위치시킬 것인지를 지정합니다. 이를 통해 지정한 특정 요소의 근처와 같이 해당 Viewport 내에서 부모위치를 따라가도록 지정할 것인지, 지정한 특정 요소를 기준으로 위치를 계산할 뿐 Viewport 내에서 고정된 위치를 가질 수 있도록 지정할 것인지를 결정합니다.
  • transform : 해당 위치를 잡기 위해 사용하는 CSS Propertytop, left와 같은 Layout 속성을 사용할지 transform을 사용할지에 대한 요소입니다. 이를 사용하면 성능이 더 뛰어나지만 다른 Transform Animation과 충돌을 일으킬 수 있어 주의해야 합니다. transform 옵션을 사용하지 않는 경우에서는 요소의 width를 고정하거나 max-width를 주어야 크기가 마음대로 조정되지 않는다고 합니다.
  • middleware : floating의 대상 요소 위치를 세밀하게 제어하려는 경우 미들웨어를 사용합니다. 해당 옵션에 요소를 추가하는 경우 내부적으로 계산이 추가되어 floatingStyles에 반영됩니다. middleware에 들어갈 수 있는 항목을 그룹별로 살펴보고 넘어가겠습니다.

    • 위치 조정 관련
      • offset : 플로팅 기준 요소와와 플로팅 대상 컴포넌트 사이의 간격을 조정할 수 있도록 합니다.
      • inline : 플로팅 기준 요소와 플로팅 대상 컴포넌트를 동일한 박스모델이 아닌 별개의 박스모델 내에서 계산될 수 있도록 합니다. 자세한 차이는 공식 문서에 이미지로 나와있어 쉽게 이해할 수 있습니다. (https://floating-ui.com/docs/inline)
    • 가시성에 따른 최적화
      • shift : Viewport에서 가려지는 위치로 스크롤이 되더라도 플로팅 요소의 가려지는 범위만큼 자동으로 이동시켜 주어 Viewport에서 플로팅 대상이 전부 보일 수 있도록 해줍니다.
      • flip : 플로팅 대상이 스크롤이동으로 인해 전부 가려지게 된다면 플로팅 요소를 기준으로 정반대의 위치에 나타나도록 플립 해줍니다. 실제 flip과의 차이는 공식문서를 보면 더 쉽게 이해할 수 있습니다 :)
        (shift : https://floating-ui.com/docs/shift  , flip : https://floating-ui.com/docs/flip)
      • autoPlacement : 위의 flip과 유사하지만 플로팅 대상을 기준으로 Viewport에서 더 많은 요소가 보이는 축을 기준으로 플로팅 요소가 나타나도록 해줍니다. 이는 Viewport에서 보이지 않게 되는 순간 플로팅 대상을 이동시켜 주는 flip과 충돌되어 두 가지 요소 중 한 가지만 사용해야 합니다.
    • 데이터 제공자
      • arrow : 위에서 적용한 옵션들을 바탕으로 플로팅 대상과 플로팅 요소사이에 Arrow Component가 위치할 수 있도록 계산해 줍니다. 구체적인 사용방법은 아래의 코드와 같습니다.
      • hide : Viewport에서 툴팁이 사라질 경우 자동으로 hide처리를 해줄지에 대한 여부를 정합니다.
// arrow 사용방법

const arrowRef = useRef(null);

const {refs, floatingStyles, middlewareData} = useFloating({
  middleware: [
    arrow({
      element: arrowRef,
    }),
  ],
});

return (
   <div ref={refs.setFloating} style={floatingStyles}>
     <div
       ref={arrowRef}
       style={{
       position: 'absolute',
       left: middlewareData.arrow?.x,
       top: middlewareData.arrow?.y,
      }}
    />
  </div>
)

 

 

  • open : 해당 플로팅 대상을 렌더 할지, 렌더 하지 않을지에 대한 visible 여부로 플로팅 요소가 아직 배치되지 않았는지 확인할 수 있습니다.
  • onOpenChange : 플로팅 대상의 렌더여부를 핸들링하는 핸들러를 넣어줍니다. 기본적으로는 위의 open 속성을 넣어주는 것만으로 충분합니다.
  • whileElementsMounted : 플로팅 대상 및 플로팅 요소가 마운트 될 때 호출되고, 마운트 해제될 때 호출되는 정리 함수를 반환하는 함수입니다. open에 대한 의존성을 가지고 있는 useEffect와 동일한 역할로 보입니다. 조건부로 렌더링 하는 경우에는 반드시 해당 라이브러리에서 제공하는 autoUpdate를 import 하여 주입해 줄 것을 권장합니다.

 

실제 활용 예시

 

이러한 요소를 활용해서 저희 프로젝트에서 사용할 공통 툴팁 컴포넌트를 만들어보았습니다. 해당 컴포넌트를 제작하면서 공통 컴포넌트로서 여러 가지 상황에 필요한 요소를 props로 받아 사용할 수 있도록 했는데, 해당 요소들을 인터페이스를 통해 하나씩 살펴보겠습니다.

 

import React, { useState, useEffect, useCallback, useRef } from 'react'

import { useFloating, offset, arrow, autoUpdate, FloatingArrow, Placement } from '@floating-ui/react'

interface TooltipProps {
  defaultOpen?: boolean
  children: React.ReactNode
  content?: React.ReactNode | string
  onClick?: (e?: React.MouseEvent) => void
  callOnce?: boolean
  time?: number
  placement: Placement
  space?: number
  tooltipStyle?: React.CSSProperties
  arrowStyle?: React.CSSProperties
  closeWhenOtherPlaceClick?: boolean
}

 

 

  • defaultOpen : 첫 렌더시에 화면에 open 되어있는 상태를 표현하고자 할 때 사용합니다.
  • content: Tooltip 내부에 나타날 문구나 주입받을 컴포넌트를 넣어줍니다.
  • callOnce: on/off 할 수 있는 것이 아닌 한 번만 동작하게 하고 싶을 때 사용합니다. 특정 툴팁은 계속해서 on/off 되어야 하는 케이스가 있을 경우를 위해 만들어두었습니다.
  • time: Tooltip이 일정시간 이후 자동으로 off 될 수 있도록 하고 싶을 때 사용합니다. 특정 툴팁은 렌더 이후 자동으로 사라지도록 하기 위해 만들어두었습니다.
  • placement: Trigger를 기준으로 Tooltip이 위치할 장소를 정합니다.
  • closeWhenOtherPlaceClick: 툴팁 외 지면을 클릭할 경우 툴팁이 사라질지에 대한 여부입니다. 비교적 중요도가 낮은 경우 타 위치를 클릭하더라도 툴팁이 제거될 수 있도록 옵션을 열어두었습니다.
  • tooltipStyle : 툴팁의 스타일을 커스텀하려고 하는 경우 이를 받아서 주입해 줄 수 있도록 옵션을 열어두었습니다.
  • arrowStyle : 트리거와 툴팁을 연결해 주는 화살표의 스타일 역시 위와 동일한 니즈를 가지고 있어 옵션을 열어두었습니다.

 

추가적으로 useFloating을 사용할 때 기대한 옵션은 다음과 같았습니다.

 

  • 주입받은 placement값을 useFloating에 그대로 넘겨주어 플로팅 대상의 위치를 정해주어야 합니다.
  • 주입한 Arrow의 Style이 달라지는 경우 이에 맞게 플로팅 대상과 플로팅 요소 사이의 간격을 유연하게 지정해 줄 수 있어야 했습니다.
  • Arrow Component는 해당 라이브러리를 통해 제공받아야 했습니다. 이것이 @floating-ui/react-dom 대신 @floating-ui/react를 사용한 이유였기 때문입니다.
  • 항상 열려있는 것이 아니라 조건부로 렌더링 해줄 수 있어야 했습니다.

 

위와 같은 요구사항을 토대로 다음과 같이 useFloating의 옵션을 구성했습니다.

 

const arrowRef = useRef(null)
const { x, y, strategy, refs, context } = useFloating({
  placement, // 받아온 위치값을 전달해줍니다.
  middleware: [
    offset(space), // 주입해준 arrowStyle에 맞게 간격을 조정해줍니다.
    arrow({
      element: arrowRef, // 라이브러리에서 제공해준 Arrow Component를 사용합니다.
    }),
  ],
  whileElementsMounted: autoUpdate, // 조건부로 렌더링할 수 있도록 합니다.
})

 

위와 같은 옵션을 바탕으로 받아온 값을 활용하기 위한 로직들을 추가했습니다.

 

// 받아온 defaultOpen값에 따라 최초 렌더시의 open값을 지정해주었습니다.
useEffect(() => {
  setIsShow(defaultOpen)
}, [defaultOpen])

// 받아온 expireTime에 따라 setTimeout을 통해 해당 시간 뒤에 unmount될 수 있도록 했습니다.
useEffect(() => {
  if (time && isShow) {
    setTimeout(() => {
      setIsShow(false)
    }, time)
  }
}, [time, isShow])

// 외부 영역을 누를 때 해당 툴팁도 제거되어야하는 경우를 위해 clickEvent에 대한
// 이벤트 리스너를 달아주었습니다.
useEffect(() => {
  window.addEventListener('click', closeWhenOtherPlaceClicked)

  return () => window.removeEventListener('click', closeWhenOtherPlaceClicked)
}, [])

const closeWhenOtherPlaceClicked = useCallback(() => {
  if (!closeWhenOtherPlaceClick) return
  if (!!isShow) setIsShow(false)
}, [isShow])

 

이후 주입받은 속성들을 통해 Custom ClickEvent를 만들고, 이를 받아온 children컴포넌트의 onClick이벤트에 override 해주었습니다.

 

// 한번만 Tooltip을 핸들링하려는 callOnce옵션이 있다면 1회 실행 후 return해주고
// 아니라면 이벤트 버블링을 막으며 visible 상태를 전환해줍니다.
const clickTooltip = useCallback(
  (e: React.MouseEvent<HTMLDivElement>) => {
    onClick?.()
    if (!!callOnce) return

    e.stopPropagation()
    setIsShow(!isShow)
  },
  [isShow] 
)

 


// children 컴포넌트를 override하여 플로팅 요소에 필요한 ref를 주입해주고
// onClick Event를 위에 정의한 함수로 override해줍니다.

const triggerComponent = React.cloneElement(children, {
  ref: refs.setReference,
  onClick: clickTooltip,
})

// tooltipStyle로 받아온 값중 background color값이 있다면 이 값과 통일된 컬러로 arrow를 채워줍니다.
const arrowColor = tooltipStyle?.backgroundColor ? tooltipStyle?.backgroundColor : DEFAULT_ARROW_COLOR

return (
  <>
    {triggerComponent}
    {isShow && (
      <div
        ref={refs.setFloating}
        style={{
          position: strategy,
          top: y ?? 0,
          left: x ?? 0,
        }}
      >
        <FloatingArrow ref={arrowRef} context={context} width={12} height={5} fill={arrowColor} style={arrowStyle} />
        <TooltipBody style={tooltipStyle}>{content}</TooltipBody>
      </div>
    )}
  </>
)

 

Re-Cap

 

실제 프로젝트를 진행하면서 실제로는 Tooltip을 만들어야 하는 상황이 꽤 자주 있을 것으로 생각되는데 이러한 상황 속에서 좋은 라이브러리를 활용하여 유연한 컴포넌트를 만드는 과정을 소개해보았습니다. Headless 하고 직관적이며 Documentation의 설명 및 활용 예제 역시 너무 친절하여 사용성이 좋다고 생각했는데, 실제로 활용한 글을 검색과정에서 거의 확인하지 못해 이를 활용하여 컴포넌트화 하는 과정을 소개해보았습니다. 더 좋은 방법 혹은 라이브러리가 있다면 의견 주시면 감사드리겠습니다. :)