fix(charts): measure axis labels at the size they are drawn under reduced motion

The reduced-motion backstop gives every element a near-zero transition,
and the default transition property is all, so the font size Recharts
writes into its measurement span started a transition and the read in
the same task returned the previous size. Labels were sized at whatever
the last one had, or at the inherited 16 px: the last date label moved
in from the edge and value labels that fit were dropped as colliding,
differently from one visit to the next. Nothing in the measurement host
is ever seen, so nothing in it transitions now. A browser test pins the
read under reduced motion and without it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 17:21:06 +02:00
1 parent 551fbbbdec
commit 8a11b7bfc6
2 files changed
+99

No files matched your search

+14
View File
@@ -60,6 +60,20 @@ body,
--font-sans: "Inter", "Inter Fallback";
}
/*
* Recharts writes a label's font size into the measurement span and reads its
* box in the same task. The reduced-motion backstop below gives every element
* a near-zero transition, and the default transition property is `all`, so
* that write started a transition and the read returned the previous size: a
* label was sized at whatever the last one had, and the axes placed their
* labels differently from visit to visit. Nothing in the host is ever seen,
* so nothing in it transitions.
*/
[data-slot="chart-text-measurement-host"],
[data-slot="chart-text-measurement-host"] * {
transition: none !important;
}
@custom-variant dark (&:is(.dark *));
/*
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cdp } from "vitest/browser";
import { installTextMeasurementHost } from "../text-measurement-host";
/**
* Recharts sizes every axis label by writing it into the measurement span
* with the label's font size and reading the box back in the same task. The
* reduced-motion backstop in `globals.css` gives every element a 0.01 ms
* transition, and the default transition property is `all`, so a font size
* written into the span started a transition and the read returned the size
* it had before. Under reduced motion a label was measured at whatever size
* the previous one had, or at the inherited 16 px: the last date label moved
* in from the chart's edge, and value labels that fit were dropped as
* colliding, differently from one visit to the next. The host takes its
* subtree out of transitions; this pins that the read sees the size just
* written, under reduced motion and without it.
*/
const LABEL = "10/22/2026";
function referenceWidth(fontSize: string): number {
const span = document.createElement("span");
span.style.cssText = `position:absolute;top:-20000px;white-space:pre;font-size:${fontSize}`;
span.textContent = LABEL;
document.body.appendChild(span);
const width = span.getBoundingClientRect().width;
span.remove();
return width;
}
/** What Recharts does for every label: style the shared span, then read it. */
function measureLikeRecharts(span: HTMLElement, fontSize: string): number {
Object.assign(span.style, {
position: "absolute",
top: "-20000px",
whiteSpace: "pre",
fontSize,
});
span.textContent = LABEL;
return span.getBoundingClientRect().width;
}
async function emulateReducedMotion(value: "reduce" | "no-preference") {
await cdp().send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value }],
});
}
describe("text measurement host", () => {
beforeEach(() => {
document
.querySelector('[data-slot="chart-text-measurement-host"]')
?.remove();
});
afterEach(async () => {
await emulateReducedMotion("no-preference");
});
for (const motion of ["reduce", "no-preference"] as const) {
it(`reads the size just written (${motion})`, async () => {
await emulateReducedMotion(motion);
expect(matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(
motion === "reduce",
);
const span = installTextMeasurementHost();
expect(span).not.toBeNull();
// The axis's first pass measures before it knows the tick font size,
// then measures again at it: both reads must reflect their own size.
const inherited = measureLikeRecharts(span!, "");
const small = measureLikeRecharts(span!, "11px");
const large = measureLikeRecharts(span!, "20px");
const smallAgain = measureLikeRecharts(span!, "11px");
const reference = referenceWidth("11px");
expect(reference).toBeGreaterThan(30);
expect(small).toBeCloseTo(reference, 1);
expect(smallAgain).toBeCloseTo(reference, 1);
expect(large).toBeGreaterThan(small * 1.5);
expect(inherited).toBeGreaterThan(small);
});
}
});