From 329b0af7313ac313d18b6529f8af83b1b2f90d3b Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 9 Sep 2026 17:22:45 +0800 Subject: [PATCH] fix(ui): unify chat tooltips and preserve overlay interactions - Remove duplicate native tooltips from command and message actions - Use styled tooltips and accessible labels for message buttons - Keep interactive tooltips open when entering the overlay - Prevent tooltip clicks from toggling the parent command card - Cover hover persistence, click isolation, and accessible labels --- .../ui/src/components/Tooltip/Tooltip.tsx | 10 ++++-- .../flow-chat/tool-cards/CommandToolCard.tsx | 1 - .../modern/UserMessageItem.test.tsx | 6 ++-- .../components/modern/UserMessageItem.tsx | 21 ++++++----- .../src/shared/ui/OverflowText.test.tsx | 36 +++++++++++++++++++ 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx index 5e5d9d00f2..e251267935 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -357,7 +357,11 @@ export function Tooltip({ const onEnter = (event: MouseEvent) => { if (trigger === "hover" || trigger === "hover-focus") showTooltip(event); }; - const onLeave = () => { + const onLeave = (event: MouseEvent) => { + // React can deliver the portal's mouse-enter before this native + // mouse-leave. Do not restart the hide timer when entering the tooltip. + if (interactive && event.relatedTarget instanceof Node + && tooltipRef.current?.contains(event.relatedTarget)) return; if (trigger === "hover-focus" && element.contains(element.ownerDocument.activeElement)) return; if (trigger === "hover" || trigger === "hover-focus") scheduleHideTooltip(); }; @@ -384,7 +388,7 @@ export function Tooltip({ element.removeEventListener("focusout", onBlur); element.removeEventListener("click", onClick); }; - }, [externalTriggerRef, hideTooltip, scheduleHideTooltip, showTooltip, trigger, visible]); + }, [externalTriggerRef, hideTooltip, interactive, scheduleHideTooltip, showTooltip, trigger, visible]); // A measured text slot can mount after focus has already reached its owner. // Only replay focus/virtual activation on a transition, not on visibility updates. @@ -504,6 +508,8 @@ export function Tooltip({ data-openbitfun-interactive={interactive ? "true" : "false"} data-openbitfun-state={isShown ? "visible" : undefined} data-instant={instantRef.current || undefined} + // Portal clicks still bubble through the React tree to the owning control. + onClick={(event) => event.stopPropagation()} onMouseEnter={interactive ? () => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.tsx index da4dbc8c4b..f4c9d3499c 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.tsx +++ b/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.tsx @@ -218,7 +218,6 @@ export function CommandToolCard({ data-openbitfun-part="command" data-empty={resolvedCommand ? "false" : "true"} data-testid={commandTestId} - title={resolvedCommand ?? undefined} > {resolvedCommand ?? emptyCommand} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index 6b14326337..3957d06792 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -592,9 +592,11 @@ describe('UserMessageItem steering tag', () => { const editButton = container.querySelector('.user-message-item__edit-btn'); expect(rollbackButton?.disabled).toBe(true); - expect(rollbackButton?.title).toContain(`message.rollbackDisabled${reason}`); + expect(rollbackButton?.getAttribute('aria-label')).toContain(`message.rollbackDisabled${reason}`); + expect(rollbackButton?.hasAttribute('title')).toBe(false); expect(editButton?.disabled).toBe(true); - expect(editButton?.title).toContain(`message.editDisabled${reason}`); + expect(editButton?.getAttribute('aria-label')).toContain(`message.editDisabled${reason}`); + expect(editButton?.hasAttribute('title')).toBe(false); }); it('hides the edit button when the panel context disables user message editing', () => { diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 953ec7a208..0d93717601 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -669,13 +669,16 @@ export const UserMessageItem = React.memo( )} {!isEditing && (
- + + + {canShowEditAction && ( @@ -704,7 +707,7 @@ export const UserMessageItem = React.memo( className="user-message-item__rollback-btn" onClick={handleRollback} disabled={!canRollback} - title={rollbackTooltip} + aria-label={rollbackTooltip} > {sessionMutation?.kind === 'rollback' && sessionMutation.targetTurnId === turnId ? ( diff --git a/src/web-ui/src/shared/ui/OverflowText.test.tsx b/src/web-ui/src/shared/ui/OverflowText.test.tsx index 7843300bb6..5acdecfdba 100644 --- a/src/web-ui/src/shared/ui/OverflowText.test.tsx +++ b/src/web-ui/src/shared/ui/OverflowText.test.tsx @@ -4,6 +4,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ListboxOption, OverflowText, Select, Tooltip } from '@openbitfun/ui'; +import { CommandToolCard } from '@openbitfun/ui/flow-chat'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -74,6 +75,41 @@ describe('overflow text full-content access', () => { expect(button.getAttribute('aria-describedby')).toBe('help'); }); + it('keeps the command tooltip open when portal enter precedes native trigger leave', () => { + render(); + expect(host.querySelector('[title]')).toBeNull(); + const label = host.querySelector('[data-openbitfun-part="command"] [data-overflow]')!; + const trigger = label.closest('[data-overflow-trigger]') ?? label; + hover(trigger); + reveal(); + const popup = tooltip()!; + expect(popup.textContent).toBe(longLabel); + act(() => { + popup.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: trigger })); + trigger.dispatchEvent(new MouseEvent('mouseleave', { relatedTarget: popup })); + }); + act(() => vi.advanceTimersByTime(1000)); + expect(tooltip()).toBe(popup); + act(() => popup.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }))); + act(() => vi.advanceTimersByTime(500)); + expect(tooltip()).toBeNull(); + }); + + it('does not toggle the command card when clicking inside its tooltip', () => { + const onToggle = vi.fn(); + render(); + const label = host.querySelector('[data-openbitfun-part="command"] [data-overflow]')!; + hover(label.closest('[data-overflow-trigger]') ?? label); + reveal(); + const popup = tooltip()!; + act(() => popup.querySelector('[data-openbitfun-part="body"]')!.click()); + expect(onToggle).not.toHaveBeenCalled(); + expect(tooltip()).toBe(popup); + act(() => label.click()); + expect(onToggle).toHaveBeenCalledOnce(); + }); + it('reveals rich labels and plain-text arrays without waiting for a marquee', () => { render(