import React, { useEffect, memo, useRef, useState, useCallback, useLayoutEffect } from 'react'; import styled, { css } from 'styled-components'; import type { JSX, PropsWithChildren } from 'react'; import type { TooltipProps } from '@redocly/theme/core/types'; import { useControl, useOutsideClick } from '@redocly/theme/core/hooks'; import { Portal } from '@redocly/theme/components/Portal/Portal'; import { calcAnchorPoint, resolvePlacement } from '@redocly/theme/core/utils'; function TooltipComponent({ children, isOpen, tip, withArrow = true, placement = 'top', fallbackPlacements, className = 'default', width, dataTestId, disabled = false, arrowPosition = 'center', onClick, }: PropsWithChildren): JSX.Element { const wrapperRef = useRef(null); const tooltipBodyRef = useRef(null); const { isOpened, handleOpen, handleClose } = useControl(isOpen); const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); const [activePlacement, setActivePlacement] = useState(placement); const activeArrowPosition = activePlacement === placement ? arrowPosition : 'center'; useOutsideClick(wrapperRef, handleClose); const isControlled = isOpen !== undefined; const updateTooltipPosition = useCallback((): void => { if (!isOpened || !wrapperRef.current) return; const triggerRect = wrapperRef.current.getBoundingClientRect(); const tooltipWidth = tooltipBodyRef.current?.offsetWidth ?? 0; const tooltipHeight = tooltipBodyRef.current?.offsetHeight ?? 0; const resolved = resolvePlacement({ triggerRect, tooltipWidth, tooltipHeight, placement, arrowPosition, fallbackPlacements, }); const resolvedArrow = resolved === placement ? arrowPosition : 'center'; setTooltipPosition(calcAnchorPoint(triggerRect, resolved, resolvedArrow)); setActivePlacement(resolved); }, [isOpened, placement, arrowPosition, fallbackPlacements]); useLayoutEffect(() => { if (isOpened && wrapperRef.current) { updateTooltipPosition(); const handleScroll = () => updateTooltipPosition(); const handleResize = () => updateTooltipPosition(); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleResize); }; } }, [isOpened, placement, updateTooltipPosition]); useEffect(() => { if (isOpen && !disabled) { handleOpen(); } else { handleClose(); } }, [isOpen, handleOpen, handleClose, disabled]); const controllers = !isControlled && !disabled && { onMouseEnter: handleOpen, onMouseLeave: handleClose, onClick: (e: React.MouseEvent) => { onClick?.(e); handleClose(); }, onFocus: handleOpen, onBlur: handleClose, }; return ( {children} {isOpened && !disabled && ( {tip} )} ); } export const Tooltip = memo>(TooltipComponent); const PLACEMENTS = { top: css>` ${({ withArrow, arrowPosition }) => withArrow && arrowPosition === 'left' ? css` transform: translate(0, -100%); margin-top: -10px; ` : arrowPosition === 'right' ? css` transform: translate(-100%, -100%); margin-top: -10px; ` : css` transform: translate(-50%, -100%); margin-top: -10px; `} ${({ withArrow, arrowPosition }) => withArrow && css` &::after { border-left: 14px solid transparent; border-right: 14px solid transparent; border-top-width: 8px; border-top-style: solid; border-radius: 2px; bottom: 0; ${arrowPosition === 'left' && 'left: 16px; transform: translateY(99%);'} ${arrowPosition === 'center' && 'left: 50%; transform: translate(-50%, 99%);'} ${arrowPosition === 'right' && 'right: 16px; transform: translateY(99%);'} } `} `, bottom: css>` ${({ withArrow, arrowPosition }) => withArrow && arrowPosition === 'left' ? css` transform: translate(0, 10px); margin-top: 0; ` : arrowPosition === 'right' ? css` transform: translate(-100%, 10px); margin-top: 0; ` : css` transform: translate(-50%, 10px); margin-top: 0; `} ${({ withArrow, arrowPosition }) => withArrow && css` &::after { border-left: 14px solid transparent; border-right: 14px solid transparent; border-bottom-width: 8px; border-bottom-style: solid; border-radius: 0 0 2px 2px; top: 0; ${arrowPosition === 'left' && 'left: 16px; transform: translateY(-99%);'} ${arrowPosition === 'center' && 'left: 50%; transform: translate(-50%, -99%);'} ${arrowPosition === 'right' && 'right: 16px; transform: translateY(-99%);'} } `} `, left: css>` transform: translate(-100%, -50%); margin-left: -10px; ${({ withArrow, arrowPosition }) => withArrow && css` &::after { border-top: 14px solid transparent; border-bottom: 14px solid transparent; border-left-width: 8px; border-left-style: solid; border-radius: 2px 0 0 2px; top: 50%; right: 0; ${arrowPosition === 'top' && 'top: 16px; transform: translateX(99%);'} ${arrowPosition === 'center' && 'top: 50%; transform: translate(99%, -50%);'} ${arrowPosition === 'bottom' && 'bottom: 16px; transform: translateX(99%);'} } `} `, right: css>` transform: translate(0, -50%); margin-left: 10px; ${({ withArrow, arrowPosition }) => withArrow && css` &::after { border-top: 14px solid transparent; border-bottom: 14px solid transparent; border-right-width: 8px; border-right-style: solid; border-radius: 0 2px 2px 0; top: 50%; left: 0; ${arrowPosition === 'top' && 'top: 16px; transform: translateX(-99%);'} ${arrowPosition === 'center' && 'top: 50%; transform: translate(-99%, -50%);'} ${arrowPosition === 'bottom' && 'bottom: 16px; transform: translateX(-99%);'} } `} `, }; const TooltipWrapper = styled.div` position: relative; display: flex; `; const TooltipBody = styled.span< Pick, 'placement' | 'withArrow' | 'arrowPosition'> & { width?: string; } >` display: inline-block; padding: var(--tooltip-padding); max-width: ${({ width }) => width || 'var(--tooltip-max-width)'}; white-space: normal; word-break: normal; overflow-wrap: break-word; border-radius: var(--border-radius-md); transition: opacity 0.3s ease-out; font-size: var(--font-size-base); line-height: var(--line-height-base); z-index: var(--z-index-overlay); &::after { position: absolute; content: ' '; display: inline-block; width: 0; height: 0; border-color: var(--tooltip-arrow-color, var(--tooltip-bg-color)); } background: var(--tooltip-bg-color); color: var(--tooltip-text-color); border: var(--tooltip-border-width, 0) var(--tooltip-border-style, solid) var(--tooltip-border-color, transparent); box-shadow: var(--bg-raised-shadow); width: ${({ width }) => width || 'max-content'}; ${({ placement }) => css` ${PLACEMENTS[placement]}; `} `;