From 062c63e736cbe039e86cfe927dbfcf419dcf39bd Mon Sep 17 00:00:00 2001 From: Ic3b3rg Date: Fri, 21 Aug 2026 10:30:22 +0200 Subject: [PATCH 1/4] fix(web): dismiss chat tooltips when scrolling Close hover-opened timeline tooltips when their trigger moves away under a stationary pointer. Preserve keyboard-focused tooltips and keep timeline tooltip portals below the composer. --- .../components/chat/MessagesTimeline.test.tsx | 38 +++++ .../src/components/chat/MessagesTimeline.tsx | 132 ++++++++++-------- apps/web/src/components/ui/tooltip.test.tsx | 91 ++++++++++++ apps/web/src/components/ui/tooltip.tsx | 99 ++++++++++++- .../src/components/ui/tooltipScrollDismiss.ts | 31 ++++ 5 files changed, 326 insertions(+), 65 deletions(-) create mode 100644 apps/web/src/components/ui/tooltip.test.tsx create mode 100644 apps/web/src/components/ui/tooltipScrollDismiss.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ea6cda9b6208..0d46d170a61e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -125,6 +125,32 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("../ui/tooltip", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + TooltipLayerProvider: ({ + children, + layer, + onTriggerHoverChange, + }: { + children: ReactNode; + layer: string; + onTriggerHoverChange?: ( + tooltip: { trigger: HTMLElement; dismiss: () => void } | null, + ) => void; + }) => ( +
+ {children} +
+ ), + }; +}); + function matchMedia() { return { matches: false, @@ -236,6 +262,18 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("renders timeline tooltips in the content layer", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-tooltip-layer="content"'); + expect(markup).toContain('data-tooltip-scroll-dismiss="enabled"'); + }); + it("renders the worked-for row at assistant response text size", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..ded37304d9bf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -90,7 +90,8 @@ import { type TimelineLatestTurn, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipLayerProvider, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { createTooltipScrollDismissController } from "../ui/tooltipScrollDismiss"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -283,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); + const tooltipScrollDismissController = useMemo(createTooltipScrollDismissController, []); const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); @@ -438,7 +440,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); - const handleScroll = useCallback(() => { + const updateTimelineScrollState = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { @@ -468,10 +470,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + const handleScroll = useCallback(() => { + tooltipScrollDismissController.dismissHoveredTooltip(); + updateTimelineScrollState(); + }, [tooltipScrollDismissController, updateTimelineScrollState]); + useEffect(() => { - const frame = requestAnimationFrame(handleScroll); + const frame = requestAnimationFrame(updateTimelineScrollState); return () => cancelAnimationFrame(frame); - }, [handleScroll, rows.length]); + }, [rows.length, updateTimelineScrollState]); useEffect(() => { if (!timelineViewportElement) { @@ -566,62 +573,67 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return ( - - -
- - ref={listRef} - data={rows} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainScrollAtEnd={ - anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling - ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={maintainVisibleContentPosition} - onScroll={handleScroll} - className={cn( - "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={ - loadEarlier !== null ? ( - - ) : topFadeEnabled ? ( - TIMELINE_LIST_FADE_HEADER - ) : ( - TIMELINE_LIST_HEADER - ) - } - ListFooterComponent={TIMELINE_LIST_FOOTER} - /> - { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - }} - /> -
-
-
+ + + +
+ + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={contentInsetEndAdjustment} + maintainScrollAtEnd={ + anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling + ? false + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={maintainVisibleContentPosition} + onScroll={handleScroll} + className={cn( + "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } + ListFooterComponent={TIMELINE_LIST_FOOTER} + /> + { + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + }} + /> +
+
+
+
); }); diff --git a/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000000..b13b099daa2e --- /dev/null +++ b/apps/web/src/components/ui/tooltip.test.tsx @@ -0,0 +1,91 @@ +import { type ComponentProps, Fragment, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@base-ui/react/tooltip", () => { + function Positioner({ + children, + className, + ...props + }: ComponentProps<"div"> & { children?: ReactNode }) { + return ( +
+ {children} +
+ ); + } + + function Element({ children, ...props }: ComponentProps<"div"> & { children?: ReactNode }) { + return
{children}
; + } + + return { + Tooltip: { + createHandle: () => ({}), + Provider: Fragment, + Root: Fragment, + Trigger: Element, + Portal: Fragment, + Positioner, + Popup: Element, + Viewport: Element, + }, + }; +}); + +import { TooltipLayerProvider, TooltipPopup } from "./tooltip"; +import { createTooltipScrollDismissController } from "./tooltipScrollDismiss"; + +function createFakeTooltipTrigger() { + const ownerDocument = { + activeElement: null as Element | null, + }; + const trigger = { ownerDocument } as unknown as HTMLElement; + + return { ownerDocument, trigger }; +} + +describe("tooltip layering", () => { + it("keeps global tooltips above dropdowns", () => { + const html = renderToStaticMarkup(Global tooltip); + + expect(html).toContain("z-[140]"); + }); + + it("keeps content tooltips below the chat composer", () => { + const html = renderToStaticMarkup( + + Timeline tooltip + , + ); + + expect(html).toContain("z-[15]"); + expect(html).not.toContain("z-[140]"); + }); +}); + +describe("tooltip scroll dismissal", () => { + it("dismisses the hovered tooltip once when its content scrolls", () => { + const controller = createTooltipScrollDismissController(); + const { trigger } = createFakeTooltipTrigger(); + const dismiss = vi.fn(); + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + controller.dismissHoveredTooltip(); + + expect(dismiss).toHaveBeenCalledOnce(); + }); + + it("keeps a focused tooltip open when its content scrolls", () => { + const controller = createTooltipScrollDismissController(); + const { ownerDocument, trigger } = createFakeTooltipTrigger(); + const dismiss = vi.fn(); + ownerDocument.activeElement = trigger; + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + + expect(dismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 77b15a01e16b..57f13cd8c3b7 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,15 +1,92 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef } from "react"; import { cn } from "~/lib/utils"; +import type { HoveredTooltip } from "./tooltipScrollDismiss"; const TooltipCreateHandle = TooltipPrimitive.createHandle; +type TooltipLayer = "global" | "content"; + +type TooltipLayerContextValue = { + layer: TooltipLayer; + onTriggerHoverChange: ((tooltip: HoveredTooltip | null) => void) | undefined; +}; + +const DEFAULT_TOOLTIP_LAYER_CONTEXT = { + layer: "global", + onTriggerHoverChange: undefined, +} as const; +const TooltipLayerContext = createContext(DEFAULT_TOOLTIP_LAYER_CONTEXT); +const TooltipDismissContext = createContext<(() => void) | null>(null); + +function TooltipLayerProvider({ + children, + layer, + onTriggerHoverChange, +}: { + children: ReactNode; + layer: TooltipLayer; + onTriggerHoverChange?: (tooltip: HoveredTooltip | null) => void; +}) { + const value = useMemo(() => ({ layer, onTriggerHoverChange }), [layer, onTriggerHoverChange]); + + return {children}; +} + const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root; +function Tooltip(props: TooltipPrimitive.Root.Props) { + const { onTriggerHoverChange } = useContext(TooltipLayerContext); + + if (!onTriggerHoverChange) { + return ; + } -function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { - return ; + return ; +} + +function TrackedTooltip({ actionsRef, ...props }: TooltipPrimitive.Root.Props) { + const localActionsRef = useRef(null); + const resolvedActionsRef = actionsRef ?? localActionsRef; + const dismiss = useCallback(() => resolvedActionsRef.current?.close(), [resolvedActionsRef]); + + return ( + + + + ); +} + +function TooltipTrigger({ onMouseEnter, onMouseLeave, ...props }: TooltipPrimitive.Trigger.Props) { + const { onTriggerHoverChange } = useContext(TooltipLayerContext); + const dismiss = useContext(TooltipDismissContext); + + if (!onTriggerHoverChange || !dismiss) { + return ( + + ); + } + + return ( + { + onTriggerHoverChange({ trigger: event.currentTarget, dismiss }); + onMouseEnter?.(event); + }} + onMouseLeave={(event) => { + onTriggerHoverChange(null); + onMouseLeave?.(event); + }} + {...props} + /> + ); } function TooltipPopup({ @@ -28,12 +105,17 @@ function TooltipPopup({ variant?: "default" | "glass"; anchor?: TooltipPrimitive.Positioner.Props["anchor"]; }) { + const { layer } = useContext(TooltipLayerContext); + return ( void; +}; + +type TooltipScrollDismissController = { + setHoveredTooltip: (tooltip: HoveredTooltip | null) => void; + dismissHoveredTooltip: () => void; +}; + +function createTooltipScrollDismissController(): TooltipScrollDismissController { + let hoveredTooltip: HoveredTooltip | null = null; + + return { + setHoveredTooltip(tooltip) { + hoveredTooltip = tooltip; + }, + dismissHoveredTooltip() { + const tooltip = hoveredTooltip; + hoveredTooltip = null; + + if (tooltip === null || tooltip.trigger.ownerDocument.activeElement === tooltip.trigger) { + return; + } + + tooltip.dismiss(); + }, + }; +} + +export { createTooltipScrollDismissController, type HoveredTooltip }; From 4523b7fbe4c2e4cc75899b44af86979692072f40 Mon Sep 17 00:00:00 2001 From: Ic3b3rg Date: Fri, 21 Aug 2026 10:49:50 +0200 Subject: [PATCH 2/4] fix(web): preserve tooltip focus within triggers Treat focus on descendants of a rendered tooltip trigger as focus within the trigger so scrolling does not dismiss keyboard-accessible tooltips. --- apps/web/src/components/ui/tooltip.test.tsx | 24 +++++++++++++++++-- .../src/components/ui/tooltipScrollDismiss.ts | 5 +++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx index b13b099daa2e..7dab1b55bbc1 100644 --- a/apps/web/src/components/ui/tooltip.test.tsx +++ b/apps/web/src/components/ui/tooltip.test.tsx @@ -40,9 +40,15 @@ function createFakeTooltipTrigger() { const ownerDocument = { activeElement: null as Element | null, }; - const trigger = { ownerDocument } as unknown as HTMLElement; + const descendants = new Set(); + const trigger = { + ownerDocument, + contains(node: Node | null) { + return node === trigger || (node !== null && descendants.has(node)); + }, + } as unknown as HTMLElement; - return { ownerDocument, trigger }; + return { descendants, ownerDocument, trigger }; } describe("tooltip layering", () => { @@ -88,4 +94,18 @@ describe("tooltip scroll dismissal", () => { expect(dismiss).not.toHaveBeenCalled(); }); + + it("keeps a tooltip open when focus is within its trigger", () => { + const controller = createTooltipScrollDismissController(); + const { descendants, ownerDocument, trigger } = createFakeTooltipTrigger(); + const focusedChild = {} as Element; + const dismiss = vi.fn(); + descendants.add(focusedChild); + ownerDocument.activeElement = focusedChild; + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + + expect(dismiss).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/ui/tooltipScrollDismiss.ts b/apps/web/src/components/ui/tooltipScrollDismiss.ts index a119293d8395..3bf1e3b989c0 100644 --- a/apps/web/src/components/ui/tooltipScrollDismiss.ts +++ b/apps/web/src/components/ui/tooltipScrollDismiss.ts @@ -19,7 +19,10 @@ function createTooltipScrollDismissController(): TooltipScrollDismissController const tooltip = hoveredTooltip; hoveredTooltip = null; - if (tooltip === null || tooltip.trigger.ownerDocument.activeElement === tooltip.trigger) { + if ( + tooltip === null || + tooltip.trigger.contains(tooltip.trigger.ownerDocument.activeElement) + ) { return; } From b0d52bb2f21376ecefee53b029bd952a6d9671e9 Mon Sep 17 00:00:00 2001 From: Ic3b3rg Date: Fri, 21 Aug 2026 10:56:58 +0200 Subject: [PATCH 3/4] fix(web): keep timeline tooltips above chat chrome Remove the content-specific tooltip layer so scroll dismissal does not place timeline tooltips behind minimaps and floating controls. Keep the scroll path on a stable callback-only context. --- .../components/chat/MessagesTimeline.test.tsx | 24 +--- .../src/components/chat/MessagesTimeline.tsx | 123 +++++++++--------- apps/web/src/components/ui/tooltip.test.tsx | 12 +- apps/web/src/components/ui/tooltip.tsx | 42 ++---- 4 files changed, 88 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0d46d170a61e..80ebaa96a307 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,5 +1,5 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; -import { createRef, type ReactNode, type Ref } from "react"; +import { type ComponentProps, createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; @@ -130,23 +130,10 @@ vi.mock("../ui/tooltip", async (importOriginal) => { return { ...actual, - TooltipLayerProvider: ({ + TooltipScrollDismissProvider: ({ children, - layer, - onTriggerHoverChange, - }: { - children: ReactNode; - layer: string; - onTriggerHoverChange?: ( - tooltip: { trigger: HTMLElement; dismiss: () => void } | null, - ) => void; - }) => ( -
- {children} -
+ }: ComponentProps) => ( +
{children}
), }; }); @@ -262,7 +249,7 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { - it("renders timeline tooltips in the content layer", () => { + it("enables tooltip scroll dismissal in the timeline", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('data-tooltip-layer="content"'); expect(markup).toContain('data-tooltip-scroll-dismiss="enabled"'); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ded37304d9bf..f1aa5878fd84 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -90,7 +90,7 @@ import { type TimelineLatestTurn, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -import { Tooltip, TooltipLayerProvider, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipScrollDismissProvider, TooltipTrigger } from "../ui/tooltip"; import { createTooltipScrollDismissController } from "../ui/tooltipScrollDismiss"; import { deriveDisplayedUserMessageState, @@ -572,68 +572,71 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); } + const timeline = ( + + +
+ + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={contentInsetEndAdjustment} + maintainScrollAtEnd={ + anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling + ? false + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={maintainVisibleContentPosition} + onScroll={handleScroll} + className={cn( + "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } + ListFooterComponent={TIMELINE_LIST_FOOTER} + /> + { + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + }} + /> +
+
+
+ ); + return ( - - - -
- - ref={listRef} - data={rows} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainScrollAtEnd={ - anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling - ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={maintainVisibleContentPosition} - onScroll={handleScroll} - className={cn( - "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={ - loadEarlier !== null ? ( - - ) : topFadeEnabled ? ( - TIMELINE_LIST_FADE_HEADER - ) : ( - TIMELINE_LIST_HEADER - ) - } - ListFooterComponent={TIMELINE_LIST_FOOTER} - /> - { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - }} - /> -
-
-
-
+ {timeline} + ); }); diff --git a/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx index 7dab1b55bbc1..5ab8b7256454 100644 --- a/apps/web/src/components/ui/tooltip.test.tsx +++ b/apps/web/src/components/ui/tooltip.test.tsx @@ -33,7 +33,7 @@ vi.mock("@base-ui/react/tooltip", () => { }; }); -import { TooltipLayerProvider, TooltipPopup } from "./tooltip"; +import { TooltipPopup, TooltipScrollDismissProvider } from "./tooltip"; import { createTooltipScrollDismissController } from "./tooltipScrollDismiss"; function createFakeTooltipTrigger() { @@ -58,15 +58,15 @@ describe("tooltip layering", () => { expect(html).toContain("z-[140]"); }); - it("keeps content tooltips below the chat composer", () => { + it("keeps timeline tooltips above chat chrome", () => { const html = renderToStaticMarkup( - + {}}> Timeline tooltip - , + , ); - expect(html).toContain("z-[15]"); - expect(html).not.toContain("z-[140]"); + expect(html).toContain("z-[140]"); + expect(html).not.toContain("z-[15]"); }); }); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 57f13cd8c3b7..fe49df261c07 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,43 +1,34 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; -import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useRef } from "react"; import { cn } from "~/lib/utils"; import type { HoveredTooltip } from "./tooltipScrollDismiss"; const TooltipCreateHandle = TooltipPrimitive.createHandle; -type TooltipLayer = "global" | "content"; +type TooltipScrollDismissContextValue = ((tooltip: HoveredTooltip | null) => void) | null; -type TooltipLayerContextValue = { - layer: TooltipLayer; - onTriggerHoverChange: ((tooltip: HoveredTooltip | null) => void) | undefined; -}; - -const DEFAULT_TOOLTIP_LAYER_CONTEXT = { - layer: "global", - onTriggerHoverChange: undefined, -} as const; -const TooltipLayerContext = createContext(DEFAULT_TOOLTIP_LAYER_CONTEXT); +const TooltipScrollDismissContext = createContext(null); const TooltipDismissContext = createContext<(() => void) | null>(null); -function TooltipLayerProvider({ +function TooltipScrollDismissProvider({ children, - layer, onTriggerHoverChange, }: { children: ReactNode; - layer: TooltipLayer; - onTriggerHoverChange?: (tooltip: HoveredTooltip | null) => void; + onTriggerHoverChange: (tooltip: HoveredTooltip | null) => void; }) { - const value = useMemo(() => ({ layer, onTriggerHoverChange }), [layer, onTriggerHoverChange]); - - return {children}; + return ( + + {children} + + ); } const TooltipProvider = TooltipPrimitive.Provider; function Tooltip(props: TooltipPrimitive.Root.Props) { - const { onTriggerHoverChange } = useContext(TooltipLayerContext); + const onTriggerHoverChange = useContext(TooltipScrollDismissContext); if (!onTriggerHoverChange) { return ; @@ -59,7 +50,7 @@ function TrackedTooltip({ actionsRef, ...props }: TooltipPrimitive.Root } function TooltipTrigger({ onMouseEnter, onMouseLeave, ...props }: TooltipPrimitive.Trigger.Props) { - const { onTriggerHoverChange } = useContext(TooltipLayerContext); + const onTriggerHoverChange = useContext(TooltipScrollDismissContext); const dismiss = useContext(TooltipDismissContext); if (!onTriggerHoverChange || !dismiss) { @@ -105,17 +96,12 @@ function TooltipPopup({ variant?: "default" | "glass"; anchor?: TooltipPrimitive.Positioner.Props["anchor"]; }) { - const { layer } = useContext(TooltipLayerContext); - return ( Date: Fri, 21 Aug 2026 10:59:35 +0200 Subject: [PATCH 4/4] fix(web): retain focused tooltip tracking Keep the hovered tooltip reference while focus remains within its trigger so a later scroll can dismiss it after focus moves away. --- apps/web/src/components/ui/tooltip.test.tsx | 14 ++++++++++++++ apps/web/src/components/ui/tooltipScrollDismiss.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx index 5ab8b7256454..d5cc887fd24c 100644 --- a/apps/web/src/components/ui/tooltip.test.tsx +++ b/apps/web/src/components/ui/tooltip.test.tsx @@ -95,6 +95,20 @@ describe("tooltip scroll dismissal", () => { expect(dismiss).not.toHaveBeenCalled(); }); + it("dismisses a hovered tooltip on a later scroll after focus leaves", () => { + const controller = createTooltipScrollDismissController(); + const { ownerDocument, trigger } = createFakeTooltipTrigger(); + const dismiss = vi.fn(); + ownerDocument.activeElement = trigger; + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + ownerDocument.activeElement = null; + controller.dismissHoveredTooltip(); + + expect(dismiss).toHaveBeenCalledOnce(); + }); + it("keeps a tooltip open when focus is within its trigger", () => { const controller = createTooltipScrollDismissController(); const { descendants, ownerDocument, trigger } = createFakeTooltipTrigger(); diff --git a/apps/web/src/components/ui/tooltipScrollDismiss.ts b/apps/web/src/components/ui/tooltipScrollDismiss.ts index 3bf1e3b989c0..eb96cae98ec7 100644 --- a/apps/web/src/components/ui/tooltipScrollDismiss.ts +++ b/apps/web/src/components/ui/tooltipScrollDismiss.ts @@ -17,7 +17,6 @@ function createTooltipScrollDismissController(): TooltipScrollDismissController }, dismissHoveredTooltip() { const tooltip = hoveredTooltip; - hoveredTooltip = null; if ( tooltip === null || @@ -26,6 +25,7 @@ function createTooltipScrollDismissController(): TooltipScrollDismissController return; } + hoveredTooltip = null; tooltip.dismiss(); }, };