From 8a11b7bfc62d3a754a1fa84a5e8d10d63e831602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 17:21:06 +0200 Subject: [PATCH] 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. --- src/app/globals.css | 14 +++ .../text-measurement-host.browser.test.tsx | 85 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/components/charts/__tests__/text-measurement-host.browser.test.tsx diff --git a/src/app/globals.css b/src/app/globals.css index ab29b8f34..7e3544aa5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 *)); /* diff --git a/src/components/charts/__tests__/text-measurement-host.browser.test.tsx b/src/components/charts/__tests__/text-measurement-host.browser.test.tsx new file mode 100644 index 000000000..5211fd287 --- /dev/null +++ b/src/components/charts/__tests__/text-measurement-host.browser.test.tsx @@ -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); + }); + } +});