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:
Marc-André Bombeck committed 2026-06-11 23:20:37 +02:00
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"');
});
});
+14 -6
View File
@@ -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"
+51 -11
View File
@@ -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}
+1
View File
@@ -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 ? (
+1
View File
@@ -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}
/>