diff --git a/e2e/labs-list-wide.spec.ts b/e2e/labs-list-wide.spec.ts new file mode 100644 index 000000000..4ca0fe357 --- /dev/null +++ b/e2e/labs-list-wide.spec.ts @@ -0,0 +1,140 @@ +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, 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 + * 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 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) { + 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..21e133d8a 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -106,4 +106,63 @@ 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)_72px_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 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)_"); + }); + + 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..668ca4813 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 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 = (
+ {/* 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 (