티스토리 뷰

웹/react

리액트로 툴팁 컴포넌트 만들기

안양사람 2022. 10. 31. 00:04
728x90
SMALL

배경

요즘 모노레포에서 이것저것 작업을 하고 있다. 그러다보니 자연스럽게 공통 디자인 컴포넌트들이 필요해서 하나씩 만들고 있다. 지금까지 버튼, 스피너를 만들었고 그 다음으로 인풋을 만들려고 하다가 갑자기 툴팁에 관심이 생겨서 이것부터 만들어보기로 했다. 인풋의 우선순위가 높은 것은 사실이지만 지금 나는 정해진 시간동안 프로젝트를 완성해야하는 상황이 아니다. 그냥 관심가는게 생기면 다른걸 제쳐두고 하는 편이다.

 

툴팁이 뭔데?

툴팁은 조금 생소할수도 있어서 간단하게 먼저 설명을 하고 넘어가겠다. 우리가 버튼을 호버했을 때 설명을 도와주는 ui가 나타나는 경우가 있다. 이게 바로 툴팁이다. 깃허브에서 사진을 하나 가져와봤다.

 

어떤 방법으로 구현할까?

들어가기 전에 정말 중요한 것 하나만 생각해보자. 일반적으로 호버했을 때 툴팁이 나타나기 때문에 이걸 기준으로 생각하겠다. 결국 특정 node에 호버했을 때 툴팁이 렌더링되어야한다. 즉 두가지의 node가 필요하다. 여기서 두 가지 경우로 나뉜다.

 

1. 툴팁컴포넌트의 children으로 툴팁 내용을 넣고 특정 node는 ref로 연결하는 방법

이렇게 작성하면 조금 더 직관적이다. 특정 node는 그냥 만들고 ref만 연결해서 tooltip에 넣어주면 된다. 그런데 문제가 있다. 동적으로 넣어줘야되는 경우는 이 방법을 적용할 수 없다. 예를들어서 api통신으로 리스트를 받고 이 리스트마다 툴팁을 넣어줘야한다고 생각해보자. 이때 ref를 적용하기 어렵다. 

<button ref={buttonRef}>
  나는 호버하면 툴팁이 뜨는 버튼이야
</button/>
<Tooltip targetRef={buttonRef}>
  나는 툴팁이야
</Tooltip>

 

2. 툴팁컴포넌트의 children으로 특정 node를 넣고 props로 툴팁 내용을 넘기는 방법

이렇게하면 버튼컴폰너트가 툴팁의 안에 들어가기 때문에 의존관계가 생긴다. 하지만 차라리 이런 방법이 더 좋다고 생각했다. ref를 적용할 수 없어서 classname을 넘겨서 queryselector로 가져오는 작업을 하게되면 코드가 엄청 지저분해진다.

<Tooltip title='나는 툴팁이야'>
  <button>
    나는 호버하면 툴팁이 뜨는 버튼이야
  </button/>
</Tooltip>

 

인터페이스 생각하기

바로 코드를 작성하는 것은 좋지 않은 습관이다. 이규원님(패캠)이 주니어들을 보며 키보드에서 손 떼라고 말하는 것을 유튜브 광고에서 본적이 있다. 그러니 우리 모두 손을 때고 생각부터 하자. 툴팁의 인터페이스부터 생각하자. 인터페이스부터 생각하는 이유는 컴포넌트는 만드는쪽이 아니라 사용하는 쪽을 위해서 만들어야되기 때문이다.

 

이런 디자인 컴포넌트의 인터페이스는 라이브러리를 찾아보는 것도 좋은 방법이다. 나는 mui를 찾아봤는데 옵션이 너무 많아서 적정선에서 만들기로 했다.

1. title: title로 툴팁 내용을 받자. 단 string이 아니라 ReactNode로 받으면 좋을 것 같다. 왜냐하면 툴팁에 텍스트말고 다른 것도 들어갈 수 있기 때문이다.

