mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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 headedbb26a5be. - Required merge gates passed before the squash merge. Prepared head SHA:edbb26a5beReview: 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:
@@ -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%;
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user