feat(labs): give the lab-scan review stage the width a large screen offers

The review stage is a list of readings to compare and correct, but it sat in
the default 448 px dialog, so with several readings it only grew taller.

- ResponsiveSheet gains an opt-in 6xl width tier (72rem). Nothing else
  changes width; the Sheet branch on phones ignores the tier as before.
- The review stage uses it. The picking stage stays narrow.
- Each row lays out from the width of its container, not the window:
  stacked in a narrow dialog, and once the dialog is wide enough the reading
  sits on the left with its fields on the right.
- The field grid (value, unit, date) is a container of its own, so it takes
  three columns when the fields have room for them, whatever the window
  width. The result-text span follows the same query.
- The dialog-overflow e2e spec covers the review stage at 390, 560, 768,
  1280, 1920 and 3440 px. 560 is wider than a phone's field grid needs but
  below the window sm breakpoint, which is what tells a container query from
  a window one.

Refs #1056
This commit is contained in:
BernhardBuckel committed 2026-10-02 09:14:51 +02:00
1 parent 23b813f447
commit a2a3414b29
6 files changed
+516 -220

No files matched your search

+197
View File
@@ -1,6 +1,7 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* An add / edit dialog must not pan sideways, and nothing you have to operate
@@ -290,3 +291,199 @@ test.describe("add/edit dialogs do not scroll sideways", () => {
);
});
});
/**
* The lab-scan review stage is a list of readings to compare and correct, so
* it takes the room a large screen offers while phones keep the bottom sheet.
* Measured at the widths that matter: a phone, a tablet, the shared 1280 px
* frame, full HD and an ultrawide.
*
* The rows lay themselves out from the width of the dialog (a container
* query), so the assertion is about the RESULT: where the reading and its
* fields sit relative to each other, not which class produced it.
*/
const REVIEW_VIEWPORTS = [
{
label: "phone 390",
width: 390,
height: 844,
wide: false,
threeAcross: false,
},
// Below the window `sm` breakpoint (640 px) yet with a dialog body well past
// the container `@md` breakpoint (448 px): the fields have room for three
// columns even though the window is "small". This is the width that tells
// "follows the dialog" from "follows the window".
{
label: "small tablet 560",
width: 560,
height: 900,
wide: false,
threeAcross: true,
},
{
label: "tablet 768",
width: 768,
height: 1024,
wide: false,
threeAcross: true,
},
{
label: "desktop 1280",
width: 1280,
height: 800,
wide: true,
threeAcross: true,
},
{
label: "full HD 1920",
width: 1920,
height: 1080,
wide: true,
threeAcross: true,
},
{
label: "ultrawide 3440",
width: 3440,
height: 1440,
wide: true,
threeAcross: true,
},
];
const REVIEW_ROWS = ["LDL", "HDL", "Glucose", "Creatinine", "ALT", "CRP"].map(
(analyte, index) => ({
analyte,
value: 1 + index,
valueText: null,
unit: "mmol/L",
referenceLow: 0,
referenceHigh: 5,
referenceText: "0 - 5",
takenAt: index === 0 ? null : "2026-06-10",
confidence: { analyte: 0.9, value: 0.9, unit: 0.9, range: 0.9 },
biomarkerMatch: "existing",
duplicateOf: null,
}),
);
// One pixel: enough to be a valid image for the picker, and never decoded.
const PIXEL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"base64",
);
test.describe("lab-scan review dialog uses the width it is given", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const vp of REVIEW_VIEWPORTS) {
test(`review stage @${vp.label}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
await page.route("**/api/labs/ocr/capability", (route) =>
route.fulfill({
json: {
data: {
available: true,
mode: "vision",
reason: null,
pdfSupported: true,
},
error: null,
},
}),
);
await page.route("**/api/labs/ocr/extract", (route) =>
route.fulfill({
json: {
data: {
reportDate: null,
providerType: "stub",
rows: REVIEW_ROWS,
},
error: null,
},
}),
);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /^add$/i }).first().click();
await page.getByRole("menuitem", { name: /scan/i }).click();
const surface = page.locator(SURFACE).first();
await expect(surface).toBeVisible();
// The picking stage is a single control and stays narrow.
const pickWidth = (await surface.boundingBox())!.width;
if (!vp.wide) expect(pickWidth).toBeLessThanOrEqual(vp.width);
else expect(pickWidth).toBeLessThan(500);
await page.locator('input[type="file"]').first().setInputFiles({
name: "report.png",
mimeType: "image/png",
buffer: PIXEL_PNG,
});
const rows = surface.locator("input[id$='-val']");
await expect(rows.first()).toBeVisible();
await expect(rows).toHaveCount(REVIEW_ROWS.length);
const label = `scan review @${vp.label}`;
await expectNoSidewaysScroll(page, label);
await expectControlsInsideBox(page, label);
const box = (await surface.boundingBox())!;
const analyte = (await surface
.getByLabel("Test name")
.first()
.boundingBox())!;
const value = (await rows.first().boundingBox())!;
const unit = (await surface
.locator("input[id$='-unit']")
.first()
.boundingBox())!;
const date = (await surface
.locator('[data-slot="date-field"]')
.first()
.boundingBox())!;
// Value, unit and date share a line when the fields have room for three
// columns, and the date wraps under them when they do not.
if (vp.threeAcross) {
expect(
Math.abs(unit.y - value.y),
`${label}: unit beside value`,
).toBeLessThan(8);
expect(
Math.abs(date.y - value.y),
`${label}: date beside value`,
).toBeLessThan(8);
} else {
expect(
Math.abs(unit.y - value.y),
`${label}: unit beside value`,
).toBeLessThan(8);
expect(date.y, `${label}: date wraps`).toBeGreaterThan(
value.y + value.height - 1,
);
}
if (vp.wide) {
// Room to compare: clearly wider than the old 448 px column, and never
// past the readable cap (72rem = 1152 px).
expect(box.width, `${label}: dialog width`).toBeGreaterThan(700);
expect(box.width, `${label}: dialog width`).toBeLessThanOrEqual(1153);
// The reading and its fields share a line: the value sits to the right
// of the analyte, not underneath it.
expect(value.x, `${label}: value beside analyte`).toBeGreaterThan(
analyte.x + analyte.width - 1,
);
expect(Math.abs(value.y - analyte.y)).toBeLessThan(80);
} else {
// Narrow: stacked exactly as before.
expect(value.y, `${label}: value under analyte`).toBeGreaterThan(
analyte.y + analyte.height - 1,
);
}
});
}
});
@@ -82,3 +82,36 @@ describe("<OcrRowEditor> required-field marking", () => {
expect(render({ date: true }, "de")).toContain("Bitte ein Datum angeben.");
});
});
describe("<OcrRowEditor> layout", () => {
it("lays out from the width of its container, not the window", () => {
const html = render(undefined);
expect(html).toContain('class="@container"');
// Two columns only once the container is wide; stacked below that.
expect(html).toContain("@3xl:grid-cols-");
});
it("sizes the field grid from its own container, never from the window", () => {
const numeric = render(undefined);
expect(numeric).toContain("@md:grid-cols-3");
expect(numeric).not.toMatch(/(?<![@\w-])sm:grid-cols/);
// A qualitative result spans the grid's columns; the span has to follow
// the same query as the columns, or it would open a column of its own.
const qualitative = render(undefined, "en", {
...baseRow,
value: null,
valueText: "negative",
unit: null,
});
expect(qualitative).toContain("@md:col-span-3");
expect(qualitative).not.toMatch(/(?<![@\w-])sm:col-span/);
});
it("keeps every field of the row, however the columns fall", () => {
const html = render(undefined);
for (const id of ["-val", "-unit", "-date", "-lo", "-hi", "-refText"]) {
expect(html, `field ${id}`).toContain(`${id}"`);
}
});
});
@@ -271,6 +271,10 @@ export function OcrReviewDialog({
open={open}
onOpenChange={handleClose}
title={t("labs.ocr.reviewTitle")}
// Picking a file is a single control and stays narrow. Reviewing is a
// list of readings to compare and correct, which wants the room; the
// rows lay themselves out from the width they are given.
contentWidth={stage === "review" ? "6xl" : "md"}
description={
stage === "pick" ? t("labs.ocr.uploadHint") : t("labs.ocr.reviewHint")
}
+240 -219
View File
@@ -97,243 +97,264 @@ export function OcrRowEditor({
const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds);
return (
<div className="space-y-3 rounded-lg border p-3">
<div className="flex items-start gap-3">
{/* The checkbox is the PRIMARY per-row confirm on a touch-first OCR
// `@container` makes the row follow the width of the dialog, not the
// window: in a narrow dialog (or on a phone) it stacks as before; once the
// dialog is wide enough it splits into the reading on the left and its
// fields on the right, so a long report reads as a table, not a column.
<div className="@container">
<div className="space-y-3 rounded-lg border p-3 @3xl:grid @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:items-start @3xl:space-y-0 @3xl:gap-x-6">
<div className="flex items-start gap-3">
{/* The checkbox is the PRIMARY per-row confirm on a touch-first OCR
review screen, so it needs a ≥44px hit area on coarse pointers. The
wrapping label supplies the touch target (and forwards the click to
the control) without enlarging the 20px glyph or bloating the row on
desktop, where the negative margins collapse the padding back. */}
<label
htmlFor={`${fieldId}-confirm`}
className="-m-3 flex min-h-11 min-w-11 cursor-pointer items-start justify-center p-3 sm:m-0 sm:min-h-0 sm:min-w-0 sm:p-0"
>
<Checkbox
id={`${fieldId}-confirm`}
checked={row.confirmed}
onCheckedChange={(checked) =>
onChange({ ...row, confirmed: checked === true })
}
className="mt-0.5 min-h-5 min-w-5 sm:mt-1"
aria-label={t("labs.ocr.confirmRow")}
/>
</label>
<div className="min-w-0 flex-1 space-y-1">
<Input
value={row.analyte}
onChange={(e) => onChange({ ...row, analyte: e.target.value })}
aria-label={t("labs.ocr.analyteLabel")}
{...invalidProps("analyte")}
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
)}
{row.duplicateOf ? (
<Badge variant="secondary">
<TriangleAlert aria-hidden />
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />
{t("labs.ocr.lowConfidenceBadge")}
</Badge>
) : null}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{isQualitative ? (
<div className="col-span-2 space-y-1 sm:col-span-3">
<Label htmlFor={`${fieldId}-vt`} className="text-xs">
{t("labs.ocr.resultLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-vt`}
value={row.valueText ?? ""}
onChange={(e) => onChange({ ...row, valueText: e.target.value })}
{...invalidProps("valueText")}
<label
htmlFor={`${fieldId}-confirm`}
className="-m-3 flex min-h-11 min-w-11 cursor-pointer items-start justify-center p-3 sm:m-0 sm:min-h-0 sm:min-w-0 sm:p-0"
>
<Checkbox
id={`${fieldId}-confirm`}
checked={row.confirmed}
onCheckedChange={(checked) =>
onChange({ ...row, confirmed: checked === true })
}
className="mt-0.5 min-h-5 min-w-5 sm:mt-1"
aria-label={t("labs.ocr.confirmRow")}
/>
{fieldError("valueText", "labs.ocr.errorResult")}
</label>
<div className="min-w-0 flex-1 space-y-1">
<Input
value={row.analyte}
onChange={(e) => onChange({ ...row, analyte: e.target.value })}
aria-label={t("labs.ocr.analyteLabel")}
{...invalidProps("analyte")}
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
)}
{row.duplicateOf ? (
<Badge variant="secondary">
<TriangleAlert aria-hidden />
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />
{t("labs.ocr.lowConfidenceBadge")}
</Badge>
) : null}
</div>
</div>
) : (
<>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-val`} className="text-xs">
{t("labs.ocr.valueLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-val`}
inputMode="decimal"
value={row.value === null ? "" : String(row.value)}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
value:
parsed !== null && Number.isFinite(parsed)
? parsed
: null,
});
}}
{...invalidProps("value")}
/>
{fieldError("value", "labs.ocr.errorValue")}
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-unit`} className="text-xs">
{t("labs.ocr.unitLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-unit`}
value={row.unit ?? ""}
onChange={(e) => onChange({ ...row, unit: e.target.value })}
{...invalidProps("unit")}
/>
{fieldError("unit", "labs.ocr.errorUnit")}
</div>
</>
)}
<div className="space-y-1">
<Label htmlFor={`${fieldId}-date`} className="text-xs">
{t("labs.ocr.dateLabel")}
<RequiredMark />
</Label>
<DateField
id={`${fieldId}-date`}
value={row.takenAt ?? ""}
onChange={(value) => onChange({ ...row, takenAt: value || null })}
{...invalidProps("date")}
// The bordered wrapper is the visible control; the input inside
// it only carries the aria state.
className={errors?.date ? "border-destructive" : undefined}
/>
{fieldError("date", "labs.ocr.errorDate")}
</div>
</div>
{/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below
{/* The fields are a container of their own, so how many columns they
get follows the width they actually have: the whole row when
stacked, the right-hand column once the row splits. */}
<div className="@container min-w-0 space-y-3">
<div className="grid grid-cols-2 gap-2 @md:grid-cols-3">
{isQualitative ? (
<div className="col-span-2 space-y-1 @md:col-span-3">
<Label htmlFor={`${fieldId}-vt`} className="text-xs">
{t("labs.ocr.resultLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-vt`}
value={row.valueText ?? ""}
onChange={(e) =>
onChange({ ...row, valueText: e.target.value })
}
{...invalidProps("valueText")}
/>
{fieldError("valueText", "labs.ocr.errorResult")}
</div>
) : (
<>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-val`} className="text-xs">
{t("labs.ocr.valueLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-val`}
inputMode="decimal"
value={row.value === null ? "" : String(row.value)}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
value:
parsed !== null && Number.isFinite(parsed)
? parsed
: null,
});
}}
{...invalidProps("value")}
/>
{fieldError("value", "labs.ocr.errorValue")}
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-unit`} className="text-xs">
{t("labs.ocr.unitLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-unit`}
value={row.unit ?? ""}
onChange={(e) => onChange({ ...row, unit: e.target.value })}
{...invalidProps("unit")}
/>
{fieldError("unit", "labs.ocr.errorUnit")}
</div>
</>
)}
<div className="space-y-1">
<Label htmlFor={`${fieldId}-date`} className="text-xs">
{t("labs.ocr.dateLabel")}
<RequiredMark />
</Label>
<DateField
id={`${fieldId}-date`}
value={row.takenAt ?? ""}
onChange={(value) =>
onChange({ ...row, takenAt: value || null })
}
{...invalidProps("date")}
// The bordered wrapper is the visible control; the input inside
// it only carries the aria state.
className={errors?.date ? "border-destructive" : undefined}
/>
{fieldError("date", "labs.ocr.errorDate")}
</div>
</div>
{/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below
becomes the marker's catalog range. Make that consequence visible, and
flag low range-confidence / a missing range so the user verifies it
before saving. A calm `secondary` badge, not an alarm. */}
{isNewNumericMarker ? (
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={flagRange ? "secondary" : "outline"}>
<Ruler aria-hidden />
{flagRange
? t("labs.ocr.rangeVerify")
: t("labs.ocr.rangeFromScan")}
</Badge>
</div>
) : null}
{isNewNumericMarker ? (
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={flagRange ? "secondary" : "outline"}>
<Ruler aria-hidden />
{flagRange
? t("labs.ocr.rangeVerify")
: t("labs.ocr.rangeFromScan")}
</Badge>
</div>
) : null}
{!isQualitative ? (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor={`${fieldId}-lo`} className="text-xs">
{t("labs.ocr.refLowLabel")}
</Label>
<Input
id={`${fieldId}-lo`}
inputMode="decimal"
value={row.referenceLow === null ? "" : String(row.referenceLow)}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
referenceLow:
parsed !== null && Number.isFinite(parsed) ? parsed : null,
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-hi`} className="text-xs">
{t("labs.ocr.refHighLabel")}
</Label>
<Input
id={`${fieldId}-hi`}
inputMode="decimal"
value={
row.referenceHigh === null ? "" : String(row.referenceHigh)
}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
referenceHigh:
parsed !== null && Number.isFinite(parsed) ? parsed : null,
});
}}
/>
</div>
</div>
) : null}
{!isQualitative ? (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor={`${fieldId}-lo`} className="text-xs">
{t("labs.ocr.refLowLabel")}
</Label>
<Input
id={`${fieldId}-lo`}
inputMode="decimal"
value={
row.referenceLow === null ? "" : String(row.referenceLow)
}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
referenceLow:
parsed !== null && Number.isFinite(parsed)
? parsed
: null,
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-hi`} className="text-xs">
{t("labs.ocr.refHighLabel")}
</Label>
<Input
id={`${fieldId}-hi`}
inputMode="decimal"
value={
row.referenceHigh === null ? "" : String(row.referenceHigh)
}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
referenceHigh:
parsed !== null && Number.isFinite(parsed)
? parsed
: null,
});
}}
/>
</div>
</div>
) : null}
{/* The window as the report printed it. Editable, because the human is
{/* The window as the report printed it. Editable, because the human is
reviewing a transcription: the two bounds above are the derived
reading of this string, and correcting the string is how a range the
parser could not read ("bis 5,0", "negativ") still reaches the row
instead of being dropped. */}
{!isQualitative ? (
<div className="space-y-1">
<Label htmlFor={`${fieldId}-refText`} className="text-xs">
{t("labs.ocr.refTextLabel")}
</Label>
<Input
id={`${fieldId}-refText`}
value={row.referenceText ?? ""}
placeholder={t("labs.ocr.refTextPlaceholder")}
maxLength={120}
onChange={(e) => {
const raw = e.target.value;
onChange({
...row,
referenceText: raw.trim() === "" ? null : raw,
});
}}
/>
</div>
) : null}
{!isQualitative ? (
<div className="space-y-1">
<Label htmlFor={`${fieldId}-refText`} className="text-xs">
{t("labs.ocr.refTextLabel")}
</Label>
<Input
id={`${fieldId}-refText`}
value={row.referenceText ?? ""}
placeholder={t("labs.ocr.refTextPlaceholder")}
maxLength={120}
onChange={(e) => {
const raw = e.target.value;
onChange({
...row,
referenceText: raw.trim() === "" ? null : raw,
});
}}
/>
</div>
) : null}
{valueUnreadable ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
{t("labs.ocr.valueUnreadable")}
</p>
) : lowValueConfidence ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
{t("labs.ocr.lowConfidence")}
</p>
) : null}
{valueUnreadable ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
{t("labs.ocr.valueUnreadable")}
</p>
) : lowValueConfidence ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
{t("labs.ocr.lowConfidence")}
</p>
) : null}
</div>
</div>
</div>
);
}
@@ -142,6 +142,46 @@ describe("<ResponsiveSheet>", () => {
expect(html).toContain("pr-3");
});
it.each([
[undefined, "sm:max-w-md"],
["md", "sm:max-w-md"],
["4xl", "sm:max-w-4xl"],
["6xl", "sm:max-w-6xl"],
] as const)(
"maps contentWidth %s to %s on the Dialog branch, and to no other tier",
(contentWidth, expected) => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Review"
contentWidth={contentWidth}
>
<p>body</p>
</ResponsiveSheet>,
);
const tiers = html.match(/sm:max-w-(?:md|lg|2xl|3xl|4xl|6xl)/g);
expect(tiers).toEqual([expected]);
},
);
it("does not apply a width tier on the Sheet branch", () => {
mobile = true;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Review"
contentWidth="6xl"
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).not.toContain("sm:max-w-6xl");
mobile = false;
});
it("renders the bottom Sheet branch on narrow viewports", () => {
mobile = true;
const html = renderToStaticMarkup(
+2 -1
View File
@@ -87,7 +87,7 @@ export interface ResponsiveSheetProps {
* consumer-passed width silently collide with the hardcoded
* default, so the width now resolves from this single source.
*/
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl";
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl" | "6xl";
children: React.ReactNode;
}
@@ -100,6 +100,7 @@ const CONTENT_WIDTH_CLASS: Record<
"2xl": "sm:max-w-2xl",
"3xl": "sm:max-w-3xl",
"4xl": "sm:max-w-4xl",
"6xl": "sm:max-w-6xl",
};
/**