Merge pull request #1141 from MBombeck/ready/pr-1132

feat(labs): let the lab list's range bar use a wide card
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-04 16:14:56 +02:00
commit 63a5304e43
4 files changed
+222 -5

No files matched your search

+140
View File
@@ -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<string, number> = {};
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);
}
});
});
@@ -106,4 +106,63 @@ describe("<LabList> 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(/<div[^>]*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);
}
});
});
});
+18 -4
View File
@@ -93,8 +93,11 @@ function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) {
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
<div className={cn("w-full", MOBILE_CELL.rangeBar)}>
{/* 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. */}
<LabReferenceRangeBar
className="lg:max-w-none"
value={reading.value}
referenceLow={reading.referenceLow}
referenceHigh={reading.referenceHigh}
@@ -255,15 +258,26 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{t("labs.showingLatestOf", { shown, total })}
</p>
) : null}
<Card>
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] lg:gap-x-3">
{/* `@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. */}
<Card className="@container">
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:gap-x-3 lg:@max-5xl:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] @5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_72px_auto]">
{groups.map((group) => {
const inner = (
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
{/* 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. */}
<span
data-slot="lab-list-analyte"
className="truncate font-medium"
className="truncate font-medium lg:overflow-visible lg:[overflow-wrap:anywhere] lg:text-clip lg:whitespace-normal"
>
{group.analyte}
</span>
@@ -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 (
<div
data-slot="lab-reference-range-bar"
className="w-full max-w-48 shrink-0"
className={cn("w-full max-w-48 shrink-0", className)}
aria-label={rangeLabel}
>
<RangeBar