229 lines
6.5 KiB
TypeScript
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
|
|
)}
|
|
</>
|
|
);
|
|
}
|