mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(ui): announce failures, keep focus honest and stop the last layout shifts
Hidden states of the floating coach button now leave the tab order and accessibility tree, its focus ring carries an offset, its icon is dark on the gradient, an unread nudge is announced once via a live region, and it sits correctly above the fold on tablet widths and yields to the onboarding tour. Chart error cards announce themselves, share the exact height variables of the skeleton band, carry per-chart retry labels, and a failed chunk renders inside the regular card shell. The header icon cluster spaces its extended hit areas apart, the tab strip keeps horizontal paint room for focus rings, the streak row reserves its slot in every card state, the selection bar clears the bottom nav on landscape phones, and the version poller keeps an in-memory reload guard for browsers that block session storage. Mood notes render as plain paragraphs with a toggle that appears only when text is actually clamped, the remember control reveals on hover and hands focus to its confirmation, and the hypnogram names its source and paints only the lanes the night contains.
This commit is contained in:
1 parent
a2a995e429
commit
95f21bfa4d
29 files changed
+703
-137
No files matched your search
@@ -32,5 +32,16 @@ describe("<ChartEmptyState>", () => {
|
||||
<ChartEmptyState title="..." height={320} />,
|
||||
);
|
||||
expect(html).toContain("height:320px");
|
||||
expect(html).not.toContain("h-[var(--chart-height,240px)]");
|
||||
});
|
||||
|
||||
it("sizes through the chart-height variables by default", () => {
|
||||
// The former hardcoded `style="height:240px"` ignored per-mount
|
||||
// `--chart-height` overrides; the default now reads the same
|
||||
// variables the painted chart does (240 px mobile / 280 px md+).
|
||||
const html = renderToStaticMarkup(<ChartEmptyState title="..." />);
|
||||
expect(html).toContain("h-[var(--chart-height,240px)]");
|
||||
expect(html).toContain("md:h-[var(--chart-height-md,280px)]");
|
||||
expect(html).not.toContain("height:240px");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
// vi.doMock below invalidates the module registry, so the re-imported
|
||||
// provider gets a fresh (unprimed) locale cache — pass the DE bundle
|
||||
// explicitly instead of relying on the vitest.setup.ts seeding.
|
||||
@@ -65,6 +67,9 @@ describe("chart isError rendering — error state, not empty state", () => {
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
// Card chrome must stay mounted.
|
||||
expect(html).toContain("Pulse");
|
||||
// The retry button carries the chart title in its accessible name
|
||||
// so several failed charts on one page stay distinguishable.
|
||||
expect(html).toContain('aria-label="Erneut versuchen – Pulse"');
|
||||
// The empty-state copy must NOT paint — an outage is not "no data".
|
||||
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
|
||||
|
||||
@@ -113,3 +118,80 @@ describe("chart isError rendering — error state, not empty state", () => {
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function renderState(
|
||||
props: Partial<
|
||||
import("../chart-error-state").ChartErrorStateProps
|
||||
> = {},
|
||||
) {
|
||||
const { ChartErrorState } = await import("../chart-error-state");
|
||||
return renderToStaticMarkup(
|
||||
<ChartErrorState
|
||||
title="Data could not be loaded"
|
||||
actionLabel="Retry"
|
||||
onAction={() => undefined}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("announces the failure as a status by default", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).toContain('role="status"');
|
||||
});
|
||||
|
||||
it("escalates to alert when the boundary asks for it", async () => {
|
||||
const html = await renderState({ role: "alert" });
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
it("sizes through the chart-height variables by default", async () => {
|
||||
// The former hardcoded `style="height:240px"` ignored per-mount
|
||||
// `--chart-height` overrides; the default now reads the same
|
||||
// variables the painted chart does.
|
||||
const html = await renderState();
|
||||
expect(html).toContain("h-[var(--chart-height,240px)]");
|
||||
expect(html).toContain("md:h-[var(--chart-height-md,280px)]");
|
||||
expect(html).not.toContain("height:240px");
|
||||
});
|
||||
|
||||
it("keeps the explicit height prop as a per-mount override", async () => {
|
||||
const html = await renderState({ height: 140 });
|
||||
expect(html).toContain("height:140px");
|
||||
expect(html).not.toContain("h-[var(--chart-height,240px)]");
|
||||
});
|
||||
|
||||
it("joins the action context into the retry button's accessible name", async () => {
|
||||
const html = await renderState({ actionContext: "Pulse" });
|
||||
expect(html).toContain('aria-label="Retry – Pulse"');
|
||||
});
|
||||
|
||||
it("omits the aria-label without a context so the label is the name", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).not.toContain("aria-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChartErrorBoundary> — chunk-failure fallback", () => {
|
||||
it("wraps the fallback in the chart-card shell and escalates to alert", () => {
|
||||
// Error boundaries do not catch during static SSR, so the fallback
|
||||
// wiring is pinned structurally: the fallback card must carry the
|
||||
// solid chart-card shell (not a bare dashed box among solid cards)
|
||||
// and pass `role="alert"` (the whole chart is gone, not just data).
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/charts/chart-error-state.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain('data-slot="chart-error-boundary-card"');
|
||||
expect(src).toContain(
|
||||
'className="bg-card border-border rounded-xl border p-4 md:p-6"',
|
||||
);
|
||||
expect(src).toContain('role="alert"');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { LineChart as LineChartIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
|
||||
*
|
||||
@@ -29,9 +31,11 @@ export interface ChartEmptyStateProps {
|
||||
*/
|
||||
description?: ReactNode;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<HealthChart>` /
|
||||
* `<MedicationComplianceChart>`).
|
||||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||||
* contexts. Absent, the card sizes through the same
|
||||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||||
* reads (240 px mobile / 280 px md+ by default), so a per-mount
|
||||
* override resizes the empty state too.
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
@@ -39,13 +43,17 @@ export interface ChartEmptyStateProps {
|
||||
export function ChartEmptyState({
|
||||
title,
|
||||
description,
|
||||
height = 240,
|
||||
height,
|
||||
}: ChartEmptyStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-empty-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
className={cn(
|
||||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center",
|
||||
height === undefined &&
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||||
)}
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<LineChartIcon
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Error-state card + per-chart error boundary.
|
||||
@@ -31,30 +32,59 @@ export interface ChartErrorStateProps {
|
||||
/** Action handler — query `refetch` or a full page reload. */
|
||||
onAction: () => void;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<ChartEmptyState>`).
|
||||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||||
* contexts. Absent, the card sizes through the same
|
||||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||||
* reads, so a per-mount override resizes the error card too.
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* Optional translated context (usually the chart title). Joins the
|
||||
* action label into the button's accessible name so several retry
|
||||
* buttons on one page stay distinguishable for screen-reader users.
|
||||
*/
|
||||
actionContext?: string;
|
||||
/**
|
||||
* Live-region semantics. `status` (default) announces politely when
|
||||
* a data query fails inside an otherwise healthy card; the chunk
|
||||
* boundary fallback passes `alert` because the surrounding chart is
|
||||
* gone entirely.
|
||||
*/
|
||||
role?: "status" | "alert";
|
||||
}
|
||||
|
||||
export function ChartErrorState({
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
height = 240,
|
||||
height,
|
||||
actionContext,
|
||||
role = "status",
|
||||
}: ChartErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-error-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
role={role}
|
||||
className={cn(
|
||||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center",
|
||||
height === undefined &&
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||||
)}
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<Button variant="outline" size="sm" onClick={onAction}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
aria-label={
|
||||
actionContext ? `${actionLabel} – ${actionContext}` : undefined
|
||||
}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
@@ -111,11 +141,21 @@ export function ChartErrorBoundary({
|
||||
return (
|
||||
<Boundary
|
||||
fallback={
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
/>
|
||||
// Wrapped in the chart-card shell so a chunk failure renders
|
||||
// as one solid card among the other cards, not a bare dashed
|
||||
// box; `role="alert"` because the whole chart is gone, not
|
||||
// just its data.
|
||||
<div
|
||||
data-slot="chart-error-boundary-card"
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
role="alert"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1531,6 +1531,7 @@ export function HealthChart({
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={title}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
|
||||
@@ -424,6 +424,7 @@ export function MedicationComplianceChart({
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !hasData ? (
|
||||
|
||||
@@ -613,6 +613,7 @@ export function MoodChart({
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
onAction={() => void refetch()}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user