mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
co-authored by
multica-agent
Multica Eve
parent
55be334864
commit
207a4bf4e9
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user