fix(charts): keep axis unit labels on one line; 44 px date and time triggers

Recharts word-wraps a tick label that it measures wider than its axis,
and it measures in the page's body font rather than the tick's 11 px.
"62 bpm" on the pulse chart under a workout and "106 mg/dL" on the
glucose page therefore broke onto two lines at every width, and the
pulse chart's second line sat on the first x tick. Axis units are now
joined with a no-break space through `axisUnitSuffix`, so the label is
one word and stays on one line. A guard fails when a chart axis joins a
unit with a plain space.

The calendar and clock buttons in the date and time fields paint at
32 px; a `::before` halo now gives them a 44 px touch target without
changing the field.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 22:18:28 +02:00
1 parent 801295f55a
commit debd44a75b
9 files changed
+109 -6

No files matched your search

+3 -2
View File
@@ -71,6 +71,7 @@ import {
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
// "the last 7 days", not "the last 7 readings". The labels said "points"
@@ -1982,8 +1983,8 @@ export function HealthChart({
: String(value)
}
unit={
showYAxisUnit && (yAxisUnit ?? unit)
? ` ${yAxisUnit ?? unit}`
showYAxisUnit
? axisUnitSuffix(yAxisUnit ?? unit)
: undefined
}
/>
@@ -24,6 +24,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import {
BUCKET_MINUTES,
type IntradayHrBucket,
@@ -300,7 +301,7 @@ export function IntradayPulseChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
unit=" bpm"
unit={axisUnitSuffix("bpm")}
width={52}
/>
<Tooltip
@@ -26,6 +26,7 @@ import {
} from "recharts";
import { makeFormatters } from "@/lib/format-locale";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
export interface EfficacyChartTarget {
label: string;
@@ -169,7 +170,7 @@ export function EfficacyChart({
tickLine={false}
axisLine={false}
width={40}
unit={unitSuffix ? ` ${unitSuffix}` : undefined}
unit={axisUnitSuffix(unitSuffix)}
/>
<YAxis
yAxisId="adherence"
@@ -93,6 +93,19 @@ describe("<DateField>", () => {
expect(html).toContain("sm:h-10");
});
it("gives the calendar button a 44 px touch target at its 32 px size", () => {
const html = render(<DateField value="2026-02-19" />);
const button = /<button[^>]*aria-label="Open date picker"[^>]*>/.exec(
html,
)?.[0];
expect(button).toBeDefined();
// Painted size unchanged; the ::before halo adds 6 px per edge.
expect(button).toContain("h-8 w-8");
expect(button).toContain("relative");
expect(button).toContain("before:-inset-1.5");
expect(button).toContain("before:absolute");
});
it("exposes a labelled calendar picker affordance", () => {
const html = render(<DateField value="2026-02-19" />);
expect(html).toContain('aria-label="Open date picker"');
@@ -105,6 +105,18 @@ describe("formatHhmm", () => {
});
describe("<TimeField> SSR", () => {
it("gives the clock button a 44 px touch target at its 32 px size", () => {
const html = render(<TimeField value="14:05" />, "de");
const button = /<button[^>]*data-slot="time-field-trigger"[^>]*>/.exec(
html,
)?.[0];
expect(button).toBeDefined();
expect(button).toContain("h-8 w-8");
expect(button).toContain("relative");
expect(button).toContain("before:-inset-1.5");
expect(button).toContain("before:absolute");
});
it("paints the value in 24h under the de AUTO locale and exposes the picker affordance", () => {
const html = render(<TimeField value="14:05" />, "de");
expect(html).toContain('value="14:05"');
+3 -1
View File
@@ -225,7 +225,9 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
type="button"
disabled={disabled}
aria-label={t("common.openDatePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
// The button paints at 32 px; the `::before` halo (6 px per edge)
// makes the touch target 44 px without changing the field.
className="text-muted-foreground hover:text-foreground relative flex h-8 w-8 shrink-0 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] disabled:cursor-not-allowed disabled:opacity-50"
>
<CalendarIcon className="h-4 w-4" aria-hidden="true" />
</button>
+3 -1
View File
@@ -189,7 +189,9 @@ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openTimePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
// The button paints at 32 px; the `::before` halo (6 px per edge)
// makes the touch target 44 px without changing the field.
className="text-muted-foreground hover:text-foreground relative flex h-8 w-8 shrink-0 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] disabled:cursor-not-allowed disabled:opacity-50"
data-slot="time-field-trigger"
>
<Clock className="h-4 w-4" aria-hidden="true" />
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
stripComments,
walkSourceFiles,
} from "@/__tests__/helpers/source-files";
import { axisUnitSuffix } from "../axis-unit";
const COMPONENTS = join(process.cwd(), "src", "components");
/**
* Every `<XAxis … />` / `<YAxis … />` tag's `unit=` value in the tree. The tag
* runs to its `/>`: stopping at the first `>` would end it inside an arrow
* function prop and miss a `unit` written after one.
*/
function axisUnitProps(): Array<{ file: string; value: string }> {
const out: Array<{ file: string; value: string }> = [];
for (const rel of walkSourceFiles(COMPONENTS, {
floor: 300,
extensions: [".tsx"],
})) {
if (rel.includes("__tests__")) continue;
const src = stripComments(readFileSync(join(COMPONENTS, rel), "utf8"));
for (const tag of src.matchAll(/<[XY]Axis\b[\s\S]*?\/>/g)) {
const unit = /\bunit=(\{[\s\S]*?\}|"[^"]*")/.exec(tag[0]);
if (unit) out.push({ file: rel, value: unit[1] });
}
}
return out;
}
describe("axis unit suffix", () => {
it("joins the unit with a no-break space", () => {
expect(axisUnitSuffix("bpm")).toBe(" bpm");
expect(axisUnitSuffix("")).toBeUndefined();
expect(axisUnitSuffix(null)).toBeUndefined();
});
it("finds the axis units it is meant to police", () => {
// A matcher that finds nothing would pass the check below by default.
expect(axisUnitProps().length).toBeGreaterThanOrEqual(6);
});
it("no chart axis joins a unit with a breaking space", () => {
// Recharts wraps a tick label at a breaking space when it measures the
// label wider than the axis, and it measures in the body font: "62 bpm"
// broke onto two lines and its second line sat on the first x tick.
const offenders = axisUnitProps()
.filter(({ value }) => /["`] /.test(value))
.map(({ file, value }) => `${file}: unit=${value}`);
expect(offenders).toEqual([]);
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* The unit suffix a chart axis appends to each tick value ("62 bpm").
*
* Joined with a no-break space, never a plain one. Recharts word-wraps a
* tick label that is wider than the axis, and it measures the label in the
* page's body font rather than the tick's own 11 px, so "62 bpm" and
* "106 mg/dL" measured wider than their axis and broke onto two lines on
* every screen. The second line dropped onto the first x tick. A label with
* no breaking space is one word and stays on one line.
*/
export function axisUnitSuffix(
unit: string | null | undefined,
): string | undefined {
return unit ? ` ${unit}` : undefined;
}