Skip to content

Commit a5a5df6

Browse files
Fix clipped usage chart tooltip (#82846)
Summary: - The PR replaces per-bar absolute Usage chart tooltips with one viewport-fixed floating tooltip and adds focus/keyboard handling plus focused jsdom coverage. - Reproducibility: yes. at source level. Current main renders an absolute `.daily-bar-tooltip` inside `.daily- ... ` overflow contexts, and the linked issue plus PR before screenshot demonstrate the tall-bar clipping case. Automerge notes: - PR branch already contained follow-up commit before automerge: Merge branch 'main' into fix-usage-tooltip-clipping Validation: - ClawSweeper review passed for head edbb26a. - Required merge gates passed before the squash merge. Prepared head SHA: edbb26a Review: #82846 (comment) Co-authored-by: sandypockets <41454557+sandypockets@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 0f1f952 commit a5a5df6

3 files changed

Lines changed: 440 additions & 12 deletions

File tree

ui/src/styles/usage.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,11 @@ details.usage-filter-select summary::-webkit-details-marker,
10341034
transform: translateY(-1px);
10351035
}
10361036

1037+
.daily-bar-wrapper:focus-visible {
1038+
outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent);
1039+
outline-offset: 3px;
1040+
}
1041+
10371042
.daily-bar--stacked {
10381043
display: flex;
10391044
flex-direction: column;
@@ -1079,6 +1084,17 @@ details.usage-filter-select summary::-webkit-details-marker,
10791084
transform: translate(-50%, 0);
10801085
}
10811086

1087+
.daily-bar-tooltip--floating {
1088+
position: fixed;
1089+
left: 0;
1090+
top: 0;
1091+
bottom: auto;
1092+
transform: none;
1093+
max-width: min(220px, calc(100vw - 16px));
1094+
opacity: 1;
1095+
z-index: 1000;
1096+
}
1097+
10821098
.cost-breakdown-bar {
10831099
display: flex;
10841100
width: 100%;

ui/src/ui/views/usage-render-overview.test.ts

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
/* @vitest-environment jsdom */
22

33
import { render } from "lit";
4-
import { describe, expect, it } from "vitest";
5-
import { renderSessionsCard, renderUsageInsights } from "./usage-render-overview.ts";
6-
import type { UsageAggregates, UsageSessionEntry, UsageTotals } from "./usageTypes.ts";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
renderDailyChartCompact,
7+
renderSessionsCard,
8+
renderUsageInsights,
9+
} from "./usage-render-overview.ts";
10+
import type {
11+
CostDailyEntry,
12+
UsageAggregates,
13+
UsageSessionEntry,
14+
UsageTotals,
15+
} from "./usageTypes.ts";
716

817
const totals: UsageTotals = {
918
input: 100,
@@ -40,6 +49,89 @@ const aggregates = {
4049
daily: [],
4150
} as unknown as UsageAggregates;
4251

52+
function rect(left: number, top: number, width: number, height: number): DOMRect {
53+
return {
54+
x: left,
55+
y: top,
56+
left,
57+
top,
58+
width,
59+
height,
60+
right: left + width,
61+
bottom: top + height,
62+
toJSON: () => ({}),
63+
} as DOMRect;
64+
}
65+
66+
function setViewport(width: number, height: number) {
67+
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
68+
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
69+
}
70+
71+
function mockTooltipRect(width: number, height: number) {
72+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
73+
function (this: HTMLElement) {
74+
if (this.classList.contains("daily-bar-tooltip--floating")) {
75+
return rect(0, 0, width, height);
76+
}
77+
return rect(0, 0, 0, 0);
78+
},
79+
);
80+
}
81+
82+
function mockElementRect(
83+
element: HTMLElement,
84+
left: number,
85+
top: number,
86+
width: number,
87+
height: number,
88+
) {
89+
Object.defineProperty(element, "getBoundingClientRect", {
90+
configurable: true,
91+
value: () => rect(left, top, width, height),
92+
});
93+
}
94+
95+
function dailyEntry(date: string, totalTokens: number, totalCost = 0): CostDailyEntry {
96+
return {
97+
...totals,
98+
date,
99+
input: totalTokens,
100+
output: 0,
101+
cacheRead: 0,
102+
cacheWrite: 0,
103+
totalTokens,
104+
totalCost,
105+
};
106+
}
107+
108+
function renderDailyChart(
109+
daily: CostDailyEntry[],
110+
onSelectDay = vi.fn<(day: string, shiftKey: boolean) => void>(),
111+
) {
112+
const container = document.createElement("div");
113+
document.body.append(container);
114+
render(
115+
renderDailyChartCompact(daily, [], "tokens", "total", () => {}, onSelectDay),
116+
container,
117+
);
118+
return {
119+
container,
120+
onSelectDay,
121+
bars: Array.from(container.querySelectorAll<HTMLElement>(".daily-bar-wrapper")),
122+
};
123+
}
124+
125+
function getFloatingTooltip(): HTMLElement | null {
126+
return document.body.querySelector(".daily-bar-tooltip--floating");
127+
}
128+
129+
afterEach(() => {
130+
document.body.replaceChildren();
131+
window.dispatchEvent(new Event("scroll"));
132+
vi.restoreAllMocks();
133+
});
134+
43135
function directText(element: Element | null | undefined): string | undefined {
44136
return Array.from(element?.childNodes ?? [])
45137
.filter((node) => node.nodeType === Node.TEXT_NODE)
@@ -92,6 +184,101 @@ describe("renderUsageInsights", () => {
92184
});
93185
});
94186

