/** * Tooltip Component for Tutorial Guidance */ import React, { useState, useEffect, useRef } from 'react'; export interface TooltipProps { children: React.ReactElement; content: string; hint?: string; // Optional hint text (e.g., "Recommended for first-time users") position?: 'top' | 'bottom' | 'left' | 'right'; show: boolean; // Control visibility based on tutorial_watched tooltipId: string; // Unique ID for this tooltip currentTooltipId: string | null; // ID of currently active tooltip onDismiss?: () => void; // Callback when user dismisses/interacts with tooltip currentIndex?: number; // Current tooltip index (1-based) totalCount?: number; // Total number of tooltips onSkip?: () => void; // Callback when user skips tutorial } const Tooltip: React.FC = ({ children, content, hint, position = 'top', show, tooltipId, currentTooltipId, onDismiss, currentIndex, totalCount, onSkip, }) => { const [isVisible, setIsVisible] = useState(false); const [isFadingOut, setIsFadingOut] = useState(false); // Only show if this is the current tooltip and tutorial is enabled const shouldShow = show && currentTooltipId === tooltipId; const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); const triggerRef = useRef(null); const tooltipRef = useRef(null); const fadeOutTimeoutRef = useRef(null); useEffect(() => { // Clear any pending fade-out timeout if (fadeOutTimeoutRef.current) { clearTimeout(fadeOutTimeoutRef.current); fadeOutTimeoutRef.current = null; } if (shouldShow && triggerRef.current) { // If we were fading out, cancel it setIsFadingOut(false); // Small delay before showing to allow previous tooltip to fade out const showTimeout = setTimeout(() => { setIsVisible(true); setIsFadingOut(false); // Use setTimeout to ensure DOM is updated before calculating position setTimeout(() => { updatePosition(); }, 0); }, 200); // 200ms delay to allow fade-out animation return () => clearTimeout(showTimeout); } else if (isVisible && !shouldShow) { // Start fade-out animation setIsFadingOut(true); // Hide after fade-out animation completes fadeOutTimeoutRef.current = setTimeout(() => { setIsVisible(false); setIsFadingOut(false); }, 200); // Match CSS animation duration return () => { if (fadeOutTimeoutRef.current) { clearTimeout(fadeOutTimeoutRef.current); } }; } else if (!shouldShow && !isVisible) { // Ensure state is clean when not showing setIsFadingOut(false); } }, [shouldShow]); useEffect(() => { if (isVisible && tooltipRef.current) { // Update position after tooltip is rendered const updatePos = () => { requestAnimationFrame(() => { updatePosition(); }); }; updatePos(); const handleResize = () => updatePos(); const handleScroll = () => updatePos(); window.addEventListener('resize', handleResize); window.addEventListener('scroll', handleScroll, true); return () => { window.removeEventListener('resize', handleResize); window.removeEventListener('scroll', handleScroll, true); }; } }, [isVisible]); const updatePosition = () => { if (!triggerRef.current || !tooltipRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); const arrowSize = 9; // Arrow size in pixels const gap = 8; // Gap between trigger and tooltip let top = 0; let left = 0; switch (position) { case 'top': // Position above the trigger, accounting for arrow top = triggerRect.top - tooltipRect.height - gap; left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; break; case 'bottom': // Position below the trigger, accounting for arrow top = triggerRect.bottom + gap; left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; break; case 'left': // Position to the left of the trigger, accounting for arrow top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; left = triggerRect.left - tooltipRect.width - gap; break; case 'right': // Position to the right of the trigger, accounting for arrow top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2; left = triggerRect.right + gap; break; } // Keep tooltip within viewport (using viewport coordinates since tooltip is fixed) const padding = 8; if (left < padding) left = padding; if (left + tooltipRect.width > window.innerWidth - padding) { left = window.innerWidth - tooltipRect.width - padding; } if (top < padding) { top = padding; } if (top + tooltipRect.height > window.innerHeight - padding) { top = window.innerHeight - tooltipRect.height - padding; } setTooltipPosition({ top, left }); }; const handleDismiss = () => { setIsVisible(false); if (onDismiss) { onDismiss(); } }; // Always render children, but only show tooltip if it's the current one if (!show) { return children; } const clonedChild = React.cloneElement(children, { ref: (node: HTMLElement | null) => { triggerRef.current = node; // Preserve original ref if it exists (function refs only) const originalRef = (children as any).ref; if (typeof originalRef === 'function') { originalRef(node); } // Note: Object refs (React.MutableRefObject) are not preserved // as they may be read-only. This is acceptable since we're wrapping the element. }, }); return ( <> {clonedChild} {isVisible && ( <> {/* Backdrop */}
{/* Tooltip */}
{currentIndex !== undefined && totalCount !== undefined && (
{currentIndex} of {totalCount}
)}

{content}

{hint && (

{hint}

)}
{onSkip && ( )}
)} ); }; export default Tooltip;