mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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 | |---|---| |  |  | <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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user