Files
HealthLog/src/components/insights/tile-header.tsx
T
Marc-André Bombeck f223ca8979 fix(a11y): explicit accessibilityLayer, tile headings, stronger focus ring
Health-chart and mood-chart now pass accessibilityLayer explicitly on
their ComposedChart root — Recharts 3 already defaults it on, but
pinning it here documents the choice and survives a future default
flip on the two busiest chart surfaces in the app.

TileHeader's titleAs="h2" was previously read as a single-owner
exception; the docstring undersold it — sibling h2 tiles in one grid
nest correctly under the page h1 same as a lone owning block. Wires it
through the dashboard's recent-workouts tile and the insights vitals
grid (both the loaded SparklineDeltaTile card and the two provisional/
building states), so a rotor user gets one heading per tile instead of
landing on the page's single h1 and nothing else.

The coach composer's focus ring rode ring-primary/15 — likely under
the 3:1 non-text-contrast floor next to its /50 border. Raises it to
/50, matching the family of "wrapper focus-within ring, outline-none
inner control" the standard already uses elsewhere at full strength.
2026-07-18 03:52:32 +02:00

115 lines
4.2 KiB
TypeScript

import type { ComponentType, ReactNode } from "react";
import { CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
/**
* v1.12.6 — the one canonical tile header used by every Insights tile.
*
* Pre-v1.12.6 each tile hand-rolled its own `icon + heading` row at a
* slightly different size, weight, and colour: the "Einschätzung" card
* led with a foreground-colour icon + `CardTitle`, the target panel led
* with a small muted `Target` glyph + a range string, and the stat strip
* led with a tiny uppercase muted caption. Side-by-side on the same
* subpage they read as three different card languages.
*
* `TileHeader` pins the single header contract to the "Einschätzung"
* reference (the calmest, most legible of the three): a `flex
* items-center gap-2` row with the icon and the `CardTitle` BOTH in the
* foreground colour (white in dark mode) — never `text-muted-foreground`
* — at `text-base`, icon `h-5 w-5 shrink-0`. An optional `right` slot
* carries a trailing affordance (a provenance / tooltip glyph, a status
* pill) without breaking the row's alignment.
*
* RSC-safe: no hooks, no browser API, so it renders on the server inside
* any client or server tile.
*/
interface TileHeaderProps {
/**
* Leading glyph. Accepts a Lucide icon component (or any component that
* takes a `className`) so the caller passes the component itself
* (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size
* and colour so every tile header matches. Optional: a text-only tile
* header (chart cards whose title needs no glyph) omits it and still
* gets the canonical row + `CardTitle` treatment.
*/
icon?: ComponentType<{ className?: string }>;
/** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */
title: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
right?: ReactNode;
/**
* The ONE sanctioned compact variant: icon `h-4 w-4`, title `text-sm` —
* for dense correlation/stat tiles that sit several to a row where the
* full `text-base` header visually overpowers the tile body. Everything
* else stays on the default; do not express a third size via
* `titleClassName`.
*/
size?: "default" | "sm";
className?: string;
titleClassName?: string;
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
id?: string;
/**
* Render the title as a real heading element (e.g. `"h2"`) instead of the
* default presentational `CardTitle` div, keeping the identical CardTitle
* styling. Use on tiles that sit directly under the page's `h1` — a single
* owning block (the recovery blocks) or a grid of sibling tiles (dashboard
* tiles, insights overview grids) both nest correctly as `h1 → h2, h2, …`;
* just don't nest a `titleAs="h2"` tile inside another heading's own
* subsection. Default (unset) keeps every other tile a plain CardTitle.
*/
titleAs?: "h2";
}
export function TileHeader({
icon: Icon,
title,
right,
size = "default",
className,
titleClassName,
id,
titleAs,
}: TileHeaderProps) {
const titleClassNames = cn(
size === "sm" ? "text-sm" : "text-base",
titleClassName,
);
return (
<div
data-slot="tile-header"
className={cn("flex items-center gap-2", className)}
>
{/* Icon and heading share the foreground colour — the calm,
high-contrast read the "Einschätzung" card established. */}
{Icon ? (
<Icon
className={cn(
"text-foreground shrink-0",
size === "sm" ? "h-4 w-4" : "h-5 w-5",
)}
aria-hidden="true"
/>
) : null}
{titleAs === "h2" ? (
// Heading semantics with the exact CardTitle visual treatment so the
// tile can carry a real `<h2>` where it owns the subpage's heading.
<h2
id={id}
data-slot="card-title"
className={cn("leading-none font-semibold", titleClassNames)}
>
{title}
</h2>
) : (
<CardTitle id={id} className={titleClassNames}>
{title}
</CardTitle>
)}
{right ? <div className="ml-auto flex items-center">{right}</div> : null}
</div>
);
}