Introduce a reusable Tooltip component with delayed hover reveal, viewport-aware horizontal nudging, and smooth CSS entrance animation. Supports top/bottom positioning via a data-side attribute. FilterPanel now wraps the auto-redact checkbox in a Tooltip that explains what auto-redaction detects. When sensitive messages exist in the current view, a red pill badge displays the count next to the label, giving users immediate visibility into how many messages contain detectable secrets before toggling auto-redact on. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
82 lines
2.3 KiB
TypeScript
82 lines
2.3 KiB
TypeScript
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
|
|
interface Props {
|
|
content: React.ReactNode;
|
|
children: React.ReactElement;
|
|
delayMs?: number;
|
|
side?: "top" | "bottom";
|
|
}
|
|
|
|
export function Tooltip({ content, children, delayMs = 150, side = "top" }: Props) {
|
|
const [visible, setVisible] = useState(false);
|
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
|
const triggerRef = useRef<HTMLElement>(null);
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
const show = useCallback(() => {
|
|
timerRef.current = setTimeout(() => setVisible(true), delayMs);
|
|
}, [delayMs]);
|
|
|
|
const hide = useCallback(() => {
|
|
clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
}, []);
|
|
|
|
// Clean up timer on unmount
|
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
|
|
|
// Recompute position when visible
|
|
useEffect(() => {
|
|
if (!visible || !triggerRef.current) return;
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
setPosition({
|
|
x: rect.left + rect.width / 2,
|
|
y: side === "top" ? rect.top : rect.bottom,
|
|
});
|
|
}, [visible, side]);
|
|
|
|
// Nudge tooltip horizontally if it overflows the viewport
|
|
useEffect(() => {
|
|
if (!visible || !tooltipRef.current || !position) return;
|
|
const el = tooltipRef.current;
|
|
const tooltipRect = el.getBoundingClientRect();
|
|
const pad = 8;
|
|
if (tooltipRect.left < pad) {
|
|
el.style.transform = `translateX(${pad - tooltipRect.left}px)`;
|
|
} else if (tooltipRect.right > window.innerWidth - pad) {
|
|
el.style.transform = `translateX(${window.innerWidth - pad - tooltipRect.right}px)`;
|
|
} else {
|
|
el.style.transform = "";
|
|
}
|
|
}, [visible, position]);
|
|
|
|
return (
|
|
<>
|
|
{React.cloneElement(children, {
|
|
ref: triggerRef,
|
|
onMouseEnter: show,
|
|
onMouseLeave: hide,
|
|
onFocus: show,
|
|
onBlur: hide,
|
|
})}
|
|
{visible && position && (
|
|
<div
|
|
ref={tooltipRef}
|
|
role="tooltip"
|
|
className="tooltip-popup"
|
|
style={{
|
|
position: "fixed",
|
|
left: position.x,
|
|
top: side === "top" ? position.y - 8 : position.y + 8,
|
|
zIndex: 9999,
|
|
}}
|
|
data-side={side}
|
|
>
|
|
{content}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|