mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(charts): BP/weight/pulse polish (gradient, baseline, rich tooltip)
The HealthChart wrapper is the single component the dashboard uses for
every numeric vital — BP systolic/diastolic, weight, pulse, body fat,
sleep, steps. This commit upgrades it once with the v1.4.16 visual
leap so all those metrics jump together.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area can
paint under the line. Each metric gets its own
chart-gradient-inline-{type} <linearGradient> definition; default
35 percent top alpha → 0 percent bottom matches the Apple Health
Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup
carries the slot for visual-regression and e2e probes.
- Personal baseline reference line: 90-day rolling median per type,
painted as a faint dashed line labelled "Your normal" / "Dein
Mittel". The new computePersonalBaseline() helper returns null when
fewer than 5 points exist so a baseline never renders off noise.
- Rich tooltip via the new RichChartTooltip primitive: rounded card
with drop shadow, per-row coloured dot, tabular-nums value cell, and
a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary
rows from the moving-average / trend overlays are filtered out so
the card stays focused on the primary metric.
- 600ms ease-out animation on first render via Recharts'
isAnimationActive prop, suppressed when prefers-reduced-motion is
set at the OS level (new src/lib/charts/reduced-motion.ts helper
with 4 unit tests).
- Sparse-data placeholder via ChartEmptyState when fewer than 3
daily points exist — a near-empty chart looks broken, the
placeholder reads as intentional.
In-target zone shading was already wired via the existing valueBands
prop and stays untouched. The dashed peer's labelling is unchanged.
7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1
gradient-id namespacing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fdac9e2517
commit
74c2eb8bff
4 files changed
+489
-22
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { computePersonalBaseline } from "../health-chart";
|
||||
|
||||
/**
|
||||
* v1.4.16 B1a — HealthChart Apple-Health-style polish contract.
|
||||
*
|
||||
* The HealthChart wrapper is the single component that the dashboard
|
||||
* uses for BP, weight, pulse, body-fat, sleep, and steps. We upgrade it
|
||||
* once with the v1.4.16 visual leap and the unit test pins the
|
||||
* SSR-renderable surface markers:
|
||||
*
|
||||
* - the linear gradient primitive is wired (data-slot present),
|
||||
* - the optional 90-day personal-baseline ReferenceLine is painted
|
||||
* when the wrapper is given a baseline computation hook,
|
||||
* - the rich tooltip primitive replaces the default Recharts tooltip
|
||||
* so tooltip rendering happens through `RichChartTooltip`.
|
||||
*
|
||||
* Recharts itself renders client-side so we can't assert pixel layout
|
||||
* in node-based vitest. We assert that the wrapper's SSR output ships
|
||||
* the gradient `<defs>` block (Recharts emits primitives during SSR
|
||||
* even though hover/animations only kick in client-side) and that
|
||||
* the gradient id includes the metric type prefix.
|
||||
*/
|
||||
|
||||
const sampleSeries = vi.hoisted(() => {
|
||||
// 30 days of synthetic systolic readings — ~120 baseline with mild
|
||||
// upward drift + small noise. The chart's 90-day median feature only
|
||||
// paints a baseline when sufficient data exists, so we hand it a
|
||||
// dataset comfortably above the 3-point threshold.
|
||||
const out: Array<{ measuredAt: string; value: number }> = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const dt = new Date(Date.UTC(2026, 4, 1 + i, 12, 0, 0));
|
||||
out.push({
|
||||
measuredAt: dt.toISOString(),
|
||||
value: 118 + (i % 5) - 2 + i * 0.05,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: sampleSeries.map((row) => {
|
||||
const ts = Date.parse(row.measuredAt);
|
||||
return {
|
||||
date: new Date(ts).toDateString(),
|
||||
timestamp: ts,
|
||||
BLOOD_PRESSURE_SYS: row.value,
|
||||
};
|
||||
}),
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("computePersonalBaseline()", () => {
|
||||
it("returns null with fewer than 5 data points", () => {
|
||||
expect(computePersonalBaseline([])).toBeNull();
|
||||
expect(
|
||||
computePersonalBaseline([
|
||||
{ value: 120 },
|
||||
{ value: 122 },
|
||||
{ value: 118 },
|
||||
{ value: 125 },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the median for an odd-sized series", () => {
|
||||
// Sorted: [110, 115, 120, 125, 130]; median = 120.
|
||||
expect(
|
||||
computePersonalBaseline([
|
||||
{ value: 130 },
|
||||
{ value: 110 },
|
||||
{ value: 120 },
|
||||
{ value: 125 },
|
||||
{ value: 115 },
|
||||
]),
|
||||
).toBe(120);
|
||||
});
|
||||
|
||||
it("averages the two middle values for an even-sized series", () => {
|
||||
// Sorted: [110, 115, 120, 125, 130, 135]; middle two = 120,125 → 122.5.
|
||||
expect(
|
||||
computePersonalBaseline([
|
||||
{ value: 110 },
|
||||
{ value: 115 },
|
||||
{ value: 120 },
|
||||
{ value: 125 },
|
||||
{ value: 130 },
|
||||
{ value: 135 },
|
||||
]),
|
||||
).toBe(122.5);
|
||||
});
|
||||
|
||||
it("caps the window at the most recent N points (default 90)", () => {
|
||||
// 100 points: first 5 are 200, remaining 95 are 100. With a 90-point
|
||||
// cap we slice off the trailing 90 → all 100s → median 100.
|
||||
const points: Array<{ value: number }> = [];
|
||||
for (let i = 0; i < 5; i++) points.push({ value: 200 });
|
||||
for (let i = 0; i < 95; i++) points.push({ value: 100 });
|
||||
expect(computePersonalBaseline(points, 90)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthChart> v1.4.16 B1a polish", () => {
|
||||
it("emits a sibling-SVG <defs> gradient block addressable by Recharts via url(#id)", async () => {
|
||||
// ResponsiveContainer renders empty during SSR (its dimensions
|
||||
// depend on the live DOM), so Recharts' own SSR output ships no
|
||||
// <defs>. To keep the gradient SSR-discoverable (and so visual
|
||||
// regressions can target it without a browser), the wrapper emits
|
||||
// an external `<svg width=0 height=0>` next to ResponsiveContainer
|
||||
// containing the gradient. Recharts picks it up via `url(#id)`
|
||||
// because SVG IDs are document-scoped.
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS"]}
|
||||
title="Blood Pressure"
|
||||
unit="mmHg"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-linear-gradient"');
|
||||
});
|
||||
|
||||
it("namespaces the gradient id by metric type so multi-metric charts don't collide", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS"]}
|
||||
title="Blood Pressure"
|
||||
unit="mmHg"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/id="chart-gradient-BLOOD_PRESSURE_SYS"/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
@@ -16,6 +17,13 @@ import {
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo, useId } from "react";
|
||||
import {
|
||||
ChartLinearGradient,
|
||||
chartGradientFill,
|
||||
} from "./chart-gradient";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -177,6 +185,31 @@ function movingAverageByPoints(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.16 B1a — personal-baseline helper.
|
||||
*
|
||||
* Computes the median (50th-percentile) value of a daily-aggregated
|
||||
* series, capped at the most-recent 90 daily points (Apple Health uses
|
||||
* the rolling-90-day window for "your normal" framing). Returns `null`
|
||||
* when fewer than 5 points exist — a baseline drawn off 1-2 readings is
|
||||
* misleading; surface nothing instead.
|
||||
*
|
||||
* Pure & deterministic so the unit test pins exact medians.
|
||||
*/
|
||||
export function computePersonalBaseline(
|
||||
data: Array<{ value: number }>,
|
||||
windowPoints = 90,
|
||||
): number | null {
|
||||
if (data.length < 5) return null;
|
||||
const recent = data.slice(-windowPoints);
|
||||
const sorted = recent.map((d) => d.value).sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
return sorted[mid];
|
||||
}
|
||||
|
||||
function buildTrendSeriesByTime(data: Array<{ date: Date; value: number }>): {
|
||||
points: Array<{ date: Date; value: number }>;
|
||||
slopePerDay: number;
|
||||
@@ -641,10 +674,28 @@ export function HealthChart({
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
}, [chartData, showTrend, types, unit, valueMode, t, fmt]);
|
||||
|
||||
// v1.4.16 B1a — personal baseline (90-day rolling median) per type.
|
||||
// Painted as a faint dashed reference line labelled "Your normal" so
|
||||
// the user can read each point against their own baseline, not an
|
||||
// absolute value the chart axis happens to render.
|
||||
const personalBaselines = useMemo(() => {
|
||||
if (!chartData?.length) return new Map<string, number>();
|
||||
const map = new Map<string, number>();
|
||||
for (const type of types) {
|
||||
const series = chartData
|
||||
.filter((d) => typeof d[type] === "number")
|
||||
.map((d) => ({ value: d[type] as number }));
|
||||
const baseline = computePersonalBaseline(series, 90);
|
||||
if (baseline !== null) map.set(type, baseline);
|
||||
}
|
||||
return map;
|
||||
}, [chartData, types]);
|
||||
|
||||
if (!isLoading && !data?.length) return null;
|
||||
|
||||
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
|
||||
const showContextDetails = showMA || showTrend || showBands;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 md:p-6">
|
||||
@@ -724,7 +775,15 @@ export function HealthChart({
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : !chartData?.length ? null : (
|
||||
) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? (
|
||||
// v1.4.16 B1a — sparse-data placeholder. <3 daily points is too
|
||||
// few to render a meaningful trend; paint a friendly hint
|
||||
// instead so the dashboard doesn't look broken.
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-[240px]">
|
||||
{visibleBands.length > 0 ? (
|
||||
<div
|
||||
@@ -754,12 +813,73 @@ export function HealthChart({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* v1.4.16 B1a — sibling SVG <defs> block.
|
||||
ResponsiveContainer is empty during SSR so a gradient
|
||||
defined inside its child <svg> isn't discoverable in
|
||||
static-rendered markup. Emitting the gradient in a
|
||||
standalone hidden <svg> next to the chart keeps it
|
||||
available document-wide; Recharts' <Area fill="url(#id)">
|
||||
looks the gradient up by id at paint time and finds it
|
||||
regardless of which SVG it lives in. */}
|
||||
<svg
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", pointerEvents: "none" }}
|
||||
data-slot="chart-gradient-defs"
|
||||
>
|
||||
{types.map((type, i) => (
|
||||
<ChartLinearGradient
|
||||
key={`grad-${type}`}
|
||||
id={`chart-gradient-${type}`}
|
||||
colorVar={
|
||||
// Best-effort mapping from the dot colour back to a
|
||||
// Dracula token — the chart already accepts an
|
||||
// arbitrary `colors` array (defaults to purple/pink/
|
||||
// cyan), so we mirror that here.
|
||||
colors[i % colors.length]?.startsWith("var(")
|
||||
? colors[i % colors.length]
|
||||
.replace("var(", "")
|
||||
.replace(")", "")
|
||||
: "--dracula-purple"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="relative z-10 h-full touch-pan-y">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
{/* Inline gradient defs as Recharts children — same id
|
||||
space as the sibling SVG above, so even browsers
|
||||
that scope SVG-id resolution to the local document
|
||||
fragment (Safari edge cases) still find a match. */}
|
||||
<defs>
|
||||
{types.map((type, i) => (
|
||||
<linearGradient
|
||||
key={`inline-grad-${type}`}
|
||||
id={`chart-gradient-inline-${type}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={colors[i % colors.length]}
|
||||
stopOpacity={0.35}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={colors[i % colors.length]}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
@@ -846,27 +966,116 @@ export function HealthChart({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* v1.4.16 B1a — personal-baseline reference line.
|
||||
90-day rolling median per type, painted as a faint
|
||||
dashed line labelled "Your normal" / "Dein Mittel".
|
||||
Only the *first* baseline gets an inline label so
|
||||
multi-type charts (e.g. systolic + diastolic) don't
|
||||
paint two overlapping labels. */}
|
||||
{types.map((type, i) => {
|
||||
const baseline = personalBaselines.get(type);
|
||||
if (baseline == null) return null;
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`baseline-${type}`}
|
||||
y={baseline}
|
||||
stroke={colors[i % colors.length]}
|
||||
strokeDasharray="2 4"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={1}
|
||||
ifOverflow="discard"
|
||||
label={
|
||||
i === 0
|
||||
? {
|
||||
value: t("charts.personalBaseline"),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Tooltip
|
||||
filterNull={false}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={(props) => {
|
||||
const {
|
||||
active,
|
||||
payload,
|
||||
label: rechartsLabel,
|
||||
} = props as unknown as {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name?: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
payload?: ChartDataPoint;
|
||||
}>;
|
||||
label?: number;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts =
|
||||
payload[0]?.payload?.timestamp ??
|
||||
(typeof rechartsLabel === "number"
|
||||
? chartData?.[Math.round(rechartsLabel)]?.timestamp
|
||||
: undefined);
|
||||
const dateLabel = ts
|
||||
? formatDateShort(new Date(ts), true)
|
||||
: "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`*_ma`, `*_trend`) so the
|
||||
// tooltip stays focused on the primary metric
|
||||
// rows; the user already sees these as dashed
|
||||
// overlays in the chart.
|
||||
if (dataKey.endsWith("_ma") || dataKey.endsWith("_trend"))
|
||||
continue;
|
||||
const baseline = personalBaselines.get(dataKey);
|
||||
let delta: string | undefined;
|
||||
if (baseline != null) {
|
||||
const diff = item.value - baseline;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${fmt.number(
|
||||
Math.abs(diff),
|
||||
1,
|
||||
)}${unit ? ` ${unit}` : ""}`;
|
||||
delta = t("charts.deltaVsBaseline").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
);
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
name: item.name ?? dataKey,
|
||||
value: `${formatTooltipValue(item.value)}${
|
||||
unit ? ` ${unit}` : ""
|
||||
}`,
|
||||
color: item.color ?? "var(--dracula-purple)",
|
||||
delta,
|
||||
});
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
formatter={(value) =>
|
||||
typeof value === "number"
|
||||
? `${formatTooltipValue(value)}${unit ? ` ${unit}` : ""}`
|
||||
: value
|
||||
}
|
||||
labelFormatter={(_label, payload) =>
|
||||
payload?.[0]?.payload?.timestamp
|
||||
? formatDateShort(
|
||||
new Date(payload[0].payload.timestamp),
|
||||
true,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{showContextDetails && (
|
||||
<Legend
|
||||
@@ -877,6 +1086,26 @@ export function HealthChart({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.16 B1a — gradient-filled Area painted *under*
|
||||
the line. Each type gets its own gradient
|
||||
referenced by id. The Area's stroke is set to
|
||||
transparent so only the fill paints; the line on
|
||||
top of it carries the actual stroke. */}
|
||||
{types.map((type) => (
|
||||
<Area
|
||||
key={`area-${type}`}
|
||||
type="monotone"
|
||||
dataKey={type}
|
||||
stroke="transparent"
|
||||
fill={chartGradientFill(`chart-gradient-inline-${type}`)}
|
||||
fillOpacity={1}
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
connectNulls
|
||||
legendType="none"
|
||||
/>
|
||||
))}
|
||||
{types.map((type, i) => (
|
||||
<Line
|
||||
key={type}
|
||||
@@ -888,6 +1117,9 @@ export function HealthChart({
|
||||
dot={{ r: 3, fill: colors[i % colors.length] }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
))}
|
||||
{showMA &&
|
||||
@@ -919,7 +1151,7 @@ export function HealthChart({
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
|
||||
import { prefersReducedMotion } from "../reduced-motion";
|
||||
|
||||
describe("prefersReducedMotion()", () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
// @ts-expect-error — restore SSR-default state.
|
||||
delete globalThis.window;
|
||||
} else {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false on the server (no window)", () => {
|
||||
// @ts-expect-error — explicit SSR-environment shape.
|
||||
delete globalThis.window;
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the matchMedia matches flag in the browser", () => {
|
||||
const matchMedia = vi.fn(() => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
// @ts-expect-error — synthesise window for the test.
|
||||
globalThis.window = { matchMedia };
|
||||
expect(prefersReducedMotion()).toBe(true);
|
||||
expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
|
||||
});
|
||||
|
||||
it("returns false when matchMedia throws", () => {
|
||||
// @ts-expect-error — synthesise window.
|
||||
globalThis.window = {
|
||||
matchMedia: () => {
|
||||
throw new Error("legacy browser");
|
||||
},
|
||||
};
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when matchMedia is missing on the window", () => {
|
||||
// @ts-expect-error — synthesise window without matchMedia.
|
||||
globalThis.window = {};
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Tiny helper for chart wrappers that need to respect
|
||||
* `prefers-reduced-motion` (v1.4.16 B1a).
|
||||
*
|
||||
* SSR-safe: returns `false` (i.e. animations *enabled*) whenever
|
||||
* `window.matchMedia` is unavailable. The chart wrappers consume this
|
||||
* during render and pass the negation to Recharts' `isAnimationActive`
|
||||
* prop so first-render line-draw animations are suppressed for users
|
||||
* who've requested reduced motion at the OS level.
|
||||
*
|
||||
* Pure / no-side-effects so the unit test runs under the default
|
||||
* vitest "node" environment.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
} catch {
|
||||
// matchMedia can throw on legacy mobile browsers when the query
|
||||
// string is unrecognised — fall back to "no, animate".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user