mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
4 files changed
+222
-5
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user