Skip to content

Commit 3a8baed

Browse files
V48 Gate 3 (implementation-only): explainer tooltips scroll their overflow instead of clipping it
The base tooltip (BitcodeInlineExplainer + TelemetryExplainerTrigger) was pointer-events-none, so content past the viewport-capped maxHeight clipped unreachably. Tooltips are now interactive: pointer-events-auto with overscroll-contain, a 160ms hide grace so the pointer can travel from trigger into the tooltip, hover inside cancels the hide, and the scroll-capture dismisser ignores scrolls originating INSIDE the tooltip (that is how the overflow is read); page scrolls/resizes still dismiss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 683bef7 commit 3a8baed

2 files changed

Lines changed: 94 additions & 21 deletions

File tree

uapi/components/base/bitcode/execution/BitcodeInlineExplainer.tsx

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import React, { useCallback, useEffect, useState } from 'react';
3+
import React, { useCallback, useEffect, useRef, useState } from 'react';
44
import { createPortal } from 'react-dom';
55

66
import { cn } from '@bitcode/styling';
@@ -156,39 +156,75 @@ export default function BitcodeInlineExplainer({
156156
setIsMounted(true);
157157
}, []);
158158

159+
const tooltipRef = useRef<HTMLSpanElement | null>(null);
160+
const hideTimeoutRef = useRef<number | null>(null);
161+
162+
const cancelScheduledHide = useCallback(() => {
163+
if (hideTimeoutRef.current !== null) {
164+
window.clearTimeout(hideTimeoutRef.current);
165+
hideTimeoutRef.current = null;
166+
}
167+
}, []);
168+
169+
useEffect(() => cancelScheduledHide, [cancelScheduledHide]);
170+
171+
const hideTooltipNow = useCallback(() => {
172+
cancelScheduledHide();
173+
setIsVisible(false);
174+
}, [cancelScheduledHide]);
175+
159176
useEffect(() => {
160177
if (!isVisible) return undefined;
161178

162-
const hideTooltip = () => setIsVisible(false);
163-
window.addEventListener('scroll', hideTooltip, true);
164-
window.addEventListener('resize', hideTooltip);
179+
// Scrolls INSIDE the tooltip must not dismiss it — that is how
180+
// overflowing explainer content is read (the tooltip is viewport-height
181+
// capped and scrolls). Page scrolls and resizes still dismiss.
182+
const hideOnViewportChange = (event: Event) => {
183+
if (
184+
tooltipRef.current &&
185+
event.target instanceof Node &&
186+
tooltipRef.current.contains(event.target)
187+
) {
188+
return;
189+
}
190+
hideTooltipNow();
191+
};
192+
window.addEventListener('scroll', hideOnViewportChange, true);
193+
window.addEventListener('resize', hideTooltipNow);
165194
return () => {
166-
window.removeEventListener('scroll', hideTooltip, true);
167-
window.removeEventListener('resize', hideTooltip);
195+
window.removeEventListener('scroll', hideOnViewportChange, true);
196+
window.removeEventListener('resize', hideTooltipNow);
168197
};
169-
}, [isVisible]);
198+
}, [hideTooltipNow, isVisible]);
170199

171200
const showTooltip = useCallback(
172201
(event: React.SyntheticEvent<HTMLElement>) => {
202+
cancelScheduledHide();
173203
const trigger = event.currentTarget.querySelector('button');
174204
if (trigger instanceof HTMLElement) {
175205
setPlacement(resolveExplainerPlacement(trigger, side));
176206
setIsVisible(true);
177207
}
178208
},
179-
[side],
209+
[cancelScheduledHide, side],
180210
);
181211

212+
// Grace period so the pointer can travel from the trigger into the
213+
// tooltip to scroll overflowing content without the tooltip vanishing.
182214
const hideTooltip = useCallback(() => {
183-
setIsVisible(false);
184-
}, []);
215+
cancelScheduledHide();
216+
hideTimeoutRef.current = window.setTimeout(() => setIsVisible(false), 160);
217+
}, [cancelScheduledHide]);
185218

