fix(desktop): delay hover disclosures by default

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-17 14:07:01 -07:00
committed by Carl
parent 076081bfc6
commit c6c5c3ed02
17 changed files with 173 additions and 57 deletions
@@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({
</p>
</div>
<TooltipProvider delayDuration={150}>
<TooltipProvider>
<div className="flex flex-wrap gap-2">
{teams.map((team) => {
const resolution = resolveTeamPersonas(team, personas);
@@ -10,7 +10,12 @@ import {
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
import { Shimmer } from "@/shared/ui/Shimmer";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -26,7 +31,6 @@ type BotActivityBarProps = {
variant?: "toolbar" | "inline";
};
const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180;
const HEADLINE_ROTATION_MS = 2200;
@@ -106,7 +110,7 @@ export function BotActivityComposerAction({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer]);
const closeWithDelay = React.useCallback(() => {
@@ -39,6 +39,12 @@ import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons";
import { EditCommunityDialog } from "./EditCommunityDialog";
// Community actions is a responsive navigation submenu, not an informational
// disclosure. Keep its short hover dwell explicit rather than inheriting the
// shared 500 ms Popover delay intended to prevent incidental inspection UI.
const PROFILE_MENU_HOVER_OPEN_DELAY_MS = 80;
const PROFILE_MENU_HOVER_CLOSE_DELAY_MS = 160;
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
idle: "Not connected",
connecting: "Connecting…",
@@ -128,7 +134,9 @@ export function CommunitySwitcher({
clearProfileMenuHoverTimer();
profileMenuHoverTimer.current = window.setTimeout(
() => setDropdownOpen(nextOpen),
nextOpen ? 80 : 160,
nextOpen
? PROFILE_MENU_HOVER_OPEN_DELAY_MS
: PROFILE_MENU_HOVER_CLOSE_DELAY_MS,
);
}
@@ -622,7 +622,7 @@ function InboxMessageDetailPane({
</div>
</div>
<TooltipProvider delayDuration={200}>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1">
<UpdateIndicator />
{canOpenChannel && contextChannelId ? (
@@ -12,7 +12,12 @@ import {
isPositiveEmojiParticle,
useEmojiBurst,
} from "@/shared/ui/EmojiBurstProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const REACTION_PILL_BASE_CLASSES =
@@ -379,7 +384,10 @@ function ReactionPill({
const handleMouseEnter = React.useCallback(() => {
if (reaction.users.length === 0) return;
clearTimers();
openTimeout.current = setTimeout(() => setOpen(true), 200);
openTimeout.current = setTimeout(
() => setOpen(true),
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
);
}, [reaction.users.length, clearTimers]);
const scheduleClose = React.useCallback(() => {
@@ -692,7 +692,7 @@ const MessageTimelineBase = React.forwardRef<
) : null;
return (
<TooltipProvider delayDuration={200}>
<TooltipProvider>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{showUnreadPill ? (
<div
@@ -5,14 +5,7 @@ import {
} from "@/features/messages/lib/dateFormatters";
import { cn } from "@/shared/lib/cn";
import { formatItemTimestamp } from "@/shared/lib/datetime";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
/**
* The timestamp beside a message author, and the clock that fades in over the
@@ -50,26 +43,21 @@ export function MessageTimestamp({
: formatItemTimestamp(createdAt, { withTime: true });
return (
<TooltipProvider
delayDuration={TIMESTAMP_TOOLTIP_DELAY_MS}
skipDelayDuration={0}
>
<Tooltip>
<TooltipTrigger asChild>
<p
className={cn(
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
className,
)}
data-testid="message-timestamp"
>
{displayTime}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p
className={cn(
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
className,
)}
data-testid="message-timestamp"
>
{displayTime}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
);
}
@@ -28,7 +28,12 @@ import { cn } from "@/shared/lib/cn";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import {
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
Popover,
PopoverAnchor,
PopoverContent,
} from "@/shared/ui/popover";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import { useNow } from "@/shared/lib/useNow";
import { Button } from "@/shared/ui/button";
@@ -51,7 +56,6 @@ type UserProfilePopoverProps = {
botIdenticonValue?: string;
};
const HOVER_OPEN_DELAY_MS = 500;
const HOVER_CLOSE_DELAY_MS = 200;
const RUNTIME_LABELS: Record<string, string> = {
@@ -244,7 +248,7 @@ export function UserProfilePopover({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer, enableHoverPopover]);
const handleMouseLeave = React.useCallback(() => {
@@ -16,10 +16,14 @@ import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useNow } from "@/shared/lib/useNow";
import { Markdown } from "@/shared/ui/markdown";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import {
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
Popover,
PopoverAnchor,
PopoverContent,
} from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const HOVER_OPEN_DELAY_MS = 250;
const HOVER_CLOSE_DELAY_MS = 180;
const ACTIVITY_POPOVER_MOTION_STYLE = {
"--tw-enter-scale": "1",
@@ -310,7 +314,7 @@ export function ChannelActivityPopover({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer, hasContent]);
const openImmediately = React.useCallback(() => {
if (!hasContent) return;
+1 -1
View File
@@ -86,7 +86,7 @@ function renderApp() {
enabled={huddleWindowChannelId() === null}
>
<ThemeProvider defaultTheme="buzz">
<TooltipProvider delayDuration={300}>
<TooltipProvider>
<EmojiBurstProvider>
<PoofBurstProvider>
<UpdaterProvider>
+7 -3
View File
@@ -6,9 +6,13 @@ import { cn } from "@/shared/lib/cn";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
const HOVER_OPEN_DELAY_MS = 500;
const HOVER_CLOSE_DELAY_MS = 200;
type PubKeyProps = {
@@ -99,7 +103,7 @@ export function PubKey({
clearHoverTimer();
hoverTimerRef.current = setTimeout(() => {
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS);
}, [clearHoverTimer]);
const handleMouseLeave = React.useCallback(() => {
@@ -1,6 +1,11 @@
import * as React from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
export function InlineEmojiPopover({
alt,
@@ -27,7 +32,10 @@ export function InlineEmojiPopover({
const handleMouseEnter = React.useCallback(() => {
clearTimers();
openTimeout.current = setTimeout(() => setOpen(true), 200);
openTimeout.current = setTimeout(
() => setOpen(true),
DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
);
}, [clearTimers]);
const scheduleClose = React.useCallback(() => {
+4
View File
@@ -14,6 +14,10 @@ import {
POPOVER_SURFACE_CLASS,
} from "@/shared/ui/popoverSurface";
// Radix Popover has no hover timing API: controlled hover popovers must use this
// shared dwell default themselves. Keep click and keyboard opens immediate.
export const DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS = 500;
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
+1 -1
View File
@@ -255,7 +255,7 @@ const SidebarProvider = React.forwardRef<
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<TooltipProvider>
<div
style={
{
+17 -1
View File
@@ -3,7 +3,23 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/cn";
const TooltipProvider = TooltipPrimitive.Provider;
// Hover-only disclosure should require deliberate pointer dwell. Disabling Radix's
// skip-delay grace prevents tooltips from cascading open while the pointer moves
// across adjacent controls. Callers may override both values for a proven case.
const DEFAULT_TOOLTIP_DELAY_MS = 500;
const DEFAULT_TOOLTIP_SKIP_DELAY_MS = 0;
const TooltipProvider = ({
delayDuration = DEFAULT_TOOLTIP_DELAY_MS,
skipDelayDuration = DEFAULT_TOOLTIP_SKIP_DELAY_MS,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) => (
<TooltipPrimitive.Provider
delayDuration={delayDuration}
skipDelayDuration={skipDelayDuration}
{...props}
/>
);
const Tooltip = TooltipPrimitive.Root;
+45 -2
View File
@@ -275,9 +275,52 @@ test.describe("community rail", () => {
expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0);
expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0);
await page.getByTestId("community-switcher").click();
const menu = page.getByRole("menu", { name: "Community actions" });
await communityTrigger.hover();
await page.waitForTimeout(40);
await expect(menu).toBeHidden();
await expect(menu).toBeVisible({ timeout: 160 });
const openTriggerBox = await communityTrigger.boundingBox();
const menuBox = await menu.boundingBox();
expect(openTriggerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
if (!openTriggerBox || !menuBox) {
throw new Error("Community actions geometry unavailable");
}
await communityTrigger.evaluate((trigger) => {
trigger.addEventListener("mouseleave", () => {
document.body.dataset.communityTriggerLeft = "true";
});
const observer = new MutationObserver(() => {
if (trigger.getAttribute("aria-expanded") === "false") {
document.body.dataset.communityMenuClosedDuringBridge = "true";
}
});
observer.observe(trigger, {
attributeFilter: ["aria-expanded"],
attributes: true,
});
});
await page.mouse.move(
openTriggerBox.x + openTriggerBox.width / 2,
openTriggerBox.y + openTriggerBox.height + 4,
);
await page.waitForTimeout(80);
await expect
.poll(() =>
page.locator("body").getAttribute("data-community-trigger-left"),
)
.toBe("true");
await expect(menu).toBeVisible();
await expect
.poll(() =>
page
.locator("body")
.getAttribute("data-community-menu-closed-during-bridge"),
)
.toBeNull();
await page.mouse.move(menuBox.x + menuBox.width / 2, menuBox.y + 8);
await expect(menu).toBeVisible();
await expect(
menu.getByRole("menuitem", { name: "Copy community URL" }),
@@ -12,8 +12,8 @@ test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
/** Hover the trigger, then slide the cursor onto the tooltip popup and
* assert the tooltip dismisses instead of persisting. */
/** Hover the trigger through the shared dwell, then slide the cursor onto the
* tooltip popup and assert the tooltip dismisses instead of persisting. */
async function expectTooltipDismissesOnLeave(
page: import("@playwright/test").Page,
trigger: import("@playwright/test").Locator,
@@ -22,7 +22,9 @@ async function expectTooltipDismissesOnLeave(
await trigger.hover();
const tip = page.getByRole("tooltip", { name: tooltipName });
await expect(tip).toBeVisible();
await page.waitForTimeout(400);
await expect(tip).toHaveCount(0);
await expect(tip).toBeVisible({ timeout: 1_000 });
// Slide off the trigger onto the tooltip popup.
const box = await tip.boundingBox();
@@ -48,6 +50,26 @@ test("composer toolbar tooltip dismisses when cursor leaves the trigger", async
);
});
test("adjacent composer tooltips each require a fresh dwell", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-insert-mention").hover();
await page.waitForTimeout(400);
const mentionTooltip = page.getByRole("tooltip", { name: "Mention someone" });
await expect(mentionTooltip).toHaveCount(0);
await expect(mentionTooltip).toBeVisible({ timeout: 1_000 });
await page.getByRole("button", { name: "Attach file" }).hover();
await page.waitForTimeout(400);
const attachTooltip = page.getByRole("tooltip", { name: "Attach file" });
await expect(attachTooltip).toHaveCount(0);
await expect(attachTooltip).toBeVisible({ timeout: 1_000 });
});
test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
@@ -60,6 +82,9 @@ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger",
const bold = page.getByRole("button", { name: "Bold" });
await expect(bold).toBeVisible();
// The formatting strip animates into place; wait for its delayed entrance to
// settle so the pointer remains over the trigger for the full dwell.
await page.waitForTimeout(300);
// Tooltip text is "<label> (<shortcut>)" for items that carry a shortcut.
await expectTooltipDismissesOnLeave(page, bold, "Bold (⌘B)");