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 edbb26a5be.
- Required merge gates passed before the squash merge.

Prepared head SHA: edbb26a5be
Review: https://github.com/openclaw/openclaw/pull/82846#issuecomment-4468967811

Co-authored-by: sandypockets <41454557+sandypockets@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
sandypockets
2026-05-17 13:25:23 -04:00
committed by GitHub
parent 0f1f9525f3
commit a5a5df67da
3 changed files with 440 additions and 12 deletions

View File

@@ -1034,6 +1034,11 @@ details.usage-filter-select summary::-webkit-details-marker,
transform: translateY(-1px);
}
.daily-bar-wrapper:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent);
outline-offset: 3px;
}
.daily-bar--stacked {
display: flex;
flex-direction: column;
@@ -1079,6 +1084,17 @@ details.usage-filter-select summary::-webkit-details-marker,
transform: translate(-50%, 0);
}
.daily-bar-tooltip--floating {
position: fixed;
left: 0;
top: 0;
bottom: auto;
transform: none;
max-width: min(220px, calc(100vw - 16px));
opacity: 1;
z-index: 1000;
}
.cost-breakdown-bar {
display: flex;
width: 100%;

View File

@@ -1,9 +1,18 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderSessionsCard, renderUsageInsights } from "./usage-render-overview.ts";
import type { UsageAggregates, UsageSessionEntry, UsageTotals } from "./usageTypes.ts";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
renderDailyChartCompact,
renderSessionsCard,
renderUsageInsights,
} from "./usage-render-overview.ts";
import type {
CostDailyEntry,
UsageAggregates,
UsageSessionEntry,
UsageTotals,
} from "./usageTypes.ts";
const totals: UsageTotals = {
input: 100,
@@ -40,6 +49,89 @@ const aggregates = {
daily: [],
} as unknown as UsageAggregates;
function rect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
}
function mockTooltipRect(width: number, height: number) {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.classList.contains("daily-bar-tooltip--floating")) {
return rect(0, 0, width, height);
}
return rect(0, 0, 0, 0);
},
);
}
function mockElementRect(
element: HTMLElement,
left: number,
top: number,
width: number,
height: number,
) {
Object.defineProperty(element, "getBoundingClientRect", {
configurable: true,
value: () => rect(left, top, width, height),
});
}
function dailyEntry(date: string, totalTokens: number, totalCost = 0): CostDailyEntry {
return {
...totals,
date,
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
totalCost,
};
}
function renderDailyChart(
daily: CostDailyEntry[],
onSelectDay = vi.fn<(day: string, shiftKey: boolean) => void>(),
) {
const container = document.createElement("div");
document.body.append(container);
render(
renderDailyChartCompact(daily, [], "tokens", "total", () => {}, onSelectDay),
container,
);
return {
container,
onSelectDay,
bars: Array.from(container.querySelectorAll<HTMLElement>(".daily-bar-wrapper")),
};
}
function getFloatingTooltip(): HTMLElement | null {
return document.body.querySelector(".daily-bar-tooltip--floating");
}
afterEach(() => {
document.body.replaceChildren();
window.dispatchEvent(new Event("scroll"));
vi.restoreAllMocks();
});
function directText(element: Element | null | undefined): string | undefined {
return Array.from(element?.childNodes ?? [])
.filter((node) => node.nodeType === Node.TEXT_NODE)
@@ -92,6 +184,101 @@ describe("renderUsageInsights", () => {
});
});
describe("renderDailyChartCompact", () => {
it("shows one floating tooltip for tall and short daily bars and hides it on mouse leave", () => {
setViewport(800, 600);
mockTooltipRect(180, 64);
const { bars } = renderDailyChart([
dailyEntry("2026-05-01", 1_200_000, 3.5),
dailyEntry("2026-05-02", 4, 0.01),
]);
mockElementRect(bars[0], 100, 100, 24, 200);
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
let tooltip = getFloatingTooltip();
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("1.2M tokens");
expect(tooltip?.style.top).toBe("28px");
expect(document.body.querySelectorAll(".daily-bar-tooltip--floating")).toHaveLength(1);
bars[0].dispatchEvent(new MouseEvent("mouseleave"));
expect(getFloatingTooltip()).toBeNull();
mockElementRect(bars[1], 200, 320, 24, 6);
bars[1].dispatchEvent(new MouseEvent("mouseenter"));
tooltip = getFloatingTooltip();
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("4 tokens");
bars[1].dispatchEvent(new MouseEvent("mouseleave"));
});
it("flips below when the bar is near the top and clamps inside a narrow viewport", () => {
setViewport(120, 140);
mockTooltipRect(100, 40);
const { bars } = renderDailyChart([dailyEntry("2026-05-03", 10_000, 1)]);
mockElementRect(bars[0], 110, 12, 20, 20);
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
const tooltip = getFloatingTooltip();
expect(tooltip?.dataset.placement).toBe("below");
expect(tooltip?.style.top).toBe("40px");
expect(tooltip?.style.left).toBe("12px");
bars[0].dispatchEvent(new MouseEvent("mouseleave"));
});
it("clears the floating tooltip when the chart DOM is removed", async () => {
setViewport(800, 600);
mockTooltipRect(160, 56);
const { bars, container } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]);
mockElementRect(bars[0], 300, 220, 24, 80);
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
expect(getFloatingTooltip()).not.toBeNull();
container.remove();
await Promise.resolve();
expect(getFloatingTooltip()).toBeNull();
});
it("shows on keyboard focus, hides on blur, and keeps day selection operable", () => {
setViewport(800, 600);
mockTooltipRect(160, 56);
const { bars, onSelectDay } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]);
mockElementRect(bars[0], 300, 220, 24, 80);
bars[0].dispatchEvent(new Event("focus"));
expect(getFloatingTooltip()?.textContent).toContain("500 tokens");
bars[0].dispatchEvent(new Event("blur"));
expect(getFloatingTooltip()).toBeNull();
bars[0].dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }));
expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true);
bars[0].dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", false);
const space = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: " ",
shiftKey: true,
});
bars[0].dispatchEvent(space);
expect(space.defaultPrevented).toBe(true);
expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true);
bars[0].dispatchEvent(new MouseEvent("mouseenter"));
bars[0].dispatchEvent(new Event("pointerdown", { bubbles: true }));
bars[0].dispatchEvent(new Event("focus"));
bars[0].dispatchEvent(new MouseEvent("mouseleave"));
expect(getFloatingTooltip()).toBeNull();
});
});
describe("renderSessionsCard", () => {
const noop = () => {};

View File

@@ -24,6 +24,218 @@ function pct(part: number, total: number): number {
return (part / total) * 100;
}
const DAILY_BAR_TOOLTIP_MARGIN_PX = 8;
const DAILY_BAR_TOOLTIP_GAP_PX = 8;
type DailyBarTooltipTrigger = "hover" | "focus";
type DailyBarTooltipContent = {
dateLabel: string;
tokensLabel: string;
costLabel: string;
breakdownLines: string[];
};
type ActiveDailyBarTooltip = {
source: HTMLElement;
reasons: Set<DailyBarTooltipTrigger>;
content: DailyBarTooltipContent;
};
let activeDailyBarTooltip: ActiveDailyBarTooltip | null = null;
let floatingDailyBarTooltip: HTMLElement | null = null;
let floatingDailyBarTooltipListenersAttached = false;
let floatingDailyBarTooltipObserver: MutationObserver | null = null;
let suppressNextDailyBarFocusTooltip = false;
let suppressDailyBarFocusTooltipTimer: number | null = null;
function clampValue(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), Math.max(min, max));
}
function getFloatingDailyBarTooltip(): HTMLElement {
if (!floatingDailyBarTooltip) {
floatingDailyBarTooltip = document.createElement("div");
floatingDailyBarTooltip.className = "daily-bar-tooltip daily-bar-tooltip--floating";
}
if (!floatingDailyBarTooltip.isConnected) {
document.body.append(floatingDailyBarTooltip);
}
return floatingDailyBarTooltip;
}
function renderFloatingDailyBarTooltipContent(
tooltip: HTMLElement,
content: DailyBarTooltipContent,
) {
const date = document.createElement("strong");
date.textContent = content.dateLabel;
const children: Node[] = [
date,
document.createElement("br"),
document.createTextNode(content.tokensLabel),
document.createElement("br"),
document.createTextNode(content.costLabel),
];
for (const line of content.breakdownLines) {
const item = document.createElement("div");
item.textContent = line;
children.push(item);
}
tooltip.replaceChildren(...children);
}
function positionFloatingDailyBarTooltip() {
if (!activeDailyBarTooltip) {
return;
}
if (!activeDailyBarTooltip.source.isConnected) {
hideDailyBarTooltip();
return;
}
const tooltip = getFloatingDailyBarTooltip();
const sourceRect = activeDailyBarTooltip.source.getBoundingClientRect();
tooltip.style.visibility = "hidden";
tooltip.style.left = "0px";
tooltip.style.top = "0px";
const tooltipRect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const maxLeft = viewportWidth - tooltipRect.width - DAILY_BAR_TOOLTIP_MARGIN_PX;
const maxTop = viewportHeight - tooltipRect.height - DAILY_BAR_TOOLTIP_MARGIN_PX;
const left = clampValue(
sourceRect.left + sourceRect.width / 2 - tooltipRect.width / 2,
DAILY_BAR_TOOLTIP_MARGIN_PX,
maxLeft,
);
let top = sourceRect.top - tooltipRect.height - DAILY_BAR_TOOLTIP_GAP_PX;
let placement = "above";
if (top < DAILY_BAR_TOOLTIP_MARGIN_PX) {
placement = "below";
top = sourceRect.bottom + DAILY_BAR_TOOLTIP_GAP_PX;
}
tooltip.dataset.placement = placement;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(clampValue(top, DAILY_BAR_TOOLTIP_MARGIN_PX, maxTop))}px`;
tooltip.style.visibility = "";
}
function attachFloatingDailyBarTooltipListeners() {
if (floatingDailyBarTooltipListenersAttached) {
return;
}
window.addEventListener("resize", positionFloatingDailyBarTooltip);
window.addEventListener("scroll", positionFloatingDailyBarTooltip, true);
floatingDailyBarTooltipListenersAttached = true;
}
function detachFloatingDailyBarTooltipListeners() {
if (!floatingDailyBarTooltipListenersAttached) {
return;
}
window.removeEventListener("resize", positionFloatingDailyBarTooltip);
window.removeEventListener("scroll", positionFloatingDailyBarTooltip, true);
floatingDailyBarTooltipListenersAttached = false;
}
function attachFloatingDailyBarTooltipObserver() {
if (floatingDailyBarTooltipObserver) {
return;
}
floatingDailyBarTooltipObserver = new MutationObserver(() => {
if (activeDailyBarTooltip && !activeDailyBarTooltip.source.isConnected) {
hideDailyBarTooltip();
}
});
floatingDailyBarTooltipObserver.observe(document.body, { childList: true, subtree: true });
}
function detachFloatingDailyBarTooltipObserver() {
floatingDailyBarTooltipObserver?.disconnect();
floatingDailyBarTooltipObserver = null;
}
function showDailyBarTooltip(
source: HTMLElement,
content: DailyBarTooltipContent,
reason: DailyBarTooltipTrigger,
) {
if (!activeDailyBarTooltip || activeDailyBarTooltip.source !== source) {
activeDailyBarTooltip = {
source,
reasons: new Set(),
content,
};
}
activeDailyBarTooltip.content = content;
activeDailyBarTooltip.reasons.add(reason);
const tooltip = getFloatingDailyBarTooltip();
renderFloatingDailyBarTooltipContent(tooltip, content);
positionFloatingDailyBarTooltip();
attachFloatingDailyBarTooltipListeners();
attachFloatingDailyBarTooltipObserver();
}
function hideDailyBarTooltip(source?: HTMLElement, reason?: DailyBarTooltipTrigger) {
if (!activeDailyBarTooltip) {
return;
}
if (source && activeDailyBarTooltip.source !== source) {
return;
}
if (reason) {
activeDailyBarTooltip.reasons.delete(reason);
if (activeDailyBarTooltip.reasons.size > 0) {
return;
}
}
activeDailyBarTooltip = null;
floatingDailyBarTooltip?.remove();
detachFloatingDailyBarTooltipListeners();
detachFloatingDailyBarTooltipObserver();
}
function suppressDailyBarFocusTooltipForPointer() {
suppressNextDailyBarFocusTooltip = true;
if (suppressDailyBarFocusTooltipTimer !== null) {
window.clearTimeout(suppressDailyBarFocusTooltipTimer);
}
suppressDailyBarFocusTooltipTimer = window.setTimeout(() => {
suppressNextDailyBarFocusTooltip = false;
suppressDailyBarFocusTooltipTimer = null;
}, 0);
}
function showDailyBarFocusTooltip(source: HTMLElement, content: DailyBarTooltipContent) {
if (suppressNextDailyBarFocusTooltip) {
return;
}
showDailyBarTooltip(source, content, "focus");
}
function handleDailyBarKeydown(
event: KeyboardEvent,
day: string,
onSelectDay: (day: string, shiftKey: boolean) => void,
) {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
onSelectDay(day, event.shiftKey);
}
function getCostBreakdown(totals: UsageTotals) {
// Use actual costs from API data (already aggregated in backend)
const totalCost = totals.totalCost || 0;
@@ -256,9 +468,31 @@ function renderDailyChartCompact(
]
: [];
const totalLabel = isTokenMode ? formatTokens(d.totalTokens) : formatCost(d.totalCost);
const tooltipContent = {
dateLabel: formatFullDate(d.date),
tokensLabel: `${formatTokens(d.totalTokens)} ${normalizeLowercaseStringOrEmpty(
t("usage.metrics.tokens"),
)}`.trim(),
costLabel: formatCost(d.totalCost),
breakdownLines,
};
return html`
<div
class="daily-bar-wrapper ${isSelected ? "selected" : ""}"
role="button"
tabindex="0"
aria-pressed=${isSelected ? "true" : "false"}
aria-label=${`${tooltipContent.dateLabel}: ${tooltipContent.tokensLabel}, ${tooltipContent.costLabel}`}
@pointerdown=${suppressDailyBarFocusTooltipForPointer}
@mouseenter=${(e: MouseEvent) =>
showDailyBarTooltip(e.currentTarget as HTMLElement, tooltipContent, "hover")}
@mouseleave=${(e: MouseEvent) =>
hideDailyBarTooltip(e.currentTarget as HTMLElement, "hover")}
@focus=${(e: FocusEvent) =>
showDailyBarFocusTooltip(e.currentTarget as HTMLElement, tooltipContent)}
@blur=${(e: FocusEvent) =>
hideDailyBarTooltip(e.currentTarget as HTMLElement, "focus")}
@keydown=${(e: KeyboardEvent) => handleDailyBarKeydown(e, d.date, onSelectDay)}
@click=${(e: MouseEvent) => onSelectDay(d.date, e.shiftKey)}
>
${dailyChartMode === "by-type"
@@ -283,15 +517,6 @@ function renderDailyChartCompact(
: html` <div class="daily-bar" style="height: ${heightPx.toFixed(0)}px"></div> `}
${showTotals ? html`<div class="daily-bar-total">${totalLabel}</div>` : nothing}
<div class="${labelClass}">${shortLabel}</div>
<div class="daily-bar-tooltip">
<strong>${formatFullDate(d.date)}</strong><br />
${formatTokens(d.totalTokens)}
${normalizeLowercaseStringOrEmpty(t("usage.metrics.tokens"))}<br />
${formatCost(d.totalCost)}
${breakdownLines.length
? html`${breakdownLines.map((line) => html`<div>${line}</div>`)}`
: nothing}
</div>
</div>
`;
})}