187+
describe("renderDailyChartCompact", () => {
188+
it("shows one floating tooltip for tall and short daily bars and hides it on mouse leave", () => {
189+
setViewport(800, 600);
190+
mockTooltipRect(180, 64);
191+
const { bars } = renderDailyChart([
192+
dailyEntry("2026-05-01", 1_200_000, 3.5),
193+
dailyEntry("2026-05-02", 4, 0.01),
194+
]);
195+
196+
mockElementRect(bars[0], 100, 100, 24, 200);
197+
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
198+
199+
let tooltip = getFloatingTooltip();
200+
expect(tooltip).not.toBeNull();
201+
expect(tooltip?.textContent).toContain("1.2M tokens");
202+
expect(tooltip?.style.top).toBe("28px");
203+
expect(document.body.querySelectorAll(".daily-bar-tooltip--floating")).toHaveLength(1);
204+
205+
bars[0].dispatchEvent(new MouseEvent("mouseleave"));
206+
expect(getFloatingTooltip()).toBeNull();
207+
208+
mockElementRect(bars[1], 200, 320, 24, 6);
209+
bars[1].dispatchEvent(new MouseEvent("mouseenter"));
210+
211+
tooltip = getFloatingTooltip();
212+
expect(tooltip).not.toBeNull();
213+
expect(tooltip?.textContent).toContain("4 tokens");
214+
bars[1].dispatchEvent(new MouseEvent("mouseleave"));
215+
});
216+
217+
it("flips below when the bar is near the top and clamps inside a narrow viewport", () => {
218+
setViewport(120, 140);
219+
mockTooltipRect(100, 40);
220+
const { bars } = renderDailyChart([dailyEntry("2026-05-03", 10_000, 1)]);
221+
222+
mockElementRect(bars[0], 110, 12, 20, 20);
223+
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
224+
225+
const tooltip = getFloatingTooltip();
226+
expect(tooltip?.dataset.placement).toBe("below");
227+
expect(tooltip?.style.top).toBe("40px");
228+
expect(tooltip?.style.left).toBe("12px");
229+
bars[0].dispatchEvent(new MouseEvent("mouseleave"));
230+
});
231+
232+
it("clears the floating tooltip when the chart DOM is removed", async () => {
233+
setViewport(800, 600);
234+
mockTooltipRect(160, 56);
235+
const { bars, container } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]);
236+
mockElementRect(bars[0], 300, 220, 24, 80);
237+
238+
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
239+
expect(getFloatingTooltip()).not.toBeNull();
240+
241+
container.remove();
242+
await Promise.resolve();
243+
expect(getFloatingTooltip()).toBeNull();
244+
});
245+
246+
it("shows on keyboard focus, hides on blur, and keeps day selection operable", () => {
247+
setViewport(800, 600);
248+
mockTooltipRect(160, 56);
249+
const { bars, onSelectDay } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]);
250+
mockElementRect(bars[0], 300, 220, 24, 80);
251+
252+
bars[0].dispatchEvent(new Event("focus"));
253+
expect(getFloatingTooltip()?.textContent).toContain("500 tokens");
254+
255+
bars[0].dispatchEvent(new Event("blur"));
256+
expect(getFloatingTooltip()).toBeNull();
257+
258+
bars[0].dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }));
259+
expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true);
260+
261+
bars[0].dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
262+
expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", false);
263+
264+
const space = new KeyboardEvent("keydown", {
265+
bubbles: true,
266+
cancelable: true,
267+
key: " ",
268+
shiftKey: true,
269+
});
270+
bars[0].dispatchEvent(space);
271+
expect(space.defaultPrevented).toBe(true);
272+
expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true);
273+
274+
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
275+
bars[0].dispatchEvent(new Event("pointerdown", { bubbles: true }));
276+
bars[0].dispatchEvent(new Event("focus"));
277+
bars[0].dispatchEvent(new MouseEvent("mouseleave"));
278+
expect(getFloatingTooltip()).toBeNull();
279+
});
280+
});
281+
95282
describe("renderSessionsCard", () => {
96283
const noop = () => {};
97284

0 commit comments

Comments
 (0)