fix(ui): let tile and card titles wrap instead of cutting them

Seven tiles passed titleClassName="truncate" to TileHeader, so German
compounds ended in an ellipsis on a narrow tile ("Erholungs-..."). The
title is the only thing naming the figure. TileHeader now owns the wrap
(min-w-0, break-words, hyphens-auto from the document lang) and has no
class override left to truncate with; CardTitle carries the same
break-words/hyphens-auto. A guard refuses truncate or nowrap on any
CardTitle or TileHeader in the tree.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:17:37 +02:00
1 parent c245a3f2af
commit 7af2e3d29a
10 files changed
+119 -23

No files matched your search

+1 -5
View File
@@ -108,11 +108,7 @@ export function CycleRingTile({ className }: { className?: string }) {
className,
)}
>
<TileHeader
icon={Droplets}
title={t("cycle.wellnessRing.label")}
titleClassName="truncate"
/>
<TileHeader icon={Droplets} title={t("cycle.wellnessRing.label")} />
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={verdict.dayOfCycle}
-1
View File
@@ -102,7 +102,6 @@ export function VisitCard({
size="sm"
icon={CalendarClock}
title={heading}
titleClassName="min-w-0 truncate"
right={
<Badge variant="outline" className="shrink-0">
{encounterKindText(t, encounter.kind as EncounterKind)}
@@ -0,0 +1,99 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Heart } from "lucide-react";
import {
stripComments,
walkSourceFiles,
} from "@/__tests__/helpers/source-files";
import { TileHeader } from "../tile-header";
/**
* A tile title names the figure under it, so it wraps and never truncates.
* Seven callers passed `titleClassName="truncate"` and German compounds
* ("Erholungs-Score", "Herzfrequenzvariabilität") ended in an ellipsis on a
* narrow tile. The class override is gone; the primitive owns the wrap.
*/
function titleClasses(html: string): string {
const match = html.match(
/data-slot="card-title"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="card-title"/,
);
expect(match, "card title not rendered").not.toBeNull();
return match![1] ?? match![2];
}
describe("<TileHeader> title", () => {
it.each([
["default", undefined],
["h2 heading", "h2" as const],
])("wraps at a syllable as a %s", (_name, titleAs) => {
const html = renderToStaticMarkup(
<TileHeader
icon={Heart}
title="Herzfrequenzvariabilität"
titleAs={titleAs}
/>,
);
const classes = titleClasses(html);
expect(classes).toContain("min-w-0");
expect(classes).toContain("break-words");
expect(classes).toContain("hyphens-auto");
expect(classes).not.toContain("truncate");
expect(classes).not.toContain("whitespace-nowrap");
});
it("keeps the compact variant on the same contract", () => {
const html = renderToStaticMarkup(
<TileHeader size="sm" icon={Heart} title="Körperfettverteilung" />,
);
const classes = titleClasses(html);
expect(classes).toContain("text-sm");
expect(classes).toContain("hyphens-auto");
expect(classes).not.toContain("truncate");
});
});
describe("no card title is cut", () => {
// A `CardTitle` or a tile `TileHeader` that truncates hides the one word
// that says what the card is. `truncate` stays legal on rows and meta
// lines that carry their full text in `title`; never on a card title.
const SRC = join(process.cwd(), "src");
const TITLE_TAG = /<(CardTitle|TileHeader)\b[^>]*?>/g;
const CUT = /\b(truncate|whitespace-nowrap|titleClassName)\b/;
const offenders: string[] = [];
let tags = 0;
for (const root of ["components", "app"]) {
for (const rel of walkSourceFiles(join(SRC, root), {
floor: root === "app" ? 100 : 500,
extensions: [".tsx"],
})) {
if (rel.includes("__tests__/")) continue;
const source = stripComments(readFileSync(join(SRC, root, rel), "utf8"));
for (const match of source.matchAll(TITLE_TAG)) {
tags += 1;
if (CUT.test(match[0])) offenders.push(`${root}/${rel}: ${match[1]}`);
}
}
}
it("reads the title tags in the tree", () => {
expect(tags).toBeGreaterThan(60);
});
it("finds none that cut", () => {
expect(offenders).toEqual([]);
});
it("would catch one", () => {
const planted = `<CardTitle className="min-w-0 truncate">x</CardTitle>`;
const found = [...planted.matchAll(TITLE_TAG)].filter((m) =>
CUT.test(m[0]),
);
expect(found).toHaveLength(1);
});
});
@@ -164,7 +164,6 @@ export function SparklineDeltaTile({
title={label}
titleAs="h2"
className="min-w-0"
titleClassName="min-w-0 truncate whitespace-nowrap"
right={
provenance ? (
<span
@@ -642,12 +642,7 @@ function BuildingTile({
data-state={state}
className="gap-3"
>
<TileHeader
icon={icon}
title={title}
titleAs="h2"
titleClassName="truncate"
/>
<TileHeader icon={icon} title={title} titleAs="h2" />
<p className="text-muted-foreground text-sm" data-slot={messageSlot}>
{message}
</p>
@@ -154,7 +154,7 @@ function RingTile({
animate && "wellness-tile-rise",
)}
>
<TileHeader icon={Icon} title={label} titleClassName="truncate" />
<TileHeader icon={Icon} title={label} />
<div className="flex flex-col items-center gap-1.5">
{/* The ring keeps just the number centred; the metric name lives in
the header above. The per-metric `hue` gradient leans the arc
@@ -118,7 +118,6 @@ export function DeviceScoreTile({
icon={Icon}
title={title}
className="min-w-0"
titleClassName="min-w-0 truncate"
right={
latest != null ? (
<span
@@ -98,7 +98,6 @@ export function ResilienceTile() {
icon={ShieldCheck}
title={t("measurements.typeResilience")}
className="min-w-0"
titleClassName="min-w-0 truncate"
right={
bandKey != null ? (
<span
+13 -6
View File
@@ -43,12 +43,10 @@ interface TileHeaderProps {
* 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`.
* else stays on the default.
*/
size?: "default" | "sm";
className?: string;
titleClassName?: string;
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
id?: string;
/**
@@ -71,14 +69,23 @@ export function TileHeader({
right,
size = "default",
className,
titleClassName,
id,
titleAs,
}: TileHeaderProps) {
const Heading = titleAs ?? "h2";
// The title wraps; it never truncates. A tile title is the only thing that
// says what the number below it is, and German compounds
// ("Herzfrequenzvariabilität", "Körperfettverteilung") overrun a narrow
// tile in one word. `min-w-0` lets it shrink beside the icon and the
// `right` slot, `hyphens-auto` breaks a compound at a syllable (the
// document's `lang` drives the dictionary) and `break-words` is the floor
// for a word the dictionary does not know. `leading-tight` keeps a second
// line clear of the first; on one line it matches the 20 px icon box, so a
// single-line header is the height it was. There is no class override:
// every caller used one to add `truncate`.
const titleClassNames = cn(
"min-w-0 leading-tight break-words hyphens-auto",
size === "sm" ? "text-sm" : "text-base",
titleClassName,
);
return (
<div
@@ -102,7 +109,7 @@ export function TileHeader({
<Heading
id={id}
data-slot="card-title"
className={cn("leading-none font-semibold", titleClassNames)}
className={cn("font-semibold", titleClassNames)}
>
{title}
</Heading>
+4 -1
View File
@@ -63,7 +63,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn(
"leading-none font-semibold break-words hyphens-auto",
className,
)}
{...props}
/>
);