かもメモ

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

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 選択することは思うけど…)


[参考]

Prettier JS はシングルクォーテーションで CSS はダブルクォーテーションにしたい

自動で JS, CSS の整形をしてくれる Prettier とても便利で愛用しています。
同じクオートの設定を JS の時は ' (Single Quote), CSS の時は " (Double Quote) とで分けたい時のメモ

overrides, files を使って設定の場合分けができる

.prettierrc.json

{
  "singleQuote": true,
  "overrides": [
    {
      "files": ["**/*.css", "**/*.scss", "**/*.html"],
      "options": {
        "singleQuote": false
      }
    }
  ]
}

デフォルトでは Single Quote ' を使い、拡張子が .css, .scss, .html の場合は singleQuote: false つまり Double Quote " でフォーマされるようになる

同じように overrides を使えばフォーマットの幅が広がりそうです。
おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]

npm WARN config init.author.name Use `--init-author-name` instead.

Node.js のバージョンを上げて npm コマンドを使ったら次のような warning が表示された

npm WARN config init.author.name Use `--init-author-name` instead.
npm WARN config init.author.email Use `--init-author-email` instead.
npm WARN config init.author.url Use `--init-author-url` instead.

環境

  • node v16.17.0
  • npm v8.15.0

config の key が変更になっていた

init.author.name
DEPRECATED: Use --init-author-name instead.
cf. config | npm Docs

init.author.xxx の代わりに --init-author-xxx を使えトノコト。
init.author.xxx が残っていると永遠に WARNING が表示されるので、この設定を削除してしまう

# 設定の確認
$  npm c list
; "user" config from /Users/USER_NAME/.npmrc

//registry.npmjs.org/:_authToken = (protected)
init.author.email = "YOUR EMAIL"
init.author.name = "YOUR NAME"
init.author.url = "YUOR URL"
registry = "https://registry.npmjs.org/"

# `init.author.xxx` を削除
$ npm c delete init.author.email
$ npm c delete init.author.name
$ npm c delete init.author.url

設定を削除したらとりあえず警告は出なくなりました。

--init-author-xxx が設定できない問題

ターミナルから設定しようとしてもエラーになる…

$ npm c set --init-author-name="KiKiKi KiKi"
npm ERR! code EUSAGE
npm ERR!
npm ERR! Manage the npm configuration files
npm ERR!
npm ERR! Usage:
npm ERR! npm config set <key>=<value> [<key>=<value> ...]
npm ERR! npm config get [<key> [<key> ...]]
npm ERR! npm config delete <key> [<key> ...]
npm ERR! npm config list [--json]
npm ERR! npm config edit
npm ERR!
npm ERR! Options:
npm ERR! [--json] [-g|--global] [--editor <editor>] [-L|--location <global|user|project>]
npm ERR! [-l|--long]
npm ERR!
npm ERR! alias: c
npm ERR!
npm ERR! Run "npm help config" for more info

こっち使えって書いてるのになんでや…

.npmrc に設定を書いてみても無視される

.npmrc

--init-author-name="KiKiKi"

config を見ると設定としては表示されるが npm init 時に設定が反映されない

# 設定としては読み込まれている
$ npm c list
; "project" config from /Users/kikiki/myproject/.npmrc

--init-author-name = "KiKiKi"

$ npm init
{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "license": "ISC"
}

init-author-xxx なら上手く動作する

GitHubissue も close になっているのでよく分かってないのですが、そこに書かれたコメントの通り --init-author-xxx ではなく init-author-xxx なら上手く動作しました…

init-author-xxx はコマンドから設定可能

$ npm c set init-author-name="KiKiKi KiKi"
$ npm c set init-author-email="YOUR EMAIL"
$ npm c set init-author-url="YOUR WEB SITE"
$
$ npm init
{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "KiKiKi KiKi <YOUR EMAIL> (YOUR WEB SITE)",
  "license": "ISC"
}

WARNING に出てくるプロパティで設定できないのどうして… (ᐡ o̴̶̷̤ ﻌ o̴̶̷̤ ᐡ)


[参考]

「わたしの美しい庭」良かった