2. type: 툴팁은 여러가지 타입이 존재한다. 가장 일반적으로 사용되는게 hover했을 때 보이고 mouseout하면 사라지는 경우다. 나는 일단 간단하게 만들것이기 때문에 일단 hover라는 타입만 정해놓고 추후에 추가하겠다.

3. position: 툴팁은 아래에서 뜰 수도 있고 위에서 뜰 수도 있다. mui에서는 12가지 방향을 만들어 놨는데 나도 이정도는 괜찮다고 생각해서 12가지 옵션을 모두 넣어줄것이다. ex) top-start, top, top-end, bottom-start, ....

4. portalContainer: 툴팁을 띄워주는 방법에는 두가지가 있다. 첫번째는 해당 컴포넌트에서 state를 이용해서 보였다 사라졌다 하게하는 방법이고 두번째 방법은 해당 컴포넌트가 아니라 특정 container에 새롭게 렌더시켜주는 것이다. 보통은 모달같은곳에 이용을 많이 한다. 이걸 사용하는 이유는 예외 경우를 막기 위해서다. 화면이 복잡해지게되면 zindex만으로는 해결하기가 힘든 상황이 온다. html tree구조의 깊이와 zindex를 같이 신경써야한다. 그리고 zindex가 생기면 다른 컴포넌트와의 우선순위도 생각을 해야한다. 이런 굉장히 복잡한 상황을 막기위해서는 그냥 상위 컨테이너에 렌더링을 시켜주면 된다. 리액트에서도 createPortal을 지원한다.

5. arrow: 툴팁들을보면 화살표를 제공하는 경우가 많다. 하지만 그렇지 않은 경우도 많기 때문에 boolean값을 받을것이다.

6. children: react18부터는 직접 타입을 제공해줘야한다. 넣어주자.

7. extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>

8. 툴팁 스타일: 이건 사실 방법이 나뉜다. 확장을 많이 열어두지 않고 간편하게 작성하려면 몇가지의 인터페이스만 지정하고 그걸로만 소통하면된다. 하지만 확장에 열려있지 않기 때문에 나중에 유지보수가 힘들다는 단점이 있다. 일단 나는 꼭 필요하다고 생각한 옵션 3가지 정도만 넣어줬다. 나중에 마이그레이션하게되면 오히려 그런 경험이 좋기 때문에 일단 이대로 간다.

interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
  title: ReactNode;
  type?: 'hover';
  position?:
    | 'top-start'
    | 'top'
    | 'top-end'
    | 'bottom-start'
    | 'bottom'
    | 'bottom-end'
    | 'right-start'
    | 'right'
    | 'right-end'
    | 'left-start'
    | 'left'
    | 'left-end';
  portalContainer?: Element | DocumentFragment;
  arrow?: boolean;
  maxWidth?: CSSProperties['maxWidth'];
  backgroundColor?: Property.BackgroundColor;
  color?: Property.Color;
  children?: ReactNode;
}

 

전체적인 구현방법 생각하기

이제 큰 틀에서 구현방법을 생각해보자. 외부에서 주입되는 데이터는 이미 정했고 이제 내부 상태와 외부 데이터를 연결시켜서 구현해보자. 전체적인 틀이기 때문에 스타일은 일단 신경쓰지 말자.

  const [show, setShow] = useState<boolean>(false);
  const onMouseEnter = useCallback(() => {
    setShow(true);
  }, []);

  const onMouseLeave = useCallback(() => {
    setShow(false);
  }, []);

  return (
    <>
      <div
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        {...otherProps}
      >
        {children}
      </div>
      {show && createPortal(getComponent(), portalContainer ?? document.body)}
    </>
  );

div태그안에 children을 넣었다. 이렇게 한 이유는 children에 이벤트를 다는게 힘들기 때문이다. 상위 컴포넌트에서 show, setShow를 알 필요가 없는데 괜히 코드가 복잡해지게된다. 그래서 div를 감싸주는 것으로 간단하게 해결했다. 마우스를 올리면 보여주고 나가면 사라지게 만들었다.

 

