mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
801295f55a
commit
debd44a75b
9 files changed
+109
-6
No files matched your search
@@ -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"');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user