feat(charts): mini ChartSkeleton variant for trends-row slot

The trends-row chart slot is now pinned to 140 px, but the default
`<ChartSkeleton>` paints a `rounded-xl` card with `p-4 md:p-6` and a
240 px chart band — overflowing the slot and reintroducing the
layout shift the height pin was meant to eliminate.

Add a `mini` prop to `<ChartSkeleton>` that renders the lighter
`rounded-md p-2` chrome plus a 140 px band. Expose
`HealthChartDynamicMini` from the chart-dynamic boundary so the
trends-row consumers pick up the matching loading shell without
having to wire the skeleton choice at every call site.

Trends-row swaps both `<HealthChartDynamic>` calls (BP + weight) for
the mini variant and the local `<MoodChart>` `next/dynamic` loader
now passes `mini` to its skeleton. The dashboard hero + sub-page
charts keep the default card chrome (`mini={false}`).
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:11:55 +02:00
1 parent 4d111e39dd
commit b4bef03fd7
4 files changed
+110 -5

No files matched your search

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ChartSkeleton } from "../chart-skeleton";
/**
* v1.4.36 W2 — `<ChartSkeleton>` carries two variants so the loading
* shell occupies the same visible footprint as the chart that lands
* after `next/dynamic` resolves.
*
* - Default (dashboard / sub-page hero): rounded-xl card chrome,
* full header row, `--chart-height` band.
* - `mini` (trends-row 140 px slot): rounded-md, light padding,
* 140 px band — no header chrome.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<ChartSkeleton>", () => {
it("renders the default card chrome (rounded-xl + p-4 md:p-6)", () => {
const html = render(<ChartSkeleton />);
expect(html).toContain('data-slot="chart-skeleton"');
expect(html).not.toContain('data-mini="true"');
expect(html).toMatch(/rounded-xl/);
expect(html).toMatch(/p-4/);
expect(html).toMatch(/md:p-6/);
});
it("renders the loading announcement for assistive tech", () => {
const html = render(<ChartSkeleton />);
expect(html).toMatch(/role="status"/);
expect(html).toMatch(/aria-busy="true"/);
});
it("mini variant uses the lighter rounded-md + p-2 chrome", () => {
const html = render(<ChartSkeleton mini />);
expect(html).toContain('data-slot="chart-skeleton"');
expect(html).toContain('data-mini="true"');
expect(html).toMatch(/rounded-md/);
expect(html).toMatch(/p-2/);
// Default card-chrome classes must NOT appear in the mini variant.
expect(html).not.toMatch(/rounded-xl/);
});
it("mini variant pins the skeleton band at h-[140px] (trends-row slot)", () => {
const html = render(<ChartSkeleton mini />);
expect(html).toMatch(/h-\[140px\]/);
});
});
+35 -1
View File
@@ -23,12 +23,46 @@ import { useTranslations } from "@/lib/i18n/context";
* The cumulative box matches the loaded chart's height to within a
* pixel so the page does not jump on hydration.
*
* v1.4.36 W2 — `mini` variant for the trends-row 140 px chart slot.
* The default skeleton's rounded-xl border + p-4/p-6 padding inflated
* past the 140 px wrapper and pushed the row taller than the loaded
* chart, re-introducing the very layout shift the wrapper now
* eliminates. The mini variant trades the card chrome for
* `rounded-md p-2` and pins the inner band at 140 px so the loading
* shell occupies the same visible box as the painted chart.
*
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
* primitive's `animate-pulse` is suppressed via Tailwind's
* `motion-reduce:animate-none` modifier.
*/
export function ChartSkeleton({ className }: { className?: string }) {
export function ChartSkeleton({
className,
mini = false,
}: {
className?: string;
/**
* When true, render the compact variant used inside the trends-row
* chart slot (140 px band, lighter chrome). Defaults to false for
* the dashboard hero / sub-page charts.
*/
mini?: boolean;
}) {
const { t } = useTranslations();
if (mini) {
return (
<div
data-slot="chart-skeleton"
data-mini="true"
role="status"
aria-busy="true"
aria-live="polite"
className={cn("bg-card border-border rounded-md border p-2", className)}
>
<span className="sr-only">{t("charts.loadingLabel")}</span>
<Skeleton className="h-[140px] w-full" />
</div>
);
}
return (
<div
data-slot="chart-skeleton"
@@ -27,3 +27,21 @@ export const HealthChartDynamic = dynamic(
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same
* underlying `<HealthChart>` import, but the loading shell renders the
* `mini` `<ChartSkeleton>` so the loading box matches the painted
* chart's footprint instead of inflating past the row contract.
*
* Consumers inside a fixed-height row (BP + weight cards in
* `trends-row.tsx`) pass `mini` on the chart itself; this wrapper
* ensures the next/dynamic loader paints the matching skeleton.
*/
export const HealthChartDynamicMini = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
+4 -4
View File
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
@@ -32,7 +32,7 @@ const MoodChart = dynamic(
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
interface TrendsRowProps {
@@ -118,7 +118,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
shadcn Card) lines up with the BP/weight tiles' lighter
shell. Both chart types ship the same data-slot now. */}
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
<HealthChartDynamic
<HealthChartDynamicMini
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
@@ -140,7 +140,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
<HealthChartDynamic
<HealthChartDynamicMini
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}