Files
govoplan-core/webui/src/components/HoverTooltip.tsx
2026-07-14 13:22:10 +02:00

229 lines
6.5 KiB
TypeScript

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<CSSProperties, "borderColor" | "background"> {
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<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(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 (
<>
<span
ref={triggerRef}
className={className}
tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
{children}
</span>
{isOpen && typeof document !== "undefined" && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof content === "string" ? translateText(content) : content}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}