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()
で取得して計算する
sample
所管
重なり順 (z-index
) を考えずに単純に tooltip を表示する要素内に表示させるならめちゃくちゃ簡単に作れた。
重なり順を考慮すると Portal で外に出して、いわゆる JavaScript で要素の位置を計算しなければならないのでちょっと大変。画面幅が変わった時に位置を変える計算も含めるともっと大変になる。どこまで考慮するかで自作レベルで済むのか react-tooltip とか chakua-ui とかのライブラリを使ってしまうかを判断するのが良い。(積極的に自作の tooltip 選択することは思うけど…)
[参考]
- Tailwind CSS でツールチップを作る
- 超シンプルなツールチップコンポーネントを自作した
- Tooltip(ツールチップ)を実装しながらReact Hookを学ぶ | アールエフェクト
- Tooltip - Chakra UI
- ポインターイベントの使用 - Web API | MDN
- React v16.4.0: Pointer Events – React Blog
- GitHub - wwayne/react-tooltip: react tooltip component
- React + TypeScript: useRefの3つの型指定と初期値の使い方 - Qiita
- ポータル – React
- React Hooks コンポーネント外のDOMに子コンポーネントを追加したい。 - かもメモ
- Element.getBoundingClientRect() - Web APIs | MDN
- HTMLElement.offsetHeight - Web APIs | MDN
- JavaScript | ウィンドウの幅と高さを取得する(window.innerWidth,window.outerWidth,他)