import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { usePlatformLanguage } from "../i18n/LanguageContext"; export type HoverTooltipTone = "default" | "danger"; type TooltipPosition = { top: number; left: number; arrowLeft: number; placement: "top" | "bottom"; }; export type HoverTooltipProps = { content: ReactNode; children: ReactNode; className?: string; tone?: HoverTooltipTone; ariaLabel?: string; triggerTabIndex?: number; openDelayMs?: number; }; const VIEWPORT_MARGIN = 12; const TRIGGER_GAP = 10; const BASE_TOOLTIP_STYLE: CSSProperties = { position: "fixed", zIndex: 20000, width: "max-content", maxWidth: "min(320px, calc(100vw - 48px))", borderRadius: 7, boxShadow: "var(--shadow-popover)", fontSize: 12, lineHeight: 1.4, padding: "9px 10px", whiteSpace: "normal", pointerEvents: "none" }; function clamp(value: number, min: number, max: number) { if (max < min) return min; return Math.min(Math.max(value, min), max); } function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties { if (tone === "danger") { return { border: "1px solid var(--border-danger-soft)", background: "var(--danger-muted-bg)", color: "var(--danger-text-tooltip)", fontWeight: 700 }; } return { border: "1px solid var(--line-dark)", background: "var(--surface)", color: "var(--text)", fontWeight: 500 }; } function arrowStyleForTone(tone: HoverTooltipTone): Pick { if (tone === "danger") { return { borderColor: "var(--border-danger-soft)", background: "var(--danger-muted-bg)" }; } return { borderColor: "var(--line-dark)", background: "var(--surface)" }; } export default function HoverTooltip({ content, children, className = "", tone = "default", ariaLabel, triggerTabIndex, openDelayMs = 350 }: HoverTooltipProps) { const { translateText } = usePlatformLanguage(); const tooltipId = useId(); const triggerRef = useRef(null); const tooltipRef = useRef(null); const openTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState(null); const clearOpenTimer = useCallback(() => { if (openTimerRef.current !== null) { window.clearTimeout(openTimerRef.current); openTimerRef.current = null; } }, []); const openWithDelay = useCallback(() => { if (!content) return; clearOpenTimer(); openTimerRef.current = window.setTimeout(() => { openTimerRef.current = null; setIsOpen(true); }, openDelayMs); }, [clearOpenTimer, content, openDelayMs]); const close = useCallback(() => { clearOpenTimer(); setIsOpen(false); }, [clearOpenTimer]); const updatePosition = useCallback(() => { const trigger = triggerRef.current; const tooltip = tooltipRef.current; if (!trigger || !tooltip) return; const triggerRect = trigger.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); const triggerCenterX = triggerRect.left + triggerRect.width / 2; const preferredLeft = triggerCenterX - tooltipRect.width / 2; const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN); const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP; const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN; const bottomCandidate = triggerRect.bottom + TRIGGER_GAP; const top = hasRoomAbove ? topCandidate : clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN); setPosition({ top, left, arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12), placement: hasRoomAbove ? "top" : "bottom" }); }, []); useLayoutEffect(() => { if (!isOpen) { setPosition(null); return; } updatePosition(); const frame = window.requestAnimationFrame(updatePosition); return () => window.cancelAnimationFrame(frame); }, [isOpen, updatePosition]); useEffect(() => { if (!isOpen) return undefined; const handleScrollOrResize = () => updatePosition(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; window.addEventListener("scroll", handleScrollOrResize, true); window.addEventListener("resize", handleScrollOrResize); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("scroll", handleScrollOrResize, true); window.removeEventListener("resize", handleScrollOrResize); window.removeEventListener("keydown", handleKeyDown); }; }, [close, isOpen, updatePosition]); useEffect(() => clearOpenTimer, [clearOpenTimer]); const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined; const tooltipStyle: CSSProperties = { ...BASE_TOOLTIP_STYLE, ...tooltipStyleForTone(tone), top: position?.top ?? -9999, left: position?.left ?? -9999, opacity: position ? 1 : 0 }; const arrowColors = arrowStyleForTone(tone); const arrowStyle: CSSProperties = position?.placement === "bottom" ? { position: "absolute", left: position.arrowLeft, top: 0, width: 9, height: 9, borderLeft: "1px solid", borderTop: "1px solid", ...arrowColors, transform: "translate(-50%, -5px) rotate(45deg)" } : { position: "absolute", left: position?.arrowLeft ?? 16, top: "100%", width: 9, height: 9, borderRight: "1px solid", borderBottom: "1px solid", ...arrowColors, transform: "translate(-50%, -5px) rotate(45deg)" }; return ( <> {children} {isOpen && typeof document !== "undefined" && createPortal( , document.body )} ); }