show가 true인 경우는 createPortal을 이용해서 컴포넌트를 만들어준다. portalContainer가 있으면 그 div안에 만들어주고 없는 경우는 document.body에 만들어주면 된다. 나는 지금 모노레포구조로 만들고 있다. 그렇기 때문에 portalContainer를 외부에서 주입받아야한다. 만약 모노레포가 아니라면 portalContainer를 주입하지 않아도 괜찮다.

 

세부내용 구현하기

툴팁을 만들 때 가장 어려운것은 position을 결정하는 것이다. 좌표를 얻어와서 계산하는 로직이 필요하다. 이게 어려운건아닌데 익숙하지가 않다. 좌표를 이용하는 경우는 많지 않다. 그래서 나도 만드는데 시간이 좀 걸렸다.

 

그 전에 하나 알아야할게있다. title의 width, height의 크기를 알아야 툴팁의 position을 정확히 정할 수 있다. 예를들어 아래에 뜨는 툴팁이라고 해보자. left혹은 right position이 필요한데 툴팁의 크기는 가변적이다. maxWidth정도는 정해놨지만 100px인경우와 200px인 경우 position이 변해야한다. 그런데 문제는 title이 호버된경우만 렌더하게되면 호버를 했을 때 툴팁의 width, height를 알 수 없다. 그래서 나는 portal을 이용하는 것과 별개로 컴포넌트를 만들어줬다. opacity, pointer events를 설정해서 화면에서 보이지 않게 변경하면 큰 문제 없다. 웹 접근성 측면에서도 이게 좋지 않을까? 하는 생각이 있다. 근데 이건 사실 잘 모르겠다. 추측일뿐이다. 하나 걱정되는건 불필요한 렌더링? 근데 이게 성능에 영향을 끼친다고 생각하지는 않는다. 그래서 일단은 이렇게 구현하기로 했다.

 

getComponent 초안

함수를 먼저 간단히 정의하겠다. 위에서 말했듯이 hidden case가 필요하다. 또한 ref로 접근해서 widht, height를 얻어야하기 때문에 hidden이 true인 경우에 ref를 전달해줘야한다.

  const getComponent = useCallback(
    (hiddne = false, tooltipRef?: MutableRefObject<HTMLDivElement | null>) => (
      <div
        ref={tooltipRef}
      >
        {title}
      </div>
    ),
    [생략~~],
  );

 

상수

주석을 달아놨다. 토스 slash 레포를 보면서 진짜 주석의 중요성을 느꼈다. 내가봤을때는 명확한 변수명이라고 생각하지만 이 코드를 처음 보는 사람은 그렇지 않다. 그래서 가벼운 상수에도 주석을 붙였다.

/**
 * DEFAULT_GAP: children과 tooltip의 거리
 * ARROW_HEIGHT: 화살표의 높이
 * ARROW_WIDTH: 활살표의 크기(높이*2를 유지해주세요)
 */
const DEFAULT_GAP = 4;
const ARROW_HEIGHT = 5;
const ARROW_WIDTH = ARROW_HEIGHT * 2;

  const HOVER_GAP = useMemo(() => {
    if (arrow) return DEFAULT_GAP + ARROW_HEIGHT;
    return DEFAULT_GAP;
  }, [arrow]);

 

툴팁 포지션

getBoundingClientRect에 대해 모른다면 다음 링크를 먼저 보자.

https://developer.mozilla.org/ko/docs/Web/API/Element/getBoundingClientRect

clinetWidth,Height에 대해 모른다면 다음 링크를 먼저 보자.

