かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React シンプルな tooltip コンポーネント作ってみた

React の勘を取り戻す素振りで tooltip コンポーネントを作ってみた。(車輪の再発明)

hover で表示するシンプルな tooltip

tooltip と表示する対象を同じ div で囲ってしまって親要素の hover で tooltip を表示させる

// Tooltip.tsx
import { FC, ReactNode } from 'react';
import styles from './tooltip.module.css';

type TooltipProps = {
  label: string;
  className?: string;
  children: ReactNode;
};

export const Tooltip: FC<TooltipProps> = ({ label, className, children }) => {
  return (
    <div className={`${styles.tooltipGroup} ${className || ''}`}>
      <span className={styles.tooltip}></span>
      {children}
    </div>
  );
}

tooltip.module.css

.tooltipGroup {
  position: relative;
  width: fit-content;
}

.tooltipGroup:hover .tooltip {
  display: inline-block;
  z-index: 1000;
}

.tooltip {
  display: none;
  position: absolute;
  top: -2.25rem;
  left: 50%;
  margin-left: -25%;
  font-size: 0.75rem;
  color: rgb(255, 255, 255);
  background: rgb(50 51 56);
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
  transform-origin: bottom center;
}

.tooltip:after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -6px;
  margin-left: -6px;
  border-color: rgb(50 51 56);
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top-style: solid;
  border-top-width: 6px;
  width: 0;
  height: 0;
}
  • Pros
    • シンプルな実装で tooltip を表示できる
  • Cons
    • 全体を div で囲ってしまっているので文書中に使いたい場合は css を調整する必要がある
    • 表示する要素に position: relative を使うので他要素との重なり順の問題が発生する可能性がある

z-index を考慮するパターン

tooltip を Portal で外に出してしまい、対象要素の位置を取得して表示位置を計算するパターン

Tooltip.tsx

import { CSSProperties, FC, ReactNode, useCallback, useRef, useState } from "react";
import { Portal } from './Portal';
import styles from "./tooltip.module.css";

type TooltipProps = {
  label: string | ReactNode;
  className?: string;
  children: ReactNode;
};

const TooltipContainer: FC<TooltipProps> = ({ label, className, children }) => {
  const targetRef = useRef<HTMLDivElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const [isShow, setIsShow] = useState<boolean>(false);
  const [pos, setPos] = useState<CSSProperties | undefined>(undefined);
  const [placement, setPlacement] = useState<'top' | 'bottom'>('top');

  const handleOnPointerEnter = useCallback(() => {
    if (!targetRef.current || !tooltipRef.current) {
      return;
    }

    // 要素の位置から tooltip を表示する位置を計算する
    // ※ 簡易にするため tooltip が画面外にはみ出す場合は簡易にしか考慮しない
    const margin = 12;
    const targetRect = targetRef.current.getBoundingClientRect();
    const targetY = targetRect.top;
    const targetX = targetRect.left;
    const targetWidth = targetRef.current.offsetWidth;
    const targetHeight = targetRef.current.offsetHeight;
    const toolTipWidth = tooltipRef.current.offsetWidth;
    const toolTipHeight = tooltipRef.current.offsetHeight;
    const wWidth = window.innerWidth;
    
    let place = 'top';
    let top = targetY - (toolTipHeight + margin);
    if (top < 0) {
      top = targetY + targetHeight + margin;
      place = 'bottom';
    }

    let left = targetX + (targetWidth - toolTipWidth) / 2;
    if (left + toolTipWidth > wWidth) {
      left = wWidth - toolTipWidth - 20;
    }

    setPos({
      top: `${top}px`,
      left: `${left}px`,
    });
    setPlacement(place);
    setIsShow(true);
  }, []);

  const handleOnPointerLeave = useCallback(() => {
    setIsShow(false);
  }, []);

  return (
    <>
      <Portal>
        <div
          className={[styles.tooltip, placement, isShow ? "isShow" : ""].join(" ").trim()}
          ref={tooltipRef}
          style={pos}
        >
          {label}
        </div>
      </Portal>
      <div
        className={`${styles.tooltipTarget} ${className || ""}`}
        ref={targetRef}
        onPointerEnter={handleOnPointerEnter}
        onPointerLeave={handleOnPointerLeave}
      >
        {children}
      </div>
    </>
  );;
};

export const ToolTip: FC<TooltipProps> = ({ label, className, children }) => {
  if (!label) {
    return <>{children}</>;
  }

  return (
    <TooltipContainer label={label} className={className}>
      {children}
    </TooltipContainer>
  );
};

Portal.tsx

import { FC, ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';

const el = document.createElement('div');

type PortalProps = {
  children: ReactNode;
};

export const Portal: FC<PortalProps> = ({ children }) => {
  useEffect(() => {
    const body = document.querySelector('body');
    if (!body) {
      return;
    }
    body.appendChild(el);

    return () => {
      if (body.contains(el)) {
        body.removeChild(el);
      }
    };
  }, []);

  return createPortal(children, el);
};

cf. React Hooks コンポーネント外のDOMに子コンポーネントを追加したい。 - かもメモ

tooltip.module.css

.tooltip:global(.isShow) {
  display: inline-block;
  z-index: 1100;
}

.tooltip {
  display: none;
  position: absolute;
  top: -2.25rem;
  left: 50%;
  margin-left: -25%;
  font-size: 0.75rem;
  color: rgb(255, 255, 255);
  background: rgb(50 51 56);
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
  transform-origin: bottom center;
}

.tooltip:after {
  content: "";
  position: absolute;
  border-color: rgb(50 51 56);
  width: 0;
  height: 0;
}

.tooltip:global(.top):after {
  left: 50%;
  bottom: -6px;
  margin-left: -6px;
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top-style: solid;
  border-top-width: 6px;
}

.tooltip:global(.bottom):after {
  left: 50%;
  top: -6px;
  margin-left: -6px;
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-bottom-style: solid;
  border-bottom-width: 6px;
}

.tooltipTarget {
  width: fit-content;
}
  • Pros
    • 重なり順を気にする必要がなくなる
  • Const
    • tooltip の表示位置の計算が結構たいへん
    • コード量が多い (ライブラリ使うほうが良くない?ってなるくらい多くなる)

Tooptip の表示位置の計算

要素の起点は左上なので、tooltip 対象の要素の位置を getBoundingClientRect() で取得して計算する

tooltip の位置計算

sample

所管

重なり順 (z-index) を考えずに単純に tooltip を表示する要素内に表示させるならめちゃくちゃ簡単に作れた。
重なり順を考慮すると Portal で外に出して、いわゆる JavaScript で要素の位置を計算しなければならないのでちょっと大変。画面幅が変わった時に位置を変える計算も含めるともっと大変になる。どこまで考慮するかで自作レベルで済むのか react-tooltip とか chakua-ui とかのライブラリを使ってしまうかを判断するのが良い。(積極的に自作の tooltip 選択することは思うけど…)


[参考]