mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Title the chart card at the level it actually sits on
A dashboard chart card called itself an h3. Nothing contained it. The cards beside it in the same grid — "Recent unlocks", "Recent workouts" — are h2, and so are the target and usual-range cards on the metric subpages, so the chart card was both the odd one out and a level skipped straight from the page h1. The rendered dashboard ladder was h1 → h3 → h3 → h3 → h2 → h2, and /insights/bmi was h1 → h3 → h2. A heading level is a claim about nesting. Someone moving through the page by headings hears a subsection announced where there is no section, and screen reader rotors build their outline from exactly this. Promoting the title keeps the same text, the same classes and the same pixels; only the claim changes. The unit guard fails in the fast run; the browser scan catches the same regression through axe, but only in the e2e job.
This commit is contained in:
1 parent
002a82106a
commit
ef9d66b7f9
2 files changed
+94
-3
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
/**
|
||||
* Chart card heading level.
|
||||
*
|
||||
* The icon-less chart header (dashboard cells, /insights/bmi, the sleep
|
||||
* overview) titles itself at the second level. It has to: the card is a
|
||||
* sibling of the other cards around it, and those already name themselves
|
||||
* there — the dashboard's "Recent unlocks" and "Recent workouts" tiles render
|
||||
* `<TileHeader titleAs="h2">`, and the metric subpages' target / usual-range
|
||||
* cards are `<h2>` too. At `<h3>` the card claimed to be nested inside a
|
||||
* section that does not exist, and the page skipped a level straight from its
|
||||
* `<h1>`: the dashboard read h1 → h3 → h3 → h3 → h2 → h2 and /insights/bmi
|
||||
* read h1 → h3 → h2.
|
||||
*
|
||||
* The browser scan in `e2e/a11y.spec.ts` catches a regression here through
|
||||
* axe's `heading-order` rule, but only in the e2e job. This is the cheap
|
||||
* guard that fails in the unit run.
|
||||
*/
|
||||
|
||||
function buildData(): unknown[] {
|
||||
const out: Array<{ date: string; timestamp: number; PULSE: number }> = [];
|
||||
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
out.push({
|
||||
date: `2026-01-${String(i + 1).padStart(2, "0")}`,
|
||||
timestamp: base + i * 86_400_000,
|
||||
PULSE: 60 + i,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart> — card heading level", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("titles the icon-less chart card at the second level, never the third", async () => {
|
||||
const data = buildData();
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// The title is a real heading carrying the real title text, not a styled
|
||||
// div — assert both halves so a silent demotion to a `<span>` fails too.
|
||||
expect(html).toContain('<h2 class="text-sm font-semibold">Pulse</h2>');
|
||||
expect(html).not.toContain("<h3");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -246,7 +246,7 @@ interface HealthChartProps {
|
||||
* v1.12.8 — optional leading glyph for the chart card's `<TileHeader>`.
|
||||
* When supplied, the chart header renders the canonical icon + title row
|
||||
* (same size + spacing as every other Insights tile) instead of the bare
|
||||
* `<h3>`. Omitted on the dashboard / mini mounts, which keep the compact
|
||||
* `<h2>`. Omitted on the dashboard / mini mounts, which keep the compact
|
||||
* heading. Accepts any component that takes a `className` (the contract
|
||||
* `<TileHeader>` and the stat strip's `icon` already use), so a page can
|
||||
* thread the same glyph it hands the stat strip.
|
||||
@@ -1691,11 +1691,23 @@ export function HealthChart({
|
||||
`<TileHeader>` (icon + title at `text-base`, foreground
|
||||
colour, h-5 w-5) so it matches the assessment / target /
|
||||
stat tiles. The dashboard / ad-hoc mounts omit the icon and
|
||||
keep the compact `<h3>`. */}
|
||||
keep the compact heading.
|
||||
|
||||
That compact heading is an `<h2>`, not an `<h3>`. The card is
|
||||
a sibling of the other cards in the same grid, and those name
|
||||
themselves at the second level already — the dashboard's
|
||||
"Recent unlocks" and "Recent workouts" tiles both render
|
||||
`<TileHeader titleAs="h2">`, and the metric subpages' target /
|
||||
usual-range cards are `<h2>` too. Sitting one level lower made
|
||||
the chart card the odd one out AND skipped a level under the
|
||||
page `<h1>`: the dashboard's rendered ladder read h1 → h3 →
|
||||
h3 → h3 → h2 → h2, and /insights/bmi read h1 → h3 → h2. A
|
||||
heading level is a claim about nesting, and the chart card is
|
||||
not nested inside anything. */}
|
||||
{titleIcon ? (
|
||||
<TileHeader icon={titleIcon} title={title} />
|
||||
) : (
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
)}
|
||||
{activeBucket !== "day" && (
|
||||
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
|
||||
|
||||
Reference in new issue
Block a user