diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx
index d4a484f7f..e2542bc63 100644
--- a/src/components/timeline/__tests__/timeline-components.test.tsx
+++ b/src/components/timeline/__tests__/timeline-components.test.tsx
@@ -551,6 +551,43 @@ describe("value line colours", () => {
expect(text(html)).toBe("129/82 mmHg, 82,6 kg");
expect(html).toContain('class="sr-only">, ');
});
+
+ it("keeps each dot and its value in one unit that never breaks", () => {
+ const html = render(
+ ,
+ );
+ // 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(/(?=]*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(
+ /]*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(
+ 'Daily strain with a very long name 12',
+ );
+ });
});
describe("life events in the capture picker", () => {
diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx
index 7c844e611..9c821c9af 100644
--- a/src/components/timeline/selection-bar.tsx
+++ b/src/components/timeline/selection-bar.tsx
@@ -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 && (
- {meanBefore}
- {meanAfter}
+ {meanAfter ? {meanAfter} : null}
)}
diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx
index 86eee576c..106d1839a 100644
--- a/src/components/timeline/timeline-chart.tsx
+++ b/src/components/timeline/timeline-chart.tsx
@@ -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) => (
0 ? "ml-3" : undefined}
+ className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
>
+ {i === 0 && lead ? {lead} : null}
{i > 0 ? , : null}
- {part.text}
+ {part.text}
{part.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] };
diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx
index ec315bfea..9974295fc 100644
--- a/src/components/timeline/timeline-chronicle.tsx
+++ b/src/components/timeline/timeline-chronicle.tsx
@@ -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"
>
@@ -295,7 +300,7 @@ export function TimelineChronicle({
{bucketMeans && parts.length > 0 && (
diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx
index 954b2a9c6..0be0e8eec 100644
--- a/src/components/timeline/timeline-view.tsx
+++ b/src/components/timeline/timeline-view.tsx
@@ -603,14 +603,14 @@ export function TimelineLegend({
>
{t("timeline.legendSpan")}
{t("timeline.legendEvent")}
@@ -622,7 +622,10 @@ export function TimelineLegend({
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
-
+
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}