intermittent commit
This commit is contained in:
228
webui/src/components/HoverTooltip.tsx
Normal file
228
webui/src/components/HoverTooltip.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user