fix(shared-ui): delay hover disclosures by default (#5821)

**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)
|

<details>
<summary>File changes</summary>

**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.

</details>

## 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 <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-18 12:55:00 -07:00
committed by GitHub
parent c442a90a17
commit d7e8fdb10c
17 changed files with 207 additions and 57 deletions

View File

@@ -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();