Skip to content

Commit c0ea1fe

Browse files
committed
fix(app): preserve context tooltip on scroll
1 parent 59fe350 commit c0ea1fe

3 files changed

Lines changed: 80 additions & 2 deletions

File tree

packages/app/e2e/regression/session-timeline-accessibility.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,45 @@ test("space activates a focused timeline button instead of scrolling", async ({
1717
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
1818
})
1919

20+
test("keeps the context usage tooltip open while its sticky trigger remains hovered", async ({ page }) => {
21+
await page.addInitScript(() => {
22+
localStorage.setItem("opencode.global.dat:tabsInfoPopup", JSON.stringify({ dismissed: true }))
23+
})
24+
await setupTimeline(page, {
25+
settings: { newLayoutDesigns: true },
26+
seedHistory: true,
27+
viewport: { width: 1200, height: 600 },
28+
reducedMotion: true,
29+
})
30+
31+
const button = page.getByRole("button", { name: "View context usage" })
32+
const tooltip = page.locator('[data-component="tooltip-v2"]')
33+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
34+
35+
await button.hover()
36+
await expect(tooltip).toBeVisible()
37+
const before = await scroller.evaluate((element) => element.scrollTop)
38+
await scroller.evaluate(
39+
(element) =>
40+
new Promise<void>((resolve) => {
41+
element.addEventListener("scroll", () => resolve(), { once: true })
42+
element.scrollBy(0, -100)
43+
}),
44+
)
45+
46+
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).not.toBe(before)
47+
await expect.poll(() => button.evaluate((element) => element.matches(":hover"))).toBe(true)
48+
await expect(tooltip).toBeVisible()
49+
await page.keyboard.press("Escape")
50+
await expect(tooltip).toBeHidden()
51+
52+
await page.mouse.move(0, 0)
53+
await button.hover()
54+
await expect(tooltip).toBeVisible()
55+
await page.mouse.move(0, 0)
56+
await expect(tooltip).toBeHidden()
57+
})
58+
2059
function lines(count: number) {
2160
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
2261
}

packages/app/src/components/session-context-usage.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,12 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
138138

139139
return (
140140
<Show when={params.id}>
141-
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
141+
<TooltipV2
142+
value={tooltipValue()}
143+
placement={props.placement ?? "top"}
144+
shift={-8}
145+
keepOpenOnScroll={buttonAppearance() === "v2"}
146+
>
142147
<Switch>
143148
<Match when={variant() === "indicator"}>{circle()}</Match>
144149
<Match when={buttonAppearance() === "v2"}>

packages/ui/src/v2/components/tooltip-v2.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
2-
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
2+
import { makeEventListener } from "@solid-primitives/event-listener"
3+
import { createEffect, createRenderEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
34
import type { ComponentProps } from "solid-js"
45
import { createStore } from "solid-js/store"
56
import "./tooltip-v2.css"
@@ -11,6 +12,7 @@ export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
1112
contentStyle?: JSX.CSSProperties
1213
inactive?: boolean
1314
forceOpen?: boolean
15+
keepOpenOnScroll?: boolean
1416
}
1517

1618
export function TooltipV2(props: TooltipV2Props) {
@@ -27,6 +29,7 @@ export function TooltipV2(props: TooltipV2Props) {
2729
"contentStyle",
2830
"inactive",
2931
"forceOpen",
32+
"keepOpenOnScroll",
3033
"ignoreSafeArea",
3134
"value",
3235
])
@@ -62,7 +65,35 @@ export function TooltipV2(props: TooltipV2Props) {
6265
close()
6366
}
6467

68+
let scrollingTrigger = false
69+
let scrollFrame: number | undefined
70+
71+
const clearScroll = () => {
72+
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
73+
scrollFrame = undefined
74+
scrollingTrigger = false
75+
}
76+
77+
createRenderEffect(() => {
78+
if (!local.keepOpenOnScroll) return
79+
makeEventListener(
80+
window,
81+
"scroll",
82+
(event) => {
83+
if (!state.open || !ref || !(event.target instanceof Node) || !event.target.contains(ref)) return
84+
clearScroll()
85+
scrollingTrigger = true
86+
scrollFrame = requestAnimationFrame(() => {
87+
scrollingTrigger = false
88+
scrollFrame = undefined
89+
})
90+
},
91+
{ capture: true },
92+
)
93+
})
94+
6595
const leave = () => {
96+
clearScroll()
6697
if (!inside()) close()
6798
drop()
6899
}
@@ -97,6 +128,7 @@ export function TooltipV2(props: TooltipV2Props) {
97128
onOpenChange={(open) => {
98129
if (local.forceOpen) return
99130
if (state.block && open) return
131+
if (!open && scrollingTrigger && ref?.matches(":hover")) return
100132
if (justClickedTrigger) {
101133
justClickedTrigger = false
102134
return
@@ -110,6 +142,7 @@ export function TooltipV2(props: TooltipV2Props) {
110142
data-component="tooltip-v2-trigger"
111143
class={local.class}
112144
onPointerDownCapture={arm}
145+
onBlur={clearScroll}
113146
onKeyDownCapture={(event: KeyboardEvent) => {
114147
if (event.key !== "Enter" && event.key !== " ") return
115148
arm()
@@ -130,6 +163,7 @@ export function TooltipV2(props: TooltipV2Props) {
130163
data-force-open={local.forceOpen}
131164
class={local.contentClass}
132165
style={local.contentStyle}
166+
onEscapeKeyDown={clearScroll}
133167
onPointerDownOutside={(e) => {
134168
if (ref === e.target || (e.target instanceof Node && ref?.contains(e.target))) {
135169
justClickedTrigger = true

0 commit comments

Comments
 (0)