fix(ui): hand tooltips over between adjacent triggers (#2365)

This commit is contained in:
Hampus
2026-09-02 17:27:46 +02:00
committed by GitHub
parent e836686a71
commit 1acd61a112
3 changed files with 64 additions and 3 deletions
@@ -34,6 +34,7 @@ import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {useExclusiveTooltip} from './TooltipExclusivity';
import {isTooltipHandoffWarm, markTooltipOpen} from './TooltipHandoff';
import {getTooltipScrollSuppressRemainingMs, subscribeTooltipScrollHide} from './TooltipScrollCoordinator';
const logger = new Logger('Tooltip');
@@ -466,9 +467,10 @@ export const Tooltip = observer(
}, [updatePositionNow, cancelRaf, getTooltipOwnerWindow]);
const getVisibilityDriverDelayMs = useCallback(() => {
const scrollSuppressRemainingMs = getTooltipScrollSuppressRemainingMs();
const openDelay = isTooltipHandoffWarm() ? 0 : delay;
switch (true) {
case delay > 0 || scrollSuppressRemainingMs > 0:
return Math.max(delay, scrollSuppressRemainingMs > 0 ? scrollSuppressRemainingMs + 1 : 0);
case openDelay > 0 || scrollSuppressRemainingMs > 0:
return Math.max(openDelay, scrollSuppressRemainingMs > 0 ? scrollSuppressRemainingMs + 1 : 0);
default:
return 0;
}
@@ -602,6 +604,10 @@ export const Tooltip = observer(
cancelRaf();
};
}, [cancelRaf, clearDelayTimer, clearPointerFocusTimeout]);
useEffect(() => {
if (!shouldRenderTooltip) return;
return markTooltipOpen();
}, [shouldRenderTooltip]);
useEffect(() => {
if (!shouldRenderTooltip) return;
return subscribeTooltipScrollHide(dismissTooltip);
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const TOOLTIP_HANDOFF_GRACE_MS = 300;
let openCount = 0;
let warmUntil = 0;
let graceTimer: ReturnType<typeof setTimeout> | null = null;
function now(): number {
return typeof performance === 'undefined' ? Date.now() : performance.now();
}
function clearGraceTimer(): void {
if (graceTimer == null) return;
clearTimeout(graceTimer);
graceTimer = null;
}
function armGraceTimer(): void {
clearGraceTimer();
const remaining = warmUntil - now();
if (remaining <= 0) return;
graceTimer = setTimeout(() => {
graceTimer = null;
warmUntil = 0;
}, remaining);
}
export function markTooltipOpen(): () => void {
openCount += 1;
warmUntil = 0;
clearGraceTimer();
let released = false;
return () => {
if (released) return;
released = true;
openCount = Math.max(0, openCount - 1);
if (openCount === 0) {
warmUntil = now() + TOOLTIP_HANDOFF_GRACE_MS;
armGraceTimer();
}
};
}
export function isTooltipHandoffWarm(): boolean {
return openCount > 0 || now() < warmUntil;
}
@@ -17,6 +17,7 @@ import {
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useExclusiveTooltip} from './TooltipExclusivity';
import {isTooltipHandoffWarm, markTooltipOpen} from './TooltipHandoff';
import {subscribeTooltipScrollHide} from './TooltipScrollCoordinator';
export interface HoverFloatingTooltipState {
@@ -67,7 +68,10 @@ export function useHoverFloatingTooltip(hoverDelay = 500, placement: Placement =
middleware,
whileElementsMounted: hoverFloatingAutoUpdate,
});
const hoverDelayConfig = useMemo(() => ({open: hoverDelay, close: CLOSE_DELAY_MS}), [hoverDelay]);
const hoverDelayConfig = useCallback(
() => ({open: isTooltipHandoffWarm() ? 0 : hoverDelay, close: CLOSE_DELAY_MS}),
[hoverDelay],
);
const hoverSafePolygon = useMemo(() => safePolygon({buffer: SAFE_POLYGON_BUFFER_PX, requireIntent: false}), []);
const hover = useHover(context, {
delay: hoverDelayConfig,
@@ -95,6 +99,10 @@ export function useHoverFloatingTooltip(hoverDelay = 500, placement: Placement =
setIsOpen(false);
}, []);
useExclusiveTooltip(isOpen, hide);
useEffect(() => {
if (!isOpen) return;
return markTooltipOpen();
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
return subscribeTooltipScrollHide(hide);