From a1a84f924b6c3c7ca365883befa6636973d3e6a4 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:56:43 +0200 Subject: [PATCH 1/2] feat(labs): let the lab list's range bar use a wide card On a wide window the compact lab list was one five-column row whose only flexible column was the name: the name column sat mostly empty while the range bar and the trend stayed small at the right edge. The list's card is now a container, and the columns follow its width instead of the window's. Below 64 rem of card the row is the lg table it was, with the range bar at 12 rem; from 64 rem the bar column grows from 12 to 18 rem and the trend column from 72 px to 8 rem, and the bar fills its column. The two tiers exclude each other, so the result does not depend on the order the CSS is emitted in. The trend sparkline itself is unchanged. From lg up a long analyte name wraps instead of being cut off; a phone keeps the single line with an ellipsis, and the bar keeps its own 12 rem cap there. The shared 1280 px frame is untouched; a Playwright spec measures it on /labs, /measurements and /mood at 1280, 1920 and 3440 px. Fixes #1057 --- e2e/labs-list-wide.spec.ts | 129 ++++++++++++++++++ .../labs/__tests__/lab-list-columns.test.tsx | 54 ++++++++ src/components/labs/lab-list.tsx | 19 ++- .../labs/lab-reference-range-bar.tsx | 6 +- 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 e2e/labs-list-wide.spec.ts diff --git a/e2e/labs-list-wide.spec.ts b/e2e/labs-list-wide.spec.ts new file mode 100644 index 000000000..52b624cf4 --- /dev/null +++ b/e2e/labs-list-wide.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; +import { + expectNoHorizontalOverflow, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; + +/** + * The lab list uses the width it has, at every width, inside a frame that does + * not move. + * + * The signed-in frame is 1280 px wide on purpose, so a page never jumps + * sideways when you move between routes. Inside it the compact list was one + * five-column row whose only flexible column was the name: on a wide window the + * name column sat mostly empty while the range bar and the trend stayed small + * at the right edge. The columns now follow the width of the list's own card + * (a container query), not the window's: + * + * - below 64 rem of card the row is the `lg` table it always was, and the + * range bar keeps its 12 rem; + * - from 64 rem the bar follows its column up to 18 rem. + * + * From `lg` a long name wraps instead of being cut off. The tiers are measured + * on the card's width and not the viewport's, so the spec reads the card and + * asserts the tier that width belongs to; a width at which no card ever reaches + * 64 rem would otherwise pass the wide check by never running it. + * + * The account is the one `globalSetup` seeds for the mobile route sweep: a long + * analyte name, readings with reference ranges. Desktop only; the phone layout + * has its own specs. + */ + +const WIDTHS = [1280, 1920, 3440] as const; +const REM = 16; +const BAR = '[data-slot="lab-reference-range-bar"]'; + +test.describe("the lab list uses its width", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "desktop-only spec", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + for (const width of WIDTHS) { + test(`at ${width} px: no sideways scroll, names read in full, the bar follows its tier`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 1000 }); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + const names = page.locator('[data-slot="lab-list-analyte"]'); + await expect(names.first()).toBeVisible(); + await settleForOverflowMeasurement(page); + + await expectNoHorizontalOverflow(page, `/labs at ${width}px`); + + // The long name is the point of the wrap: it is fully on screen. + const long = page + .locator('[data-slot="lab-list-analyte"]') + .filter({ hasText: MOBILE_ROUTES_ANALYTE }); + await expect(long.first()).toBeVisible(); + const clipped = await names.evaluateAll( + (nodes) => + nodes.filter((node) => node.scrollWidth > node.clientWidth + 1) + .length, + ); + expect(clipped, "an analyte name is cut off").toBe(0); + + const cardWidth = await page + .locator('[data-slot="lab-list"] [data-slot="card"]') + .first() + .evaluate((node) => node.getBoundingClientRect().width); + const bars = page.locator(BAR); + expect(await bars.count()).toBeGreaterThan(0); + const barWidths = await bars.evaluateAll((nodes) => + nodes.map((node) => node.getBoundingClientRect().width), + ); + + if (cardWidth < 64 * REM) { + // The narrow tier: exactly what the list had, 12 rem. + for (const w of barWidths) expect(Math.round(w)).toBe(12 * REM); + } else { + // The wide tier: wider than 12 rem, no wider than its column's cap. + for (const w of barWidths) { + expect(w).toBeGreaterThan(12 * REM); + expect(w).toBeLessThanOrEqual(18 * REM + 1); + } + } + + // The widest windows must actually reach the wide tier, or the branch + // above never ran. + if (width >= 1920) { + expect( + cardWidth, + "the card never reached 64 rem", + ).toBeGreaterThanOrEqual(64 * REM); + } + }); + } + + test("the frame is the same width on /labs, /measurements and /mood at every viewport", async ({ + page, + }) => { + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 1000 }); + const frameWidths: Record = {}; + for (const path of ["/labs", "/measurements", "/mood"]) { + await page.goto(path, { waitUntil: "domcontentloaded" }); + const frame = page.locator('[data-slot="main-content-wrapper"]'); + await expect(frame).toBeVisible(); + frameWidths[path] = Math.round( + await frame.evaluate((node) => node.getBoundingClientRect().width), + ); + } + const widths = Object.values(frameWidths); + expect( + new Set(widths).size, + `the frame differs between routes at ${width}px: ${JSON.stringify(frameWidths)}`, + ).toBe(1); + } + }); +}); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 6e3e8540d..a41569dec 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -106,4 +106,58 @@ describe(" columns", () => { const html = renderList([reading({})]); expect(html).toMatch(/class="[^"]*lucide-chevron-right[^"]*lg:invisible/); }); + + describe("on a wide list", () => { + const NARROW_TIER = + "lg:@max-5xl:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto]"; + const WIDE_TIER = + "@5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_minmax(72px,8rem)_auto]"; + + it("makes the card a container, so the columns follow the list's width, not the window's", () => { + const html = renderList([reading({})]); + expect(html).toMatch(/]*class="[^"]*@container[^"]*"/); + }); + + it("leaves the lg table as it was and adds a wider tier from @5xl, the two excluding each other", () => { + const html = renderList([reading({})]); + expect(html).toContain(NARROW_TIER); + expect(html).toContain(WIDE_TIER); + // Name, badge, bar, trend and chevron: five columns in both tiers. + expect(NARROW_TIER.split("_")).toHaveLength(5); + expect(WIDE_TIER.split("_")).toHaveLength(5); + }); + + it("keeps the name column the flexible one in both tiers", () => { + expect(NARROW_TIER).toContain("[minmax(0,1fr)_"); + expect(WIDE_TIER).toContain("[minmax(0,1fr)_"); + }); + + it("lets the range bar fill its column from lg, and caps it on a phone", () => { + const html = renderList([reading({})]); + expect(html).toMatch( + /data-slot="lab-reference-range-bar"[^>]*class="[^"]*max-w-48[^"]*lg:max-w-none/, + ); + // The slot no longer pins the bar to 12rem itself. + expect(html).not.toContain("lg:w-48"); + }); + + it("wraps a long analyte name from lg up and truncates it on a phone", () => { + const longName = "A".repeat(20) + " " + "B".repeat(100); + const html = renderList([reading({ analyte: longName })]); + expect(html).toContain(longName); + const cls = html.match( + /data-slot="lab-list-analyte"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="lab-list-analyte"/, + ); + const classes = (cls?.[1] ?? cls?.[2] ?? "").split(" "); + expect(classes).toContain("truncate"); + for (const c of [ + "lg:overflow-visible", + "lg:whitespace-normal", + "lg:text-clip", + "lg:[overflow-wrap:anywhere]", + ]) { + expect(classes).toContain(c); + } + }); + }); }); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 60c1d0a82..f7662a347 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -93,8 +93,11 @@ function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) { function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { return ( -
+
+ {/* A phone keeps the bar's own 12rem cap; from `lg` the bar fills its + column, which is 12rem until the list is wide enough to grow it. */} void } = {}) { {t("labs.showingLatestOf", { shown, total })}

) : null} - - + {/* `@container` makes the columns follow the width the list has, not the + window's: below `@5xl` (64rem of card) the row is the `lg` table it + always was (the two tiers exclude each other, so the result does not + depend on the order the CSS is emitted in), from there up the range bar and the trend take part of + the extra width instead of all of it going to the name. */} + + {groups.map((group) => { const inner = (
+ {/* One line with an ellipsis on a phone; from `lg` the name + wraps instead, so a long one (the schema allows 120 + characters) always reads in full. */} {group.analyte} diff --git a/src/components/labs/lab-reference-range-bar.tsx b/src/components/labs/lab-reference-range-bar.tsx index a9865763b..0fc0e1079 100644 --- a/src/components/labs/lab-reference-range-bar.tsx +++ b/src/components/labs/lab-reference-range-bar.tsx @@ -1,6 +1,7 @@ "use client"; import { RangeBar } from "@/components/targets/range-bar"; +import { cn } from "@/lib/utils"; import { useLabNumber } from "./use-lab-format"; @@ -16,11 +17,14 @@ export function LabReferenceRangeBar({ referenceLow, referenceHigh, unit, + className, }: { value: number | null; referenceLow: number | null; referenceHigh: number | null; unit: string; + /** Extra classes on the bar's wrapper; it caps itself at 12rem by default. */ + className?: string; }) { const labNumber = useLabNumber(); const hasLow = referenceLow !== null && Number.isFinite(referenceLow); @@ -48,7 +52,7 @@ export function LabReferenceRangeBar({ return (
Date: Sun, 4 Oct 2026 14:28:04 +0200 Subject: [PATCH 2/2] fix(labs): keep the trend column as wide as its sparkline on a wide list The wide tier let the trend column grow to 8rem while the sparkline it holds is a fixed 72px glyph, so at 1920 px an empty 56 px strip opened between the sparkline and the chevron. The column now stays 72px in both tiers; the extra width goes to the range bar and the name, as before. The wide-list spec reads the resolved grid tracks and asserts the trend track is 72px at every width it covers. --- e2e/labs-list-wide.spec.ts | 13 ++++++++++++- .../labs/__tests__/lab-list-columns.test.tsx | 7 ++++++- src/components/labs/lab-list.tsx | 9 ++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/e2e/labs-list-wide.spec.ts b/e2e/labs-list-wide.spec.ts index 52b624cf4..4ca0fe357 100644 --- a/e2e/labs-list-wide.spec.ts +++ b/e2e/labs-list-wide.spec.ts @@ -19,7 +19,8 @@ import { * * - below 64 rem of card the row is the `lg` table it always was, and the * range bar keeps its 12 rem; - * - from 64 rem the bar follows its column up to 18 rem. + * - from 64 rem the bar follows its column up to 18 rem, and the trend + * column stays as wide as its 72 px sparkline. * * From `lg` a long name wraps instead of being cut off. The tiers are measured * on the card's width and not the viewport's, so the spec reads the card and @@ -94,6 +95,16 @@ test.describe("the lab list uses its width", () => { } } + // The trend column is exactly as wide as the sparkline it holds, in both + // tiers. The sparkline is a fixed 72 px glyph; a wider column would only + // open an empty strip between it and the chevron. + const tracks = await page + .locator('[data-slot="lab-list"] [data-slot="card-content"]') + .first() + .evaluate((node) => getComputedStyle(node).gridTemplateColumns); + const trendTrack = tracks.split(" ")[3]; + expect(trendTrack, `trend column in "${tracks}"`).toBe("72px"); + // The widest windows must actually reach the wide tier, or the branch // above never ran. if (width >= 1920) { diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index a41569dec..21e133d8a 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -111,7 +111,7 @@ describe(" columns", () => { const NARROW_TIER = "lg:@max-5xl:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto]"; const WIDE_TIER = - "@5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_minmax(72px,8rem)_auto]"; + "@5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_72px_auto]"; it("makes the card a container, so the columns follow the list's width, not the window's", () => { const html = renderList([reading({})]); @@ -127,6 +127,11 @@ describe(" columns", () => { expect(WIDE_TIER.split("_")).toHaveLength(5); }); + it("keeps the trend column as wide as its 72px sparkline in both tiers", () => { + expect(NARROW_TIER.split("_")[3]).toBe("72px"); + expect(WIDE_TIER.split("_")[3]).toBe("72px"); + }); + it("keeps the name column the flexible one in both tiers", () => { expect(NARROW_TIER).toContain("[minmax(0,1fr)_"); expect(WIDE_TIER).toContain("[minmax(0,1fr)_"); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index f7662a347..668ca4813 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -261,10 +261,13 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {/* `@container` makes the columns follow the width the list has, not the window's: below `@5xl` (64rem of card) the row is the `lg` table it always was (the two tiers exclude each other, so the result does not - depend on the order the CSS is emitted in), from there up the range bar and the trend take part of - the extra width instead of all of it going to the name. */} + depend on the order the CSS is emitted in), from there up the range + bar takes part of the extra width instead of all of it going to the + name. The trend column stays 72px in both tiers: the sparkline is a + fixed 72px glyph, and a wider column would only open an empty strip + between it and the chevron. */} - + {groups.map((group) => { const inner = (