diff --git a/src/components/charts/__tests__/chart-skeleton.test.tsx b/src/components/charts/__tests__/chart-skeleton.test.tsx new file mode 100644 index 000000000..7597d147c --- /dev/null +++ b/src/components/charts/__tests__/chart-skeleton.test.tsx @@ -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 — `` 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( + {node}, + ); +} + +describe("", () => { + it("renders the default card chrome (rounded-xl + p-4 md:p-6)", () => { + const html = render(); + 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(); + 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(); + 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(); + expect(html).toMatch(/h-\[140px\]/); + }); +}); diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx index d571c467c..25e218351 100644 --- a/src/components/charts/chart-skeleton.tsx +++ b/src/components/charts/chart-skeleton.tsx @@ -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 `` * 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 ( +
+ {t("charts.loadingLabel")} + +
+ ); + } return (
}, ); + +/** + * v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same + * underlying `` import, but the loading shell renders the + * `mini` `` 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: () => }, +); diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 6ada0cbfa..a3e786e11 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -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: () => }, + { ssr: false, loading: () => }, ); 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. */}
-
-