http://jsfiddle.net/y8Y32/25/

  const getTooltipPositions = useCallback((): CSSProperties => {
    if (!ref.current || !tooltipRef.current) return {};
    const { top, right, bottom, left } = ref.current.getBoundingClientRect();
    const [tooltipWidth, tooltipHeight] = [tooltipRef.current.scrollWidth, tooltipRef.current.scrollHeight];

    const calcTop = { top: bottom + HOVER_GAP };
    const calcBottom = { bottom: document.body.clientHeight - top + HOVER_GAP };
    const calcLeft = { left: right + HOVER_GAP };
    const calcRight = { right: left + HOVER_GAP };
    const calcSubTop = { top: (top + bottom) / 2 - tooltipHeight / 2 };
    const calcSubBottom = { bottom: document.body.clientHeight - bottom };
    const calcSubLeft = { left: (left + right) / 2 - tooltipWidth / 2 };
    const calcSubRight = { right: document.body.clientWidth - right };

    switch (position) {
      case 'top-start':
        return { ...calcBottom, left };
      case 'top':
        return { ...calcBottom, ...calcSubLeft };
      case 'top-end':
        return { ...calcBottom, ...calcSubRight };
      case 'bottom-start':
        return { ...calcTop, left };
      case 'bottom':
        return { ...calcTop, ...calcSubLeft };
      case 'bottom-end':
        return { ...calcTop, ...calcSubRight };
      case 'left-start':
        return { ...calcRight, top };
      case 'left':
        return { ...calcRight, ...calcSubTop };
      case 'left-end':
        return { ...calcRight, ...calcSubBottom };
      case 'right-start':
        return { ...calcLeft, top };
      case 'right':
        return { ...calcLeft, ...calcSubTop };
      case 'right-end':
        return { ...calcLeft, ...calcSubBottom };
      default:
        return { top: 'auto' };
    }
  }, [HOVER_GAP, position]);

코드가 좀 길다. 사실 중복을 제거할 수는 있는데 굳이? 라는 생각이 들었다. 예를들어서 position이 top으로 시작하는 경우는 bottom이 동일하고 bottom으로 시작하는 부분은 top이 동일하다. 알고는 있지만 굳이 이런것까지 신경을 써야 할까? 잘 모르겠다. 나는 그냥 switch case문 하나로 끝내는게 더 좋아보인다. 만약 여기서 더 복잡해진다면 그때는 상황이 또 다르지만 말이다.

 

설명이 필요한것들만 몇 개 설명하겠다.

calcBottom: document.body.clientHeight - top으로 bottom 포지션을 얻었다. 여기서 gap을 더했다.

calcSubTop: 먼저 top과 bottom의 중간값을 계산한다. 그리고 툴팁의 height의 절반만큼을 더해주면 된다.

 

화살표 포지션

// 이 로직을 먼저 봐야되서 적어놓음
          ${arrow && [
            css`
              &::after {
                content: '';
                position: absolute;
                border-style: solid;
                border-width: ${ARROW_HEIGHT}px;
              }
            `,
            getArrowPositions(),
          ]}
