fix(timeline): keep each mean's dot and value one unbreakable unit

The dot before a mean sat on the x-height middle, a little below the
text, and a wrapping line could leave it at the end of one line while its
value moved to the next. Each dot and value is now one inline-flex unit
that centres the dot on the text line and never wraps inside; the row
breaks only between units, a long name is cut short inside its own unit,
and the leading mean sign rides in the first unit. The legend and chip
dots no longer shrink beside long text.
This commit is contained in:
Marc-André Bombeck committed 2026-10-10 20:04:13 +02:00
1 parent b787bc3176
commit 023115b05c
5 files changed
+72 -13

No files matched your search

@@ -551,6 +551,43 @@ describe("value line colours", () => {
expect(text(html)).toBe("129/82 mmHg, 82,6 kg");
expect(html).toContain('class="sr-only">, </span>');
});
it("keeps each dot and its value in one unit that never breaks", () => {
const html = render(
<MeanPartsLine
lead="Ø"
parts={[
{ key: "BLOOD_PRESSURE_SYS", text: "129/82 mmHg" },
{ key: "WEIGHT", text: "82,6 kg" },
{ key: "STRAIN", text: "Daily strain with a very long name 12" },
]}
seriesColor={seriesColor}
/>,
);
// Each unit opens with its own tag; everything up to the next unit's tag
// (the lead, the dot, the value) is inside it.
const units = html.split(/(?=<span[^>]*data-slot="timeline-mean-part")/);
expect(units).toHaveLength(3);
for (const unit of units) {
const open = unit.slice(0, unit.indexOf(">"));
expect(open).toContain("inline-flex");
expect(open).toContain("items-center");
expect(open).toContain("whitespace-nowrap");
const dot = unit.match(
/<span[^>]*data-slot="timeline-series-dot"[^>]*>/,
)?.[0];
expect(dot).toContain("shrink-0");
expect(dot).not.toContain("align-middle");
// The value follows the dot inside the same unit.
expect(text(unit.slice(unit.indexOf(dot!)))).toMatch(/\S/);
}
// The lead rides in the first unit, so "Ø" never ends a line alone.
expect(text(units[0]).startsWith("Ø")).toBe(true);
// A long name is cut short inside its unit.
expect(units[2]).toContain(
'<span class="min-w-0 truncate">Daily strain with a very long name 12</span>',
);
});
});
describe("life events in the capture picker", () => {
+4 -4
View File
@@ -39,7 +39,7 @@ import {
} from "./timeline-dates";
import { useItemWords } from "./item-words";
import { itemsInPeriod } from "./timeline-geometry";
import { MeanPartsLine, laneDotStyle } from "./timeline-chart";
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
import {
bucketValues,
labelledMeanParts,
@@ -201,12 +201,12 @@ export function SelectionBar({
})}
{means.values.length > 0 && (
<span
className="text-muted-foreground text-xs tabular-nums"
className={MEAN_PARTS_ROW}
data-slot="timeline-selection-means"
data-bucket={means.start}
>
{meanBefore}
<MeanPartsLine
lead={meanBefore || undefined}
parts={labelledMeanParts(means.values, seriesFormat, {
label: seriesLabel,
bloodPressure: t("timeline.values.bloodPressure"),
@@ -215,7 +215,7 @@ export function SelectionBar({
})}
seriesColor={seriesColor}
/>
{meanAfter}
{meanAfter ? <span>{meanAfter}</span> : null}
</span>
)}
</div>
+17 -3
View File
@@ -1117,13 +1117,22 @@ function SeriesTable({
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
* a screen reader hears a comma between them. The dot is decoration; the
* text reads the same without it.
*
* Each dot and its value are one unit that never breaks: a line wraps only
* between units (the caller's container is a wrapping flex row), so a dot
* cannot stay behind at the end of a line while its value moves to the
* next. The dot sits on the middle of the text line, and a name too long for
* the row is cut short inside its unit. `lead` (the "Ø" in front of the
* selection bar's means) rides in the first unit for the same reason.
*/
export function MeanPartsLine({
parts,
seriesColor,
lead,
}: {
parts: readonly MeanPart[];
seriesColor: (key: string) => string;
lead?: React.ReactNode;
}) {
return parts.map((part, i) => (
<span
@@ -1133,16 +1142,17 @@ export function MeanPartsLine({
// The readings behind a mean: a hover title, and the same words for a
// screen reader, but not on the line itself.
title={part.detail}
className={i > 0 ? "ml-3" : undefined}
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
>
{i === 0 && lead ? <span className="shrink-0">{lead}</span> : null}
{i > 0 ? <span className="sr-only">, </span> : null}
<span
data-slot="timeline-series-dot"
className="mr-1 inline-block size-2 rounded-full align-middle"
className="size-2 shrink-0 rounded-full"
style={{ background: seriesColor(part.key) }}
aria-hidden="true"
/>
{part.text}
<span className="min-w-0 truncate">{part.text}</span>
{part.detail ? (
<span className="sr-only" data-slot="timeline-mean-detail">
{`, ${part.detail}`}
@@ -1152,6 +1162,10 @@ export function MeanPartsLine({
));
}
/** The row the units of a `MeanPartsLine` wrap in: breaks only between them. */
export const MEAN_PARTS_ROW =
"text-muted-foreground inline-flex max-w-full flex-wrap items-center gap-x-3 gap-y-1 text-xs tabular-nums";
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
return { background: LANE_COLOR[lane] };
@@ -50,7 +50,12 @@ import {
formatMonthYear,
} from "./timeline-dates";
import { LANE_COLOR } from "./timeline-geometry";
import { MeanPartsLine, bucketText, laneDotStyle } from "./timeline-chart";
import {
MEAN_PARTS_ROW,
MeanPartsLine,
bucketText,
laneDotStyle,
} from "./timeline-chart";
import {
chronicleMeans,
meanParts,
@@ -247,7 +252,7 @@ export function TimelineChronicle({
className="bg-muted text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
>
<span
className="size-1.5 rounded-full"
className="size-1.5 shrink-0 rounded-full"
style={laneDotStyle(chip.lane)}
aria-hidden="true"
/>
@@ -295,7 +300,7 @@ export function TimelineChronicle({
</h3>
{bucketMeans && parts.length > 0 && (
<span
className="text-muted-foreground text-xs tabular-nums"
className={MEAN_PARTS_ROW}
data-slot="timeline-chronicle-means"
data-bucket={bucketMeans.start}
>
+6 -3
View File
@@ -603,14 +603,14 @@ export function TimelineLegend({
>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground h-1.5 w-4 rounded-full"
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendSpan")}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground size-2 rounded-full"
className="bg-muted-foreground size-2 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendEvent")}
@@ -622,7 +622,10 @@ export function TimelineLegend({
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
<span
className="bg-foreground h-0.5 w-4 shrink-0"
aria-hidden="true"
/>
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span