diff --git a/packages/ui/components/common/__tests__/icon-button.test.tsx b/packages/ui/components/common/__tests__/icon-button.test.tsx index 8b8a97d1a..48f435586 100644 --- a/packages/ui/components/common/__tests__/icon-button.test.tsx +++ b/packages/ui/components/common/__tests__/icon-button.test.tsx @@ -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( + } />, + ); + + 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( + <> + } /> + } /> + , + ); + 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(); + } + }); }); diff --git a/packages/ui/components/common/__tests__/truncated-tooltip.test.tsx b/packages/ui/components/common/__tests__/truncated-tooltip.test.tsx index 81bf42f30..11fbc987c 100644 --- a/packages/ui/components/common/__tests__/truncated-tooltip.test.tsx +++ b/packages/ui/components/common/__tests__/truncated-tooltip.test.tsx @@ -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(); diff --git a/packages/ui/components/common/deferred-tooltip.tsx b/packages/ui/components/common/deferred-tooltip.tsx index a2123c722..0a7423d0d 100644 --- a/packages/ui/components/common/deferred-tooltip.tsx +++ b/packages/ui/components/common/deferred-tooltip.tsx @@ -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); diff --git a/packages/ui/components/common/theme-provider.tsx b/packages/ui/components/common/theme-provider.tsx index 483ae6b97..2804e96ad 100644 --- a/packages/ui/components/common/theme-provider.tsx +++ b/packages/ui/components/common/theme-provider.tsx @@ -15,7 +15,7 @@ export function ThemeProvider({ disableTransitionOnChange {...props} > - + {children} diff --git a/packages/ui/components/ui/tooltip.tsx b/packages/ui/components/ui/tooltip.tsx index 80e7546f9..9ae4e6e95 100644 --- a/packages/ui/components/ui/tooltip.tsx +++ b/packages/ui/components/ui/tooltip.tsx @@ -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 ( ) @@ -61,6 +99,7 @@ function TooltipContent({ )} {...props} > + {children} @@ -68,4 +107,12 @@ function TooltipContent({ ) } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + TOOLTIP_OPEN_DELAY, + TOOLTIP_INSTANT_WINDOW, + isTooltipInstantPhase, +} diff --git a/packages/views/issues/components/issue-runs-dialog.test.tsx b/packages/views/issues/components/issue-runs-dialog.test.tsx index 0ee3d8503..2bfcc95b5 100644 --- a/packages/views/issues/components/issue-runs-dialog.test.tsx +++ b/packages/views/issues/components/issue-runs-dialog.test.tsx @@ -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}”`); }); diff --git a/packages/views/issues/components/pickers/property-picker.tsx b/packages/views/issues/components/pickers/property-picker.tsx index 8f3d84ee4..8e9e5f032 100644 --- a/packages/views/issues/components/pickers/property-picker.tsx +++ b/packages/views/issues/components/pickers/property-picker.tsx @@ -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 diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index 7e35e89dc..7646f3c69 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -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. */}
@@ -1503,15 +1503,13 @@ export function ManualCreatePanel({ {t(($) => $.create_issue.create_another)} {submitState === "missing_title" ? ( - - - {/* No `` wrapper needed now: aria-disabled leaves the - button focusable and hoverable, so it can anchor its own - tooltip. */} - - {t(($) => $.create_issue.title_required)} - - + + {/* No `` wrapper needed now: aria-disabled leaves the + button focusable and hoverable, so it can anchor its own + tooltip. */} + + {t(($) => $.create_issue.title_required)} + ) : ( createButton )}