mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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
This commit is contained in:
1 parent
808b2672cd
commit
a1a84f924b
4 files changed
+203
-5
No files matched your search
@@ -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<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,58 @@ 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)_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(/<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 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,23 @@ 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 and the trend take part of
|
||||
the extra width instead of all of it going to the name. */}
|
||||
<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)_minmax(72px,8rem)_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