Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions src/app/tooltip-demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ export default function TooltipDemoPage() {

return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 p-10">
<h1 className="mb-2 text-3xl font-bold text-gray-900 dark:text-white">
Tooltip System Demo
</h1>
<h1 className="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Tooltip System Demo</h1>
<p className="mb-10 text-gray-500 dark:text-gray-400">
Hover or focus the buttons below to see tooltips. Anomaly detection is active — rapidly
toggling a tooltip or keeping it open for &gt;10 s will log an anomaly.
Expand Down Expand Up @@ -63,9 +61,7 @@ export default function TooltipDemoPage() {

{/* Placement showcase */}
<section className="mb-12">
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">
Placements
</h2>
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">Placements</h2>
<div className="flex flex-wrap items-center gap-10">
{PLACEMENTS.map((placement) => (
<Tooltip
Expand Down Expand Up @@ -97,8 +93,7 @@ export default function TooltipDemoPage() {
<Tooltip
content={
<span>
<strong>Tip:</strong> This tooltip supports{' '}
<em>rich React content</em>.
<strong>Tip:</strong> This tooltip supports <em>rich React content</em>.
</span>
}
placement="right"
Expand All @@ -118,6 +113,42 @@ export default function TooltipDemoPage() {
</Tooltip>
</section>

{/* YouTube preview tooltip */}
<section className="mb-12">
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">
YouTube Preview
</h2>
<Tooltip
content={
<div className="w-[320px] max-w-full overflow-hidden rounded-md">
<iframe
className="h-44 w-full"
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube preview"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
}
placement="bottom"
delayMs={100}
zoomScale={zoomScale}
interactive
onAnomaly={(type) => onOpen(`youtube-${type}`)}
>
<button
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
onFocus={() => onOpen('youtube')}
onBlur={() => onClose('youtube')}
onMouseEnter={() => onOpen('youtube')}
onMouseLeave={() => onClose('youtube')}
>
Hover for YouTube preview
</button>
</Tooltip>
</section>

{/* Disabled tooltip */}
<section className="mb-12">
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">
Expand All @@ -133,9 +164,7 @@ export default function TooltipDemoPage() {
{/* Anomaly log */}
<section>
<div className="mb-3 flex items-center gap-4">
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200">
Anomaly Log
</h2>
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200">Anomaly Log</h2>
{anomalies.length > 0 && (
<button
onClick={clearAnomalies}
Expand Down
61 changes: 49 additions & 12 deletions src/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface TooltipProps {
delayMs?: number;
/** Whether the tooltip is disabled */
disabled?: boolean;
/** Whether the tooltip should allow pointer events for interactive content */
interactive?: boolean;
/** Optional extra class for the tooltip bubble */
className?: string;
/** Optional zoom scale for the tooltip bubble (1 = normal size) */
Expand Down Expand Up @@ -49,11 +51,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
placement = 'top',
delayMs = 200,
disabled = false,
interactive = false,
className = '',
zoomScale = 1,
onAnomaly,
}) => {
const [visible, setVisible] = useState(false);
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const tooltipId = useId();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const openCountRef = useRef(0);
Expand All @@ -66,6 +70,35 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
};

const hide = useCallback(
(tooltipHovered = isTooltipHovered) => {
clearTimer();
if (interactive) {
timerRef.current = setTimeout(() => {
if (!tooltipHovered) {
setVisible(false);
}
}, 100);
} else {
setVisible(false);
}
},
[interactive, isTooltipHovered],
);

const handleTooltipEnter = useCallback(() => {
if (!interactive) return;
clearTimer();
setIsTooltipHovered(true);
setVisible(true);
}, [interactive]);

const handleTooltipLeave = useCallback(() => {
if (!interactive) return;
setIsTooltipHovered(false);
hide(false);
}, [interactive, hide]);

/** Anomaly detection: flag if tooltip opens >5 times in 3 seconds */
const trackOpen = useCallback(() => {
const now = Date.now();
Expand All @@ -90,18 +123,24 @@ export const Tooltip: React.FC<TooltipProps> = ({
}, delayMs);
}, [disabled, delayMs, trackOpen]);

const hide = useCallback(() => {
clearTimer();
setVisible(false);
}, []);

const child = React.Children.only(children);
const normalizedZoom = Math.max(0.5, Math.min(3, zoomScale ?? 1));
const tooltipStyle = {
transform: normalizedZoom === 1 ? undefined : `scale(${normalizedZoom})`,
transformOrigin: TRANSFORM_ORIGINS[placement],
};

const tooltipClasses = [
interactive
? 'pointer-events-auto whitespace-normal max-w-[28rem]'
: 'pointer-events-none whitespace-nowrap',
'absolute z-50 rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg dark:bg-gray-700',
PLACEMENT_CLASSES[placement],
className,
]
.filter(Boolean)
.join(' ');

return (
<span className="relative inline-flex">
{React.cloneElement(child, {
Expand Down Expand Up @@ -129,13 +168,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
id={tooltipId}
role="tooltip"
style={tooltipStyle}
className={[
'pointer-events-none absolute z-50 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg dark:bg-gray-700',
PLACEMENT_CLASSES[placement],
className,
]
.filter(Boolean)
.join(' ')}
onMouseEnter={handleTooltipEnter}
onMouseLeave={handleTooltipLeave}
onFocus={handleTooltipEnter}
onBlur={handleTooltipLeave}
className={tooltipClasses}
>
{content}
</span>
Expand Down
59 changes: 44 additions & 15 deletions src/components/ui/__tests__/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('Tooltip', () => {
render(
<Tooltip content="Hello">
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.queryByRole('tooltip')).toBeNull();
Expand All @@ -31,7 +31,7 @@ describe('Tooltip', () => {
render(
<Tooltip content="Hello" delayMs={200}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByRole('button'));
expect(screen.queryByRole('tooltip')).toBeNull();
Expand All @@ -43,7 +43,7 @@ describe('Tooltip', () => {
render(
<Tooltip content="Hello" delayMs={0}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByRole('button'));
act(() => vi.advanceTimersByTime(0));
Expand All @@ -56,7 +56,7 @@ describe('Tooltip', () => {
render(
<Tooltip content="Focus tip" delayMs={0}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
fireEvent.focus(screen.getByRole('button'));
act(() => vi.advanceTimersByTime(0));
Expand All @@ -69,7 +69,7 @@ describe('Tooltip', () => {
render(
<Tooltip content="Hidden" disabled delayMs={0}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByRole('button'));
act(() => vi.advanceTimersByTime(0));
Expand All @@ -80,7 +80,7 @@ describe('Tooltip', () => {
render(
<Tooltip content="Aria tip" delayMs={0}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByRole('button'));
act(() => vi.advanceTimersByTime(0));
Expand All @@ -93,20 +93,45 @@ describe('Tooltip', () => {
render(
<Tooltip content="Zoom tip" placement="right" delayMs={0} zoomScale={1.5}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByRole('button'));
act(() => vi.advanceTimersByTime(0));
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveStyle({ transform: 'scale(1.5)', transformOrigin: 'left center' });
});

it('supports interactive content and keeps the tooltip open while hovering the bubble', () => {
render(
<Tooltip content={<button type="button">Play</button>} interactive delayMs={0}>
<button>trigger</button>
</Tooltip>,
);

const trigger = screen.getByText('trigger');
fireEvent.mouseEnter(trigger);
act(() => vi.advanceTimersByTime(0));

const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveClass('pointer-events-auto');

fireEvent.mouseLeave(trigger);
fireEvent.mouseEnter(tooltip);
act(() => vi.advanceTimersByTime(100));

expect(screen.getByRole('tooltip')).toBeInTheDocument();

fireEvent.mouseLeave(tooltip);
act(() => vi.advanceTimersByTime(100));
expect(screen.queryByRole('tooltip')).toBeNull();
});

it('calls onAnomaly after rapid toggles', () => {
const onAnomaly = vi.fn();
render(
<Tooltip content="Rapid" delayMs={0} onAnomaly={onAnomaly}>
<button>trigger</button>
</Tooltip>
</Tooltip>,
);
const btn = screen.getByRole('button');
// Open 6 times quickly
Expand Down Expand Up @@ -135,28 +160,32 @@ describe('useTooltipAnomalyDetection', () => {
it('detects rapid-toggle anomaly', () => {
const onAnomaly = vi.fn();
const { result } = renderHook(() =>
useTooltipAnomalyDetection({ rapidToggleThreshold: 3, rapidToggleWindowMs: 3000, onAnomaly })
useTooltipAnomalyDetection({ rapidToggleThreshold: 3, rapidToggleWindowMs: 3000, onAnomaly }),
);
act(() => {
for (let i = 0; i < 4; i++) result.current.onOpen('tip1');
});
expect(onAnomaly).toHaveBeenCalledWith(expect.objectContaining({ type: 'rapid-toggle', tooltipId: 'tip1' }));
expect(onAnomaly).toHaveBeenCalledWith(
expect.objectContaining({ type: 'rapid-toggle', tooltipId: 'tip1' }),
);
});

it('detects long-hover anomaly', () => {
const onAnomaly = vi.fn();
const { result } = renderHook(() =>
useTooltipAnomalyDetection({ longHoverThresholdMs: 5000, onAnomaly })
useTooltipAnomalyDetection({ longHoverThresholdMs: 5000, onAnomaly }),
);
act(() => result.current.onOpen('tip2'));
act(() => vi.advanceTimersByTime(5000));
expect(onAnomaly).toHaveBeenCalledWith(expect.objectContaining({ type: 'long-hover', tooltipId: 'tip2' }));
expect(onAnomaly).toHaveBeenCalledWith(
expect.objectContaining({ type: 'long-hover', tooltipId: 'tip2' }),
);
});

it('cancels long-hover timer on close', () => {
const onAnomaly = vi.fn();
const { result } = renderHook(() =>
useTooltipAnomalyDetection({ longHoverThresholdMs: 5000, onAnomaly })
useTooltipAnomalyDetection({ longHoverThresholdMs: 5000, onAnomaly }),
);
act(() => result.current.onOpen('tip3'));
act(() => result.current.onClose('tip3'));
Expand All @@ -167,7 +196,7 @@ describe('useTooltipAnomalyDetection', () => {
it('detects multi-open anomaly', () => {
const onAnomaly = vi.fn();
const { result } = renderHook(() =>
useTooltipAnomalyDetection({ multiOpenThreshold: 2, onAnomaly })
useTooltipAnomalyDetection({ multiOpenThreshold: 2, onAnomaly }),
);
act(() => {
result.current.onOpen('a');
Expand All @@ -179,7 +208,7 @@ describe('useTooltipAnomalyDetection', () => {

it('clearAnomalies resets the log', () => {
const { result } = renderHook(() =>
useTooltipAnomalyDetection({ rapidToggleThreshold: 2, rapidToggleWindowMs: 3000 })
useTooltipAnomalyDetection({ rapidToggleThreshold: 2, rapidToggleWindowMs: 3000 }),
);
act(() => {
result.current.onOpen('x');
Expand Down
Loading