mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
c245a3f2af
commit
7af2e3d29a
10 files changed
+119
-23
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user