///////////////////////////////////////////////////
  const getArrowPositions = useCallback(() => {
    const topBorderColor = { 'border-color': `${backgroundColor} transparent transparent transparent` };
    const bottomBorderColor = { 'border-color': `transparent transparent ${backgroundColor} transparent` };
    const leftBorderColor = { 'border-color': `transparent transparent transparent ${backgroundColor}` };
    const rightBorderColor = { 'border-color': `transparent ${backgroundColor} transparent transparent` };
    switch (position) {
      case 'top-start':
        return css`
          &::after {
            ${theme.position({ bottom: 0, left: ARROW_WIDTH })}
            margin-bottom: -${ARROW_WIDTH}px;
            ${topBorderColor}
          }
        `;
      case 'top':
        return css`
          &::after {
            ${theme.position({ bottom: 0, left: '50%' })}
            margin-left: -${ARROW_HEIGHT}px;
            margin-bottom: -${ARROW_WIDTH}px;
            ${topBorderColor}
          }
        `;
      case 'top-end':
        return css`
          &::after {
            ${theme.position({ bottom: 0, right: ARROW_WIDTH })}
            margin-bottom: -${ARROW_WIDTH}px;
            ${topBorderColor}
          }
        `;
      case 'bottom-start':
        return css`
          &::after {
            ${theme.position({ top: -ARROW_WIDTH, left: ARROW_WIDTH })}
            ${bottomBorderColor}
          }
        `;
      case 'bottom':
        return css`
          &::after {
            ${theme.position({ top: -ARROW_WIDTH, left: '50%' })}
            margin-left: -${ARROW_HEIGHT}px;
            ${bottomBorderColor}
          }
        `;
      case 'bottom-end':
        return css`
          &::after {
            ${theme.position({ top: -ARROW_WIDTH, right: ARROW_WIDTH })}
            margin-bottom: -${ARROW_WIDTH}px;
            ${bottomBorderColor}
          }
        `;
      case 'left-start':
        return css`
          &::after {
            ${theme.position({ top: ARROW_WIDTH, left: '100%' })}
            ${leftBorderColor}
          }
        `;
      case 'left':
        return css`
          &::after {
            ${theme.position({ top: '50%', left: '100%' })}
            margin-top: -${ARROW_HEIGHT}px;
            ${leftBorderColor}
          }
        `;
      case 'left-end':
        return css`
          &::after {
            ${theme.position({ bottom: ARROW_WIDTH, left: '100%' })}
            ${leftBorderColor}
          }
        `;
      case 'right-start':
        return css`
          &::after {
            ${theme.position({ top: ARROW_WIDTH, right: '100%' })}
            border-color: transparent ${backgroundColor} transparent transparent;
          }
        `;
      case 'right':
        return css`
          &::after {
            ${theme.position({ top: '50%', right: '100%' })}
            margin-top: -${ARROW_HEIGHT}px;
            ${rightBorderColor}
          }
        `;
      case 'right-end':
        return css`
          &::after {
            ${theme.position({ bottom: ARROW_WIDTH, right: '100%' })}
            ${rightBorderColor}
          }
        `;
      default:
        return css``;
    }
  }, [backgroundColor, position, theme]);

맨 위의 스타일 코드를 입력하면(content부분) ARROW_HEIGHT*2 크기의 네모난 박스가 만들어진다.

그리고 border-color transparent를 통해서 안보이게 가려주면 끝이다. 이건 직접 해보는게 빠르다.

border-color: red blue green gray;

 

position부분은 너무 직관적이라 설명하지 않는다. margin을 준 이유는 화살표를 맨 끝에 붙이고 싶지 않아서다. 마진을 주지 않으면 border-radius를 줬기 때문에 짤려보인다.

getComponent

위에서 설명했다시피 hidden과 ref를 인자로 받는다. 그리고 hidden인경우는 opacity를 0으로 아닌 경우는 위에서 만든 getTooltipPositions 함수를 이용해 포지션에 넣어준다. 다른건몰라도 pointer-events none은 필수다. 예를들어서 마우스인을 했을때 그 위에 툴팁이 뜬다면 마우스아웃된 상태가 되기 때문에 깜빡이는 현상이 발생한다. 

  const getComponent = useCallback(
    (hiddne = false, tooltipRef?: MutableRefObject<HTMLDivElement | null>) => (
      <div
        ref={tooltipRef}
        css={css`
          ${hiddne
            ? css`
                opacity: 0;
              `
            : css`
                ${theme.position('fixed', { ...getTooltipPositions() })}
              `}
          position: fixed;

          ${theme.size({ width: 'fit-content', maxWidth })}

          padding: 6px 9px;
          border-radius: 10px;

          background-color: ${backgroundColor};
          color: ${color};

          pointer-events: none;

          ${theme.typo.titleM}

          ${arrow && [
            css`
              &::after {
                content: '';
                position: absolute;
                border-style: solid;
                border-width: ${ARROW_HEIGHT}px;
              }
            `,
            getArrowPositions(),
          ]}
        `}
      >
        {title}
      </div>
    ),
    [arrow, backgroundColor, color, getArrowPositions, getTooltipPositions, maxWidth, title, theme],
  );

 

테스트코드

