import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils'; import { useTooltipPosition } from '../../hooks/useTooltipPosition'; import { TooltipContent, TooltipTip } from './tooltip/TooltipContent'; import { useSidebarContext } from '../../contexts/SidebarContext'; import styles from './tooltip/Tooltip.module.css' export interface TooltipProps { sidebarTooltip?: boolean; position?: 'right' | 'left' | 'top' | 'bottom'; content?: React.ReactNode; tips?: TooltipTip[]; children: React.ReactElement; offset?: number; maxWidth?: number | string; open?: boolean; onOpenChange?: (open: boolean) => void; arrow?: boolean; portalTarget?: HTMLElement; header?: { title: string; logo?: React.ReactNode; }; } export const Tooltip: React.FC = ({ sidebarTooltip = false, position = 'right', content, tips, children, offset: gap = 8, maxWidth = 280, open: controlledOpen, onOpenChange, arrow = false, portalTarget, header, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); const triggerRef = useRef(null); const tooltipRef = useRef(null); const hoverTimeoutRef = useRef | null>(null); // Get sidebar context for tooltip positioning const sidebarContext = sidebarTooltip ? useSidebarContext() : null; // Always use controlled mode - if no controlled props provided, use internal state const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; const handleOpenChange = (newOpen: boolean) => { if (isControlled) { onOpenChange?.(newOpen); } else { setInternalOpen(newOpen); } // Reset pin state when closing if (!newOpen) { setIsPinned(false); } }; const handleTooltipClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsPinned(true); }; const handleDocumentClick = (e: MouseEvent) => { // If tooltip is pinned and we click outside of it, unpin it if (isPinned && isClickOutside(e, tooltipRef.current)) { setIsPinned(false); handleOpenChange(false); } }; // Use the positioning hook const { coords, positionReady } = useTooltipPosition({ open, sidebarTooltip, position, gap, triggerRef, tooltipRef, sidebarRefs: sidebarContext?.sidebarRefs, sidebarState: sidebarContext?.sidebarState }); // Add document click listener for unpinning useEffect(() => { if (isPinned) { return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); } }, [isPinned]); const getArrowClass = () => { // No arrow for sidebar tooltips if (sidebarTooltip) return null; switch (position) { case 'top': return "tooltip-arrow tooltip-arrow-top"; case 'bottom': return "tooltip-arrow tooltip-arrow-bottom"; case 'left': return "tooltip-arrow tooltip-arrow-left"; case 'right': return "tooltip-arrow tooltip-arrow-right"; default: return "tooltip-arrow tooltip-arrow-right"; } }; const getArrowStyleClass = (arrowClass: string) => { const styleKey = arrowClass.split(' ')[1]; // Handle both kebab-case and camelCase CSS module exports return styles[styleKey as keyof typeof styles] || styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] || ''; }; // Only show tooltip when position is ready and correct const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true); const tooltipElement = shouldShowTooltip ? (
{isPinned && ( )} {arrow && getArrowClass() && (
)} {header && (
{header.logo || Stirling PDF}
{header.title}
)}
) : null; const handleMouseEnter = (e: React.MouseEvent) => { // Clear any existing timeout if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } // Only show on hover if not pinned if (!isPinned) { handleOpenChange(true); } (children.props as any)?.onMouseEnter?.(e); }; const handleMouseLeave = (e: React.MouseEvent) => { // Only hide on mouse leave if not pinned if (!isPinned) { // Add a small delay to prevent flickering hoverTimeoutRef.current = setTimeout(() => { handleOpenChange(false); }, 100); } (children.props as any)?.onMouseLeave?.(e); }; const handleClick = (e: React.MouseEvent) => { // Toggle pin state on click if (open) { setIsPinned(!isPinned); } else { handleOpenChange(true); setIsPinned(true); } (children.props as any)?.onClick?.(e); }; // Take the child element and add tooltip behavior to it const childWithTooltipHandlers = React.cloneElement(children as any, { // Keep track of the element for positioning ref: (node: HTMLElement) => { triggerRef.current = node; // Don't break if the child already has a ref const originalRef = (children as any).ref; if (typeof originalRef === 'function') { originalRef(node); } else if (originalRef && typeof originalRef === 'object') { originalRef.current = node; } }, // Add mouse events to show/hide tooltip onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onClick: handleClick, }); return ( <> {childWithTooltipHandlers} {portalTarget && document.body.contains(portalTarget) ? tooltipElement && createPortal(tooltipElement, portalTarget) : tooltipElement} ); };