Skip to content

Commit 5071a8a

Browse files
committed
fix(ui): keep an interactive tooltip preview open while it scrolls
An open tooltip subscribes to capture-phase scroll events so a moving anchor never leaves a stale preview behind. Scrolling the tooltip's own scroll container is not a moving anchor: with the wheel over the sub-agent preview, or a drag on the preview scrollbar, that listener closed the preview exactly when the reader was scrolling it to read the rest of the task description. Ignore scroll events whose target sits inside the tooltip surface so the preview body can be scrolled, while a scroll owned by the surface behind the trigger still dismisses it. Cover both directions in the tooltip visibility suite.
1 parent 7f5ea0f commit 5071a8a

2 files changed

Lines changed: 28 additions & 1 deletion

File tree

‎design-system/packages/ui/src/components/Tooltip/Tooltip.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,13 @@ export function Tooltip({
263263
// Capture nested, non-bubbling scrolls during both delayed and visible states.
264264
// Dormant tooltips do not need a document-level scroll subscription.
265265
if (view && !scrollCleanupRef.current) {
266-
const dismissOnScroll = () => hideCurrentRef.current();
266+
const dismissOnScroll = (event: Event) => {
267+
// Scrolling the tooltip's own scroll container (a wheel over the
268+
// preview or a scrollbar drag inside it) is not a moving anchor.
269+
const surface = tooltipRef.current;
270+
if (surface && event.target instanceof Node && surface.contains(event.target)) return;
271+
hideCurrentRef.current();
272+
};
267273
view.addEventListener("scroll", dismissOnScroll, { capture: true, passive: true });
268274
view.visualViewport?.addEventListener("scroll", dismissOnScroll, { passive: true });
269275
scrollCleanupRef.current = () => {

‎src/web-ui/src/shared/ui/Tooltip.test.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ describe('shared tooltip visibility', () => {
114114
expect(popup()).toBeNull();
115115
});
116116

117+
it('keeps an interactive preview open while its own scroll container scrolls', () => {
118+
render(
119+
<Tooltip interactive content={<p data-testid="preview">Sub-agent details</p>}>
120+
<button>Details</button>
121+
</Tooltip>,
122+
);
123+
const button = host.querySelector('button')!;
124+
enter(button); advance(); advance(1);
125+
const card = popup()!;
126+
expect(card.textContent).toBe('Sub-agent details');
127+
// The tooltip body is its own scroll container: wheel scrolling or a
128+
// scrollbar drag inside the preview must not dismiss the preview.
129+
act(() => card.dispatchEvent(new Event('scroll')));
130+
expect(popup()).toBe(card);
131+
act(() => card.querySelector('[data-openbitfun-part="content"]')!.dispatchEvent(new Event('scroll')));
132+
expect(popup()).toBe(card);
133+
// A scroll owned by the surface behind the trigger still dismisses it.
134+
act(() => host.dispatchEvent(new Event('scroll')));
135+
expect(popup()).toBeNull();
136+
});
137+
117138
it('dismisses pinned cards on visual viewport scrolling', () => {
118139
const viewport = new EventTarget();
119140
vi.stubGlobal('visualViewport', viewport);

0 commit comments

Comments
 (0)