fix(labs): fit the lab list, values list and add sheet to a phone

The compact list was a five-column table at every width. Below the fixed
columns (badge, 12rem range bar, 72px trend, chevron) the name column
collapsed to nothing, the badge sat over the value and the row panned
sideways off the card. Below lg the same cells now fold into two lines
per row; the desktop subgrid is unchanged.

The values table needed ~510px, so on a phone its edit and delete
buttons sat in a sideways-scrolling strip and the range bar labels were
clipped. Below lg each reading is a stacked row with its actions beside
it.

The add sheet's three footer buttons did not fit a phone row in German
and pushed Cancel off the left edge; the secondary save takes a row of
its own there, and the footer wraps instead of overflowing on desktop.
The scan review's 'links to existing marker' chip truncates instead of
running past its row.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:44:29 +02:00
1 parent 1b694ec222
commit 4a13004917
5 files changed
+214 -112

No files matched your search

+4 -1
View File
@@ -192,7 +192,10 @@ export default function LabsPage() {
title={t("labs.addResult")}
description={t("labs.addDescription")}
footer={
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
<div
ref={setAddFooterEl}
className="flex w-full flex-wrap justify-end gap-2"
/>
}
>
<LabForm
+4
View File
@@ -368,6 +368,10 @@ export function LabForm({
variant="outline"
data-action="save-and-add-another"
disabled={submitting}
// Three buttons do not fit one phone-width row: the secondary save
// takes a row of its own above Cancel + Save instead of pushing
// Cancel off the left edge of the sheet.
className="max-md:order-first max-md:w-full"
>
{pendingAction === "saveAndAddAnother" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
+139 -94
View File
@@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}, [readings, sortBy, sortDir]);
// One action pair per reading, shared by the phone list and the table.
const renderActions = (r: LabResultDto) => (
<div className="flex items-center justify-end gap-1">
{canManageLabs && (
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
domain="labs"
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
);
return (
<>
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("labs.form.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("labs.form.takenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="range"
label={t("labs.referenceLabel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("labs.hasNote")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("labs.editReading")}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
{canManageLabs && (
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
domain="labs"
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</TableCell>
{/* Phones get one stacked row per reading. The table needs ~510 px for
its five columns, so on a phone it panned sideways inside its
container and parked the edit / delete buttons off-screen, while the
range bar's end labels were clipped by the same container. */}
<ul
data-slot="lab-history-mobile-list"
className="divide-border divide-y lg:hidden"
>
{ordered.map((r) => (
<li key={r.id} className="flex items-start gap-3 py-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r)}
</div>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
{r.hasNote ? (
<span className="text-xs">{t("labs.hasNote")}</span>
) : null}
</div>
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
{renderActions(r)}
</li>
))}
</ul>
<div data-slot="lab-history-table" className="max-lg:hidden">
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("labs.form.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("labs.form.takenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="range"
label={t("labs.referenceLabel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("labs.hasNote")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("labs.editReading")}</span>
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
{renderActions(r)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ResponsiveSheet
open={editingId !== null}
+57 -15
View File
@@ -80,7 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className="w-48">
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
<LabReferenceRangeBar
value={reading.value}
referenceLow={reading.referenceLow}
@@ -91,6 +91,35 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
);
}
/**
* The compact list is a five-column table from `lg` up: name and reading, range
* badge, range bar, trend, chevron. Below that the same five cells fold into
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
* trend + gaps) are wider than a phone, and the only flexible column — the
* name — was the one that gave way: it collapsed to nothing and the row
* panned sideways off the card.
*
* Phone layout, per row:
*
* name / reading · date badge ›
* range bar trend
*
* The placement classes are all `max-lg:` so the desktop table keeps exactly
* the subgrid it had.
*/
const ROW_CLASS =
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
const MOBILE_CELL = {
reading: "max-lg:col-start-1 max-lg:row-start-1",
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
trend:
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
end: "max-lg:col-start-3 max-lg:row-start-1",
notLinked: "max-lg:col-span-full max-lg:row-start-3",
} as const;
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
@@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</p>
) : null}
<Card>
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
<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">
{groups.map((group) => {
const inner = (
<div className="min-w-0 flex-1">
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="truncate font-medium">
<span
data-slot="lab-list-analyte"
className="truncate font-medium"
>
{group.analyte}
</span>
</div>
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
className={cn(
ROW_CLASS,
"hover:bg-muted/40 transition-colors",
)}
>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0",
MOBILE_CELL.end,
)}
/>
</Link>
) : (
// Un-linked group (not backfilled to a catalog biomarker):
// render inert but say so, so it doesn't read as a broken
// link next to its clickable neighbours.
<div
key={group.key}
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
>
<div key={group.key} className={ROW_CLASS}>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<span className="text-muted-foreground shrink-0 text-xs italic">
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
+10 -2
View File
@@ -129,9 +129,17 @@ export function OcrRowEditor({
{fieldError("analyte", "labs.ocr.errorAnalyte")}
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
<Badge variant="outline" className="text-muted-foreground">
// 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 />
{t("labs.ocr.linksExisting", { name: row.analyte })}
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">