feat(ui): raise tooltip open delay to 500ms with instant hand-off (MUL-8313) (#9623)

* feat(ui): raise tooltip open delay to 500ms with instant hand-off (MUL-8313)

- One TOOLTIP_OPEN_DELAY (500ms) shared by TooltipProvider and
  DeferredTooltip; drop the hard-coded 200 in the theme provider and the
  redundant nested provider in the create-issue footer.
- Make the instant window explicit (TOOLTIP_INSTANT_WINDOW, 400ms, Base UI's
  default) and let DeferredTooltip, which Base UI's delay group never sees,
  join it: tooltip popups record when they mount and unmount, and a deferred
  tooltip hovered while one is showing or just closed opens immediately.
- Close delay stays 0.

Co-authored-by: multica-agent <[email protected]>

* chore: remove accidentally committed pr-body.md

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica Eve
2026-10-28 15:09:56 +08:00
committed by GitHub
co-authored by multica-agent Multica Eve
parent 55be334864
commit 207a4bf4e9
8 changed files with 131 additions and 29 deletions
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { IconButton } from "../icon-button";
import { TooltipHint } from "../tooltip-hint";
import { DeferredTooltip } from "../deferred-tooltip";
import { TooltipProvider } from "../../ui/tooltip";
import { TOOLTIP_OPEN_DELAY, TooltipProvider } from "../../ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -177,7 +177,7 @@ describe("DeferredTooltip", () => {
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(250);
vi.advanceTimersByTime(550);
});
expect(document.querySelector("[data-slot='tooltip-content']")).toHaveTextContent("Row actions");
@@ -190,7 +190,7 @@ describe("DeferredTooltip", () => {
fireEvent.pointerLeave(trigger, { pointerType: "mouse" });
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(250);
vi.advanceTimersByTime(550);
});
const popup = document.querySelector("[data-slot='tooltip-content']");
expect(popup === null || popup.getAttribute("data-open") === null).toBe(true);
@@ -215,11 +215,62 @@ describe("DeferredTooltip", () => {
fireEvent.pointerEnter(screen.getByRole("button", { name: "Collapse thread" }), { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(250);
vi.advanceTimersByTime(550);
});
expect(document.querySelector("[data-slot='tooltip-content']")).toHaveTextContent("Collapse thread");
} finally {
vi.useRealTimers();
}
});
it("waits the shared open delay on a cold hover", async () => {
vi.useFakeTimers();
try {
// Move past any instant-phase window a previous test's tooltip left open.
vi.setSystemTime(Date.now() + 10_000);
renderWithTooltips(
<DeferredTooltip content="Copy" trigger={<button type="button" aria-label="Copy" />} />,
);
fireEvent.pointerEnter(screen.getByRole("button", { name: "Copy" }), { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(TOOLTIP_OPEN_DELAY - 50);
});
expect(document.querySelector("[data-slot='tooltip-content']")).toBeNull();
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(document.querySelector("[data-slot='tooltip-content']")).toHaveTextContent("Copy");
} finally {
vi.useRealTimers();
}
});
it("opens at once when moving over from another tooltip", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(Date.now() + 10_000);
renderWithTooltips(
<>
<DeferredTooltip content="Copy" trigger={<button type="button" aria-label="Copy" />} />
<DeferredTooltip content="Paste" trigger={<button type="button" aria-label="Paste" />} />
</>,
);
const copy = screen.getByRole("button", { name: "Copy" });
const paste = screen.getByRole("button", { name: "Paste" });
fireEvent.pointerEnter(copy, { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(TOOLTIP_OPEN_DELAY + 50);
});
fireEvent.pointerLeave(copy, { pointerType: "mouse" });
fireEvent.pointerEnter(paste, { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getAllByText("Paste").some((el) => el.closest("[data-slot='tooltip-content']"))).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
@@ -19,7 +19,7 @@ async function hover(element: HTMLElement) {
try {
fireEvent.pointerEnter(element, { pointerType: "mouse" });
await act(async () => {
vi.advanceTimersByTime(250);
vi.advanceTimersByTime(550);
});
} finally {
vi.useRealTimers();
@@ -9,11 +9,12 @@ import {
type ReactNode,
type Ref,
} from "react";
import { Tooltip, TooltipContent } from "@multica/ui/components/ui/tooltip";
// Matches the ui TooltipProvider default delay so the deferred first-open
// feels identical to a native tooltip.
const TOOLTIP_OPEN_DELAY = 200;
import {
Tooltip,
TooltipContent,
TOOLTIP_OPEN_DELAY,
isTooltipInstantPhase,
} from "@multica/ui/components/ui/tooltip";
/**
* Tooltip whose machinery mounts only on first hover.
@@ -91,9 +92,14 @@ export function DeferredTooltip({
(trigger.props.onPointerEnter as ((e: React.PointerEvent) => void) | undefined)?.(e);
setWarm(true);
clearTimer();
timerRef.current = setTimeout(() => {
if (canOpen()) setOpen(true);
}, TOOLTIP_OPEN_DELAY);
// Same delay as every other tooltip, and the same instant hand-off: when
// a tooltip is showing or just closed, open without waiting.
timerRef.current = setTimeout(
() => {
if (canOpen()) setOpen(true);
},
isTooltipInstantPhase() ? 0 : TOOLTIP_OPEN_DELAY,
);
},
onPointerLeave: (e: React.PointerEvent) => {
(trigger.props.onPointerLeave as ((e: React.PointerEvent) => void) | undefined)?.(e);
@@ -15,7 +15,7 @@ export function ThemeProvider({
disableTransitionOnChange
{...props}
>
<TooltipProvider delay={200}>
<TooltipProvider>
{children}
</TooltipProvider>
</NextThemesProvider>
+49 -2
View File
@@ -2,16 +2,54 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { useEffect } from "react"
import { cn } from "@multica/ui/lib/utils"
/** Hover time before a tooltip opens, shared by every tooltip in the app. */
const TOOLTIP_OPEN_DELAY = 500
/**
* After a tooltip closes, hovering another trigger within this window opens it
* at once, so moving along a toolbar only waits on the first tooltip.
*/
const TOOLTIP_INSTANT_WINDOW = 400
// Tooltip activity across every tooltip popup, including ones Base UI's delay
// group doesn't see (DeferredTooltip drives a controlled Tooltip with no
// Trigger). Updated by the popup itself as it mounts and unmounts.
let openPopups = 0
let lastPopupClosedAt = Number.NEGATIVE_INFINITY
/** True while a tooltip is showing or just closed, i.e. the next one should skip its delay. */
function isTooltipInstantPhase(): boolean {
return (
openPopups > 0 ||
Date.now() - lastPopupClosedAt < TOOLTIP_INSTANT_WINDOW
)
}
function TooltipActivity() {
useEffect(() => {
openPopups += 1
return () => {
openPopups -= 1
lastPopupClosedAt = Date.now()
}
}, [])
return null
}
function TooltipProvider({
delay = 200,
delay = TOOLTIP_OPEN_DELAY,
timeout = TOOLTIP_INSTANT_WINDOW,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
timeout={timeout}
{...props}
/>
)
@@ -61,6 +99,7 @@ function TooltipContent({
)}
{...props}
>
<TooltipActivity />
{children}
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
@@ -68,4 +107,12 @@ function TooltipContent({
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
TOOLTIP_OPEN_DELAY,
TOOLTIP_INSTANT_WINDOW,
isTooltipInstantPhase,
}
@@ -424,7 +424,7 @@ describe("IssueRunsDialog", () => {
vi.useFakeTimers();
fireEvent.pointerEnter(line, { pointerType: "mouse" });
act(() => {
vi.advanceTimersByTime(250);
vi.advanceTimersByTime(550);
});
expect(document.querySelector("[data-slot='tooltip-content']")).toHaveTextContent(`“${long}”`);
});
@@ -256,7 +256,7 @@ export function PickerItem({
emptyValue?: boolean;
/** Design-system tooltip for the row — useful when truncated content needs
* the full string, or when the row carries metadata that doesn't fit on
* a single line. Wrapped in a real Tooltip component (200ms delay,
* a single line. Wrapped in a real Tooltip component (shared delay,
* styled), not a native `title` attribute. */
tooltip?: React.ReactNode;
/** Full text of a row label that may be cut off. Unlike `tooltip`, it only
+9 -11
View File
@@ -48,7 +48,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@multica/ui/components/ui/tooltip";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { TooltipHint } from "@multica/ui/components/common/tooltip-hint";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
@@ -1469,7 +1469,7 @@ export function ManualCreatePanel({
{/* Footer — same 2x2-grid-on-phones / single-row-from-`sm` shape
as the agent panel; see the note on AgentCreatePanel's footer
for why (MUL-6236). TooltipProvider/Tooltip render no DOM and
for why (MUL-6236). Tooltip renders no DOM and
TooltipContent is portaled, so the Create button stays a direct
grid child in both branches below. */}
<div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-2.5 border-t px-4 py-3 shrink-0 sm:flex sm:flex-wrap">
@@ -1503,15 +1503,13 @@ export function ManualCreatePanel({
{t(($) => $.create_issue.create_another)}
</label>
{submitState === "missing_title" ? (
<TooltipProvider delay={200}>
<Tooltip>
{/* No `<span>` wrapper needed now: aria-disabled leaves the
button focusable and hoverable, so it can anchor its own
tooltip. */}
<TooltipTrigger render={createButton} />
<TooltipContent side="top">{t(($) => $.create_issue.title_required)}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
{/* No `<span>` wrapper needed now: aria-disabled leaves the
button focusable and hoverable, so it can anchor its own
tooltip. */}
<TooltipTrigger render={createButton} />
<TooltipContent side="top">{t(($) => $.create_issue.title_required)}</TooltipContent>
</Tooltip>
) : (
createButton
)}