186219
const tooltipMarkup = isMounted && isVisible
187220
? createPortal(
188221
<span
189222
role="tooltip"
223+
ref={tooltipRef}
224+
onMouseEnter={cancelScheduledHide}
225+
onMouseLeave={hideTooltipNow}
190226
className={cn(
191-
'pointer-events-none fixed z-[90] overflow-y-auto rounded-[1.15rem] border border-white/10 bg-[rgba(4,8,18,0.98)] px-4 py-4 text-left text-sm font-normal normal-case tracking-normal opacity-100 shadow-[0_24px_56px_rgba(0,0,0,0.42)] transition duration-150 ease-out',
227+
'pointer-events-auto fixed z-[90] overflow-y-auto overscroll-contain rounded-[1.15rem] border border-white/10 bg-[rgba(4,8,18,0.98)] px-4 py-4 text-left text-sm font-normal normal-case tracking-normal opacity-100 shadow-[0_24px_56px_rgba(0,0,0,0.42)] transition duration-150 ease-out',
192228
)}
193229
style={tooltipPositionStyle(placement)}
194230
>

uapi/components/base/bitcode/execution/TelemetryExplainerTrigger.tsx

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import React, { useCallback, useEffect, useState } from 'react';
3+
import React, { useCallback, useEffect, useRef, useState } from 'react';
44
import { createPortal } from 'react-dom';
55

66
import { cn } from '@bitcode/styling';
@@ -45,37 +45,74 @@ export function TelemetryExplainerTrigger({
4545
}: TelemetryExplainerTriggerProps) {
4646
const [placement, setPlacement] = useState<TooltipPlacement | null>(null);
4747
const [isMounted, setIsMounted] = useState(false);
48+
const tooltipRef = useRef<HTMLSpanElement | null>(null);
49+
const hideTimeoutRef = useRef<number | null>(null);
4850

4951
useEffect(() => {
5052
setIsMounted(true);
5153
}, []);
5254

55+
const cancelScheduledHide = useCallback(() => {
56+
if (hideTimeoutRef.current !== null) {
57+
window.clearTimeout(hideTimeoutRef.current);
58+
hideTimeoutRef.current = null;
59+
}
60+
}, []);
61+
62+
useEffect(() => cancelScheduledHide, [cancelScheduledHide]);
63+
64+
const hideTooltipNow = useCallback(() => {
65+
cancelScheduledHide();
66+
setPlacement(null);
67+
}, [cancelScheduledHide]);
68+
5369
useEffect(() => {
5470
if (!placement) return undefined;
55-
const hideTooltip = () => setPlacement(null);
56-
window.addEventListener('scroll', hideTooltip, true);
57-
window.addEventListener('resize', hideTooltip);
71+
// Scrolls INSIDE the tooltip must not dismiss it — that is how
72+
// overflowing content is read (the tooltip is viewport-height capped
73+
// and scrolls). Page scrolls and resizes still dismiss.
74+
const hideOnViewportChange = (event: Event) => {
75+
if (
76+
tooltipRef.current &&
77+
event.target instanceof Node &&
78+
tooltipRef.current.contains(event.target)
79+
) {
80+
return;
81+
}
82+
hideTooltipNow();
83+
};
84+
window.addEventListener('scroll', hideOnViewportChange, true);
85+
window.addEventListener('resize', hideTooltipNow);
5886
return () => {
59-
window.removeEventListener('scroll', hideTooltip, true);
60-
window.removeEventListener('resize', hideTooltip);
87+
window.removeEventListener('scroll', hideOnViewportChange, true);
88+
window.removeEventListener('resize', hideTooltipNow);
6189
};
62-
}, [placement]);
90+
}, [hideTooltipNow, placement]);
6391

6492
const showTooltip = useCallback(
6593
(event: React.SyntheticEvent<HTMLElement>) => {
94+
cancelScheduledHide();
6695
setPlacement(resolveExplainerPlacement(event.currentTarget, side));
6796
},
68-
[side],
97+
[cancelScheduledHide, side],
6998
);
7099

71-
const hideTooltip = useCallback(() => setPlacement(null), []);
100+
// Grace period so the pointer can travel from the trigger into the
101+
// tooltip to scroll overflowing content without the tooltip vanishing.
102+
const hideTooltip = useCallback(() => {
103+
cancelScheduledHide();
104+
hideTimeoutRef.current = window.setTimeout(() => setPlacement(null), 160);
105+
}, [cancelScheduledHide]);
72106

73107
const tooltipMarkup =
74108
isMounted && placement
75109
? createPortal(
76110
<span
77111
role="tooltip"
78-
className="pointer-events-none fixed z-[90] overflow-y-auto rounded-[1.15rem] border border-white/10 bg-[rgba(4,8,18,0.98)] px-4 py-4 text-left text-sm font-normal normal-case tracking-normal opacity-100 shadow-[0_24px_56px_rgba(0,0,0,0.42)] transition duration-150 ease-out"
112+
ref={tooltipRef}
113+
onMouseEnter={cancelScheduledHide}
114+
onMouseLeave={hideTooltipNow}
115+
className="pointer-events-auto fixed z-[90] overflow-y-auto overscroll-contain rounded-[1.15rem] border border-white/10 bg-[rgba(4,8,18,0.98)] px-4 py-4 text-left text-sm font-normal normal-case tracking-normal opacity-100 shadow-[0_24px_56px_rgba(0,0,0,0.42)] transition duration-150 ease-out"
79116
style={tooltipPositionStyle(placement)}
80117
>
81118
<span

0 commit comments

Comments
 (0)