모든것을 테스트하지 않을 것이다. portalContainer부분을 제외하고 나머지는 스토리북으로 충분하다고 생각했다.(물론 아니라고 생각할 수도 있다) 그래서 bdd로 portalContainer case만 테스트했다. 테스트는 너무 간단해서 설명 생략

import { render, screen, fireEvent } from '../../utils/testUtils';

import Tooltip from './Tooltip';

const context = describe;

describe('Tooltip', () => {
  const TOOLTIP_ID = 'test-tooltip';
  const TOOLTIP_TITLE = 'tooltip';
  const CHILDREN_TEXT = 'HOVER';

  context('when portalContainer case', () => {
    it('portalContainer가 없는 경우(docucument body)', () => {
      render(
        <Tooltip title={TOOLTIP_TITLE} data-testid={TOOLTIP_ID}>
          <div>{CHILDREN_TEXT}</div>
        </Tooltip>,
      );

      expect(document.body.childElementCount).toBe(1);
      fireEvent.mouseOver(screen.getByTestId(TOOLTIP_ID));
      expect(document.body.childElementCount).toBe(2);
      fireEvent.mouseOut(screen.getByTestId(TOOLTIP_ID));
      expect(document.body.childElementCount).toBe(1);
    });
  });

  it('portalContainer가 존재하는 경우', () => {
    const portalContainer = document.createElement('div');
    render(
      <Tooltip title={TOOLTIP_TITLE} data-testid={TOOLTIP_ID} portalContainer={portalContainer}>
        <div>{CHILDREN_TEXT}</div>
      </Tooltip>,
    );
    
    expect(portalContainer).not.toBeNull();
    expect(portalContainer.childElementCount).toBe(0);
    fireEvent.mouseOver(screen.getByTestId(TOOLTIP_ID));
    expect(portalContainer.childElementCount).toBe(1);
    fireEvent.mouseOut(screen.getByTestId(TOOLTIP_ID));
    expect(portalContainer.childElementCount).toBe(0);
  });
});

 

전체코드

import { useState, useCallback, useRef, CSSProperties, MutableRefObject, useMemo } from 'react';
import type { FC, ReactNode, HTMLAttributes } from 'react';

import { css, useTheme } from '@emotion/react';
import { createPortal } from 'react-dom';

import type { Property } from 'csstype';

// TODO:
// type 추가(timeout 등등) + lint disabled 제거
// 주석추가

// 툴팁 스타일을 외부에서 주입하게 변경? 지금은 backgroundColor, color, maxWidth만 변경 가능
// but 외부에서 주입하면 코드를 작성하기 힘들어짐. 그럼에도 불구하고 더 높은 자유도를 주는 것도 필요해 보이긴 함
// mui는 클래스와 styles 내부 라이브러리를 이요해서 제어.
// 나는 그냥 클래스이름에 prefix붙여서 안겹치제 만들고 emotion으로 제어해야 할 듯

interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
  title: ReactNode;
  type?: 'hover';
  position?:
    | 'top-start'
    | 'top'
    | 'top-end'
    | 'bottom-start'
    | 'bottom'
    | 'bottom-end'
    | 'right-start'
    | 'right'
    | 'right-end'
    | 'left-start'
    | 'left'
    | 'left-end';
  portalContainer?: Element | DocumentFragment;
  arrow?: boolean;
  maxWidth?: CSSProperties['maxWidth'];
  backgroundColor?: Property.BackgroundColor;
  color?: Property.Color;
  children?: ReactNode;
}
const Tooltip: FC<Props> = ({
  title,
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  type = 'hover',
  position = 'bottom',
  portalContainer,
  arrow = true,
  maxWidth = 300,
  backgroundColor: _backgroundColor,
  color: _color,
  children,
  ...otherProps
}) => {
  const theme = useTheme();
  const ref = useRef<HTMLDivElement | null>(null);
  const tooltipRef = useRef<HTMLDivElement | null>(null);
  const [show, setShow] = useState<boolean>(false);
  const HOVER_GAP = useMemo(() => {
    if (arrow) return DEFAULT_GAP + ARROW_HEIGHT;
    return DEFAULT_GAP;
  }, [arrow]);
  const backgroundColor = useMemo(() => _backgroundColor ?? theme.color.grey600, [_backgroundColor, theme]);
  const color = useMemo(() => _color ?? theme.color.white, [_color, theme]);

  const onMouseEnter = useCallback(() => {
    setShow(true);
  }, []);

  const onMouseLeave = useCallback(() => {
    setShow(false);
  }, []);

  const getTooltipPositions = useCallback((): CSSProperties => {
    if (!ref.current || !tooltipRef.current) return {};
    const { top, right, bottom, left } = ref.current.getBoundingClientRect();
    const [tooltipWidth, tooltipHeight] = [tooltipRef.current.scrollWidth, tooltipRef.current.scrollHeight];

    const calcTop = { top: bottom + HOVER_GAP };
    const calcBottom = { bottom: document.body.clientHeight - top + HOVER_GAP };
    const calcLeft = { left: right + HOVER_GAP };
    const calcRight = { right: left + HOVER_GAP };
    const calcSubTop = { top: (top + bottom) / 2 - tooltipHeight / 2 };
    const calcSubBottom = { bottom: document.body.clientHeight - bottom };
    const calcSubLeft = { left: (left + right) / 2 - tooltipWidth / 2 };
    const calcSubRight = { right: document.body.clientWidth - right };

    switch (position) {
      case 'top-start':
        return { ...calcBottom, left };
      case 'top':
        return { ...calcBottom, ...calcSubLeft };
      case 'top-end':
        return { ...calcBottom, ...calcSubRight };
      case 'bottom-start':
        return { ...calcTop, left };
      case 'bottom':
        return { ...calcTop, ...calcSubLeft };
      case 'bottom-end':
        return { ...calcTop, ...calcSubRight };
      case 'left-start':
        return { ...calcRight, top };
      case 'left':
        return { ...calcRight, ...calcSubTop };
      case 'left-end':
        return { ...calcRight, ...calcSubBottom };
      case 'right-start':
        return { ...calcLeft, top };
      case 'right':
        return { ...calcLeft, ...calcSubTop };
      case 'right-end':
        return { ...calcLeft, ...calcSubBottom };
      default:
        return { top: 'auto' };
    }
  }, [HOVER_GAP, position]);

  const getArrowPositions = useCallback(() => {
    const topBorderColor = { 'border-color': `${backgroundColor} transparent transparent transparent` };
    const bottomBorderColor = { 'border-color': `transparent transparent ${backgroundColor} transparent` };
    const leftBorderColor = { 'border-color': `transparent transparent transparent ${backgroundColor}` };
    const rightBorderColor = { 'border-color': `transparent ${backgroundColor} transparent transparent` };
    switch (position) {
      case 'top-start':
        return css`
          &::after {
            ${theme.position({ bottom: 0, left: ARROW_WIDTH })}
            margin-bottom: -${ARROW_WIDTH}px;
            ${topBorderColor}
          }
        `;
      case 'top':
        return css`
          &::after {
            ${theme.position({ bottom: 0, left: '50%' })}
            margin-left: -${ARROW_HEIGHT}px;
            margin-bottom: -${ARROW_WIDTH}px;
            ${topBorderColor}
          }
        `;
      case 'top-end':
        return css`
          &::after {
            ${theme.position({ bottom: 0, right: ARROW_WIDTH })}
            margin-bottom: -${ARROW_WIDTH}px;
            ${topBorderColor}
          }
        `;
      case 'bottom-start':
        return css`
          &::after {
            ${theme.position({ top: -ARROW_WIDTH, left: ARROW_WIDTH })}
            ${bottomBorderColor}
          }
        `;
      case 'bottom':
        return css`
          &::after {
            ${theme.position({ top: -ARROW_WIDTH, left: '50%' })}
            margin-left: -${ARROW_HEIGHT}px;
            ${bottomBorderColor}
          }
        `;
      case 'bottom-end':
        return css`
          &::after {
            ${theme.position({ top: -ARROW_WIDTH, right: ARROW_WIDTH })}
            margin-bottom: -${ARROW_WIDTH}px;
            ${bottomBorderColor}
          }
        `;
      case 'left-start':
        return css`
          &::after {
            ${theme.position({ top: ARROW_WIDTH, left: '100%' })}
            ${leftBorderColor}
          }
        `;
      case 'left':
        return css`
          &::after {
            ${theme.position({ top: '50%', left: '100%' })}
            margin-top: -${ARROW_HEIGHT}px;
            ${leftBorderColor}
          }
        `;
      case 'left-end':
        return css`
          &::after {
            ${theme.position({ bottom: ARROW_WIDTH, left: '100%' })}
            ${leftBorderColor}
          }
        `;
      case 'right-start':
        return css`
          &::after {
            ${theme.position({ top: ARROW_WIDTH, right: '100%' })}
            border-color: transparent ${backgroundColor} transparent transparent;
          }
        `;
      case 'right':
        return css`
          &::after {
            ${theme.position({ top: '50%', right: '100%' })}
            margin-top: -${ARROW_HEIGHT}px;
            ${rightBorderColor}
          }
        `;
      case 'right-end':
        return css`
          &::after {
            ${theme.position({ bottom: ARROW_WIDTH, right: '100%' })}
            ${rightBorderColor}
          }
        `;
      default:
        return css``;
    }
  }, [backgroundColor, position, theme]);

  const getComponent = useCallback(
    (hiddne = false, tooltipRef?: MutableRefObject<HTMLDivElement | null>) => (
      <div
        ref={tooltipRef}
        css={css`
          ${hiddne
            ? css`
                opacity: 0;
              `
            : css`
                ${theme.position('fixed', { ...getTooltipPositions() })}
              `}
          position: fixed;

          ${theme.size({ width: 'fit-content', maxWidth })}

          padding: 6px 9px;
          border-radius: 10px;

          background-color: ${backgroundColor};
          color: ${color};

          pointer-events: none;

          ${theme.typo.titleM}

          ${arrow && [
            css`
              &::after {
                content: '';
                position: absolute;
                border-style: solid;
                border-width: ${ARROW_HEIGHT}px;
              }
            `,
            getArrowPositions(),
          ]}
        `}
      >
        {title}
      </div>
    ),
    [arrow, backgroundColor, color, getArrowPositions, getTooltipPositions, maxWidth, title, theme],
  );

  return (
    <>
      <div
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        ref={ref}
        css={css`
          width: fit-content;
        `}
        {...otherProps}
      >
        {children}
        {getComponent(true, tooltipRef)}
      </div>
      {show && createPortal(getComponent(), portalContainer ?? document.body)}
    </>
  );
};

export default Tooltip;

/**
 * DEFAULT_GAP: children과 tooltip의 거리
 * ARROW_HEIGHT: 화살표의 높이
 * ARROW_WIDTH: 활살표의 크기(높이*2를 유지해주세요)
 */
const DEFAULT_GAP = 4;
const ARROW_HEIGHT = 5;
const ARROW_WIDTH = ARROW_HEIGHT * 2;

 

깃허브

https://github.com/yoonminsang/play-ground/blob/develop/packages/common-components/src/Tooltip/Tooltip.tsx

 

GitHub - yoonminsang/play-ground: 놀이터

놀이터. Contribute to yoonminsang/play-ground development by creating an account on GitHub.

github.com

 

728x90
LIST

' > react' 카테고리의 다른 글

cra 없이 리액트에서 svg 사용하기  (0) 2022.03.15
리액트에서 테스트하기(렌더링 테스트)  (0) 2021.12.11
리액트란  (0) 2021.06.01
리액트 자동완성 및 ㅎㅎ  (0) 2021.01.26
react와 서버(nodejs express) 연결하기  (0) 2020.07.10
댓글
공지사항