From d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 12:55:00 -0700 Subject: [PATCH] fix(shared-ui): delay hover disclosures by default (#5821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Hover tooltips and informational popovers now wait for deliberate pointer dwell instead of appearing while users move around the app. **Problem:** Tooltips and hover-controlled popovers appeared after inconsistent, often very short delays, so moving across composer and navigation controls could obstruct the next interaction. **Solution:** Establish a 500 ms shared dwell default with no tooltip skip-delay cascade, apply it to informational hover-controlled Popovers, and preserve immediate click and keyboard behavior. The responsive Community actions navigation submenu retains its documented 80 ms open / 160 ms close timing. ## Before / after | Before | After | |---|---| | ![Before: hover disclosures appear during pointer transit](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5821/before-hover-disclosure.gif) | ![After: hover disclosures wait for deliberate pointer dwell](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5821/after-hover-disclosure.gif) |
File changes **desktop/src/shared/ui/tooltip.tsx** Wraps the Radix provider with documented 500 ms and zero skip-delay defaults. The provider API still permits a future proven exception, but no current `desktop/src` caller overrides either timing. **desktop/src/shared/ui/popover.tsx** Exports the documented shared hover-open timing for controlled popovers; ordinary click/focus Popovers remain immediate. **desktop/src/main.tsx** Uses the shared Tooltip provider defaults at the application root. **desktop/src/shared/ui/sidebar.tsx** Removes the sidebar's instant Tooltip timing override. **desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx** Removes the local short Tooltip timing override. **desktop/src/features/home/ui/InboxDetailPane.tsx** Removes the local short Tooltip timing override. **desktop/src/features/messages/ui/MessageTimeline.tsx** Removes the timeline's local short Tooltip timing override. **desktop/src/features/messages/ui/MessageTimestamp.tsx** Drops the timestamp-only provider now that its 500 ms, zero-skip behavior is shared globally. **desktop/src/features/channels/ui/BotActivityBar.tsx** Raises composer agent-activity hover dwell from 150 ms to the shared default while preserving immediate click/focus opening. **desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx** Raises channel activity hover dwell from 250 ms to the shared default while preserving immediate focus opening. **desktop/src/features/profile/ui/UserProfilePopover.tsx** Reuses the shared hover timing in place of its equivalent local constant. **desktop/src/shared/ui/PubKey.tsx** Reuses the shared hover timing in place of its equivalent local constant. **desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx** Raises emoji inspection hover dwell from 200 ms to the shared default while preserving immediate focus opening. **desktop/src/features/messages/ui/MessageReactions.tsx** Raises reaction inspection hover dwell from 200 ms to the shared default while preserving immediate focus and reaction clicks. **desktop/src/features/communities/ui/CommunitySwitcher.tsx** Documents the Community actions navigation submenu as an intentional timing exception: 80 ms to open responsively and 160 ms to preserve the pointer bridge into its portalled panel.
## Reproduction steps Move the pointer rapidly across each surface first, then hold it still over a labeled control. Hover-only disclosures should stay closed during transit and open after about **500 ms** of deliberate dwell. Moving directly between adjacent Tooltip triggers should start a fresh 500 ms dwell rather than cascading the next Tooltip open immediately. | Surface | Where to test | What to expect | |---|---|---| | Composer controls | Attachment, emoji, image editor, formatting, and composer toolbar buttons | No Tooltip while sweeping across controls; the hovered control's Tooltip opens after ~500 ms. Clicking remains immediate. | | Message actions | Hover a message, then test reply, react, more-actions, edit, and related action-bar controls | Each Tooltip waits ~500 ms, including when moving between adjacent actions. The action itself still runs immediately on click or keyboard activation. | | Message metadata and content tools | Message timestamps, code-block copy controls, diff controls, system-message controls, and video-player controls | Tooltip appears after ~500 ms. Timestamp behavior should look unchanged; it was already 500 ms with no skip cascade. | | Reaction pills | Hover a reaction with one or more reactors; also click the pill | Reactor Popover waits ~500 ms instead of 200 ms. Clicking still toggles the reaction immediately. | | Inline custom emoji | Hover a rendered custom emoji in message Markdown, then focus it with the keyboard | Emoji inspector waits ~500 ms on hover instead of 200 ms. Keyboard focus opens it immediately. | | Masked links | Hover a masked Markdown link, including one revealed inside a spoiler | Destination Tooltip waits ~500 ms instead of the former app-level 300 ms. Hidden spoilers still reveal no destination; keyboard focus remains immediate. | | Main and collapsed sidebar controls | Collapse the sidebar and hover icon-only navigation/menu buttons; also test community-rail controls | Sidebar Tooltips wait ~500 ms instead of opening instantly. Rapid movement across icons should not produce a tooltip cascade. | | Channel header and management controls | Channel members, huddle, settings, thread-view mode, management rows, and quick-agent controls | Each Tooltip waits ~500 ms; click/keyboard behavior remains immediate. | | Add-channel team chips | Open Add channel where saved teams are available and hover a team chip | Team details Tooltip waits ~500 ms instead of 150 ms; clicking the chip still toggles the team immediately. | | Channel activity preview | Hover a sidebar channel that has activity, then keyboard-focus its trigger | Activity Popover waits ~500 ms instead of 250 ms. Focus opens it immediately, and quickly crossing channel rows should not leave previews in the way. | | Composer agent activity | Run an agent so the composer activity control is present; hover, click, and focus it | Hover Popover waits ~500 ms instead of 150 ms. Click and keyboard focus still open it immediately. | | Inbox and draft controls | Home inbox open-context/more-actions controls, inbox-list controls, draft detail, and drafts panel | Tooltips wait ~500 ms instead of the inbox detail's former 200 ms/local defaults. Clicks remain immediate. | | Profile and public-key previews | Hover avatars, names/mentions, project author identities, and displayed public keys | Profile/pubkey Popovers open after ~500 ms, matching their prior behavior; the change centralizes that timing. Click actions and focus behavior remain immediate. | | Agent, team, memory, and update controls | Managed-agent rows, team identity cards, restart-diff badges, memory actions, setup steps, and update indicator | Tooltips wait ~500 ms and do not cascade when traversing adjacent controls. Actions remain immediate. | | Huddle controls | Huddle bar/indicator, mic controls, and participant-list actions | Tooltips wait ~500 ms; mute, join, participant, and keyboard actions remain immediate. | | Projects and activity surfaces | Project/repository cards, overview rail, contribution graph, activity feed, reviewers, and Pulse note controls | Tooltips wait ~500 ms with a fresh dwell between adjacent targets. Clicking/focusing interactive controls remains immediate. | | Intentional interaction-mode exceptions | Focus a Tooltip trigger; click/focus an ordinary Popover; move the pointer from an open hover Popover into its panel | Tooltip focus and Popover click/focus open immediately because they are explicit user intent, not incidental hover. Hover Popovers retain their 180–200 ms close grace so the pointer can cross into the panel. The Community actions navigation submenu is the intentional exception: it opens after 80 ms and keeps its 160 ms pointer bridge; informational hover Popovers use the shared 500 ms delay. | ## Verification - `pnpm typecheck` - `pnpm check` (passes with three existing diagnostics outside this diff) - `pnpm test` — 4,775 passed - `pnpm build` --------- Signed-off-by: Taylor Ho --- .../channels/ui/AddChannelBotTeamsSection.tsx | 2 +- .../features/channels/ui/BotActivityBar.tsx | 10 ++- .../communities/ui/CommunitySwitcher.tsx | 10 ++- .../src/features/home/ui/InboxDetailPane.tsx | 2 +- .../features/messages/ui/MessageReactions.tsx | 12 ++- .../features/messages/ui/MessageTimeline.tsx | 2 +- .../features/messages/ui/MessageTimestamp.tsx | 46 ++++------- .../profile/ui/UserProfilePopover.tsx | 10 ++- .../sidebar/ui/ChannelActivityPopover.tsx | 10 ++- desktop/src/main.tsx | 2 +- desktop/src/shared/ui/PubKey.tsx | 10 ++- .../shared/ui/markdown/InlineEmojiPopover.tsx | 12 ++- desktop/src/shared/ui/popover.tsx | 4 + desktop/src/shared/ui/sidebar.tsx | 2 +- desktop/src/shared/ui/tooltip.tsx | 18 ++++- desktop/tests/e2e/community-rail.spec.ts | 81 ++++++++++++++++++- .../e2e/composer-tooltip-dismiss.spec.ts | 31 ++++++- 17 files changed, 207 insertions(+), 57 deletions(-) diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 69e511fb40..512ee6899f 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({

- +
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a96103..cfa84f02e3 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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(() => { diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index cd530e6908..4d6f2d258b 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -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 = { 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, ); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9bb593087d..4b64ad10e0 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -622,7 +622,7 @@ function InboxMessageDetailPane({
- +
{canOpenChannel && contextChannelId ? ( diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index d4bec8db6c..fba878dbb5 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -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(() => { diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index f8c5395b17..df9af09e67 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -692,7 +692,7 @@ const MessageTimelineBase = React.forwardRef< ) : null; return ( - +
{showUnreadPill ? (
- - -

- {displayTime} -

-
- - {formatFullDateTime(createdAt)} - -
- + + +

+ {displayTime} +

+
+ + {formatFullDateTime(createdAt)} + +
); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index ca732bb3f1..257256c5f6 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -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 = { @@ -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(() => { diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1c86ac4c36..e6e24ce27b 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -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; diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index bbb4c5fa42..1520814559 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -86,7 +86,7 @@ function renderApp() { enabled={huddleWindowChannelId() === null} > - + diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx index 153801787b..fb5ac15a44 100644 --- a/desktop/src/shared/ui/PubKey.tsx +++ b/desktop/src/shared/ui/PubKey.tsx @@ -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(() => { diff --git a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx index 89b482ae0c..1082ff2032 100644 --- a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx +++ b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx @@ -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(() => { diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx index 4c161efe5d..ede0e15627 100644 --- a/desktop/src/shared/ui/popover.tsx +++ b/desktop/src/shared/ui/popover.tsx @@ -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; diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx index 568bb46918..5dbda32987 100644 --- a/desktop/src/shared/ui/sidebar.tsx +++ b/desktop/src/shared/ui/sidebar.tsx @@ -251,7 +251,7 @@ const SidebarProvider = React.forwardRef< return ( - +
) => ( + +); const Tooltip = TooltipPrimitive.Root; diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 8d24ef7e1d..57b35db386 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -296,10 +296,87 @@ 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.evaluate((trigger) => { + trigger.addEventListener( + "mouseenter", + () => { + trigger.dataset.hoverStartedAt = String(performance.now()); + }, + { once: true }, + ); + trigger.addEventListener("mouseleave", () => { + trigger.dataset.leftAt = String(performance.now()); + }); + const observer = new MutationObserver((records) => { + if ( + trigger.getAttribute("aria-expanded") === "true" && + !trigger.dataset.expandedAt + ) { + trigger.dataset.expandedAt = String(performance.now()); + } + if ( + records.some( + (record) => + record.attributeName === "aria-expanded" && + record.oldValue === "true", + ) + ) { + trigger.dataset.closedAfterOpening = "true"; + } + }); + observer.observe(trigger, { + attributeFilter: ["aria-expanded"], + attributeOldValue: true, + attributes: true, + }); + }); + await communityTrigger.hover(); + await expect(menu).toBeVisible({ timeout: 700 }); + const openDelayMs = await communityTrigger.evaluate((trigger) => { + const hoverStartedAt = Number(trigger.dataset.hoverStartedAt); + const expandedAt = Number(trigger.dataset.expandedAt); + if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) { + throw new Error("Community actions open timing was not recorded"); + } + return expandedAt - hoverStartedAt; + }); + expect(openDelayMs).toBeGreaterThanOrEqual(40); + expect(openDelayMs).toBeLessThan(300); + + 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"); + } + const triggerExitX = openTriggerBox.x + openTriggerBox.width - 1; + const triggerExitY = Math.min( + openTriggerBox.y + openTriggerBox.height - 4, + menuBox.y + menuBox.height - 4, + ); + await page.mouse.move(triggerExitX, triggerExitY); + await page.mouse.move(menuBox.x + 8, menuBox.y - 8); + await page.waitForTimeout(80); + await page.mouse.move(menuBox.x + 8, menuBox.y + 8); + const bridgeDurationMs = await communityTrigger.evaluate((trigger) => { + const leftAt = Number(trigger.dataset.leftAt); + if (!Number.isFinite(leftAt)) { + throw new Error( + "Community actions trigger exit timing was not recorded", + ); + } + return performance.now() - leftAt; + }); + expect(bridgeDurationMs).toBeGreaterThanOrEqual(60); + expect(bridgeDurationMs).toBeLessThan(140); + await page.waitForTimeout(180); await expect(menu).toBeVisible(); + await expect(communityTrigger).not.toHaveAttribute( + "data-closed-after-opening", + "true", + ); await expect( menu.getByRole("menuitem", { name: "Copy community URL" }), ).toBeVisible(); diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts index 9b0bb051fc..d6cd05eb0f 100644 --- a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts +++ b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts @@ -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 "