import { clamp } from 'lodash'; import React, { useLayoutEffect, useMemo, useRef } from 'react'; import { SilkeBox } from '../silke-box'; import { PopoverAnchor, PopoverOrigin, SilkePopover } from '../silke-popover'; import { SilkeTitle } from '../silke-text'; import styles from './silke-tooltip.scss'; import { createTooltipPath } from './utils'; export type SilkeTooltipSize = 'tiny' | 'small' | 'base' | 'large' | 'auto'; export type SilkeTooltipKind = 'default' | 'info'; export type TooltipHorizontalPosition = 'center' | 'left' | 'right'; let CLIP_PATH_ID = 0; function getOppositeOrigin(origin: PopoverOrigin = 'bottom-center'): PopoverOrigin { const [v, h] = origin.split('-'); if (v === 'bottom') return ('top-' + h) as PopoverOrigin; if (v === 'top') return ('bottom-' + h) as PopoverOrigin; if (h === 'left') return (v + '-right') as PopoverOrigin; if (h === 'right') return (v + '-left') as PopoverOrigin; return origin; } export type SilkeTooltipProps = { kind?: SilkeTooltipKind; noArrow?: boolean; /** * Element to show on */ anchor: PopoverAnchor; image?: string; show: boolean; size?: SilkeTooltipSize; targetOrigin?: PopoverOrigin; anchorOrigin?: PopoverOrigin; title?: string; children?: React.ReactNode; offsetX?: number; offsetY?: number; zIndex?: number; onRequestClose?: () => void; onClick?: () => void; onImageClick?: () => void; }; /** * Tooltip component. Needs an anchor, shows a little tip. */ export function SilkeTooltip({ kind, anchor, anchorOrigin, targetOrigin, show, children, noArrow, image, size, title, offsetX, offsetY, zIndex, onClick, onRequestClose, onImageClick, }: SilkeTooltipProps) { const targetRef = useRef(null); const pathRef = useRef(null); const clipPathRef = useRef(null); const clipPathId = useMemo(() => 'silke-clip-path-' + CLIP_PATH_ID++, []); const anchorItem = 'current' in anchor ? anchor.current : anchor; if (!kind) kind = size ? 'info' : 'default'; if (!size && (kind === 'info' || image)) size = 'base'; useLayoutEffect(() => { if (!show) return; const tooltipEl = targetRef.current; if (tooltipEl) { const updatePath = () => { const anchorRect = [0, 0, 0, 0]; if ('current' in anchor) { const anchorEl = anchor.current; if (anchorEl) { const rect = anchorEl.getBoundingClientRect(); anchorRect[0] = rect.left; anchorRect[1] = rect.top; anchorRect[2] = rect.width; anchorRect[3] = rect.height; } else return; } else if (Array.isArray(anchor)) { anchorRect[0] = anchor[0] || 0; anchorRect[1] = anchor[1] || 0; } const { top, left, width, height, bottom, right } = tooltipEl.getBoundingClientRect(); if (!width || !height || bottom < 0) return; let pointDir: 'up' | 'down' | 'left' | 'right' = 'up'; let offset = 0.5; if (bottom < anchorRect[1]) { pointDir = 'down'; } else if (right < anchorRect[0]) { pointDir = 'right'; } else if (left > anchorRect[0] + anchorRect[2]) { pointDir = 'left'; } if (pointDir === 'down' || pointDir === 'up') { offset = clamp((anchorRect[0] - left + anchorRect[2] / 2) / width, 0, 1); } else { offset = clamp((anchorRect[1] - top + anchorRect[3] / 2) / height, 0, 1); } const d = createTooltipPath(pointDir, offset, width, height, kind === 'info' ? 8 : 4); clipPathRef.current?.setAttribute('d', d); pathRef.current?.setAttribute('d', d); }; const observer = new ResizeObserver(updatePath); observer.observe(tooltipEl); updatePath(); setTimeout(updatePath, 50); const interval = setInterval(updatePath, 500); return () => { observer.disconnect(); clearInterval(interval); }; } }, [show, anchor, noArrow, anchorItem, children, kind]); if (!show) return null; if (!anchorOrigin) anchorOrigin = 'bottom-center'; if (!targetOrigin) targetOrigin = getOppositeOrigin(anchorOrigin); const [anchorV, anchorH] = anchorOrigin.split('-'); let cl = styles.root; cl += ' ' + styles[kind || 'default']; if (size) cl += ' ' + styles[size]; return (
{image && } {title && ( {title} )} {children}
); }