mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -86,7 +86,7 @@ function renderApp() {
|
||||
enabled={huddleWindowChannelId() === null}
|
||||
>
|
||||
<ThemeProvider defaultTheme="buzz">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TooltipProvider>
|
||||
<EmojiBurstProvider>
|
||||
<PoofBurstProvider>
|
||||
<UpdaterProvider>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -255,7 +255,7 @@ const SidebarProvider = React.forwardRef<
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)");
|
||||
|
||||
Reference in New Issue
Block a user