diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx index 160ebc6417..326cba2194 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -263,7 +263,13 @@ export function Tooltip({ // Capture nested, non-bubbling scrolls during both delayed and visible states. // Dormant tooltips do not need a document-level scroll subscription. if (view && !scrollCleanupRef.current) { - const dismissOnScroll = () => hideCurrentRef.current(); + const dismissOnScroll = (event: Event) => { + // Scrolling the tooltip's own scroll container (a wheel over the + // preview or a scrollbar drag inside it) is not a moving anchor. + const surface = tooltipRef.current; + if (surface && event.target instanceof Node && surface.contains(event.target)) return; + hideCurrentRef.current(); + }; view.addEventListener("scroll", dismissOnScroll, { capture: true, passive: true }); view.visualViewport?.addEventListener("scroll", dismissOnScroll, { passive: true }); scrollCleanupRef.current = () => { diff --git a/src/web-ui/src/shared/ui/Tooltip.test.tsx b/src/web-ui/src/shared/ui/Tooltip.test.tsx index c13447c768..4769c09ec9 100644 --- a/src/web-ui/src/shared/ui/Tooltip.test.tsx +++ b/src/web-ui/src/shared/ui/Tooltip.test.tsx @@ -114,6 +114,27 @@ describe('shared tooltip visibility', () => { expect(popup()).toBeNull(); }); + it('keeps an interactive preview open while its own scroll container scrolls', () => { + render( + Sub-agent details

}> + +
, + ); + const button = host.querySelector('button')!; + enter(button); advance(); advance(1); + const card = popup()!; + expect(card.textContent).toBe('Sub-agent details'); + // The tooltip body is its own scroll container: wheel scrolling or a + // scrollbar drag inside the preview must not dismiss the preview. + act(() => card.dispatchEvent(new Event('scroll'))); + expect(popup()).toBe(card); + act(() => card.querySelector('[data-openbitfun-part="content"]')!.dispatchEvent(new Event('scroll'))); + expect(popup()).toBe(card); + // A scroll owned by the surface behind the trigger still dismisses it. + act(() => host.dispatchEvent(new Event('scroll'))); + expect(popup()).toBeNull(); + }); + it('dismisses pinned cards on visual viewport scrolling', () => { const viewport = new EventTarget(); vi.stubGlobal('visualViewport', viewport);