fix(labs): pin add-entry action bars to the sheet footer; tighten header

The lab and biomarker forms portal their Cancel/Save row into the
ResponsiveSheet sticky footer (footerSlot pattern) so Save stays reachable
on mobile instead of scrolling away. The Labs header folds Manage
biomarkers into an overflow kebab beside the primary Add, mirroring the
medication header. The biomarker detail gains a self-edit Pencil opening
the pre-filled form. Add buttons on the labs detail and measurements page
meet the min-h-11 sm:min-h-9 tap floor.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:12:04 +02:00
1 parent 6df0c64d85
commit 9c8c7051aa
6 files changed
+202 -58

No files matched your search

+34 -12
View File
@@ -3,12 +3,18 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Loader2, Plus } from "lucide-react";
import { FlaskConical, Loader2, MoreVertical, Plus } from "lucide-react";
import { BiomarkerManager } from "@/components/labs/biomarker-manager";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
@@ -25,6 +31,9 @@ export default function LabsPage() {
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
// Sticky-footer slot element for the add-result sheet (filled by the
// ResponsiveSheet `footer` ref so the form portals its action row there).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -61,17 +70,7 @@ export default function LabsPage() {
{t("labs.subtitle")}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
onClick={() => setManageOpen(true)}
className="min-h-11 sm:min-h-9"
>
<FlaskConical className="h-4 w-4" />
<span className="hidden sm:inline">
{t("labs.biomarker.manage")}
</span>
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -79,6 +78,27 @@ export default function LabsPage() {
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
{/* Secondary actions fold into one overflow kebab beside the primary
Add, mirroring the medication-card header (one cluster, not a
two-button row). */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => setManageOpen(true)}>
<FlaskConical className="mr-2 h-4 w-4" />
{t("labs.biomarker.manage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -87,8 +107,10 @@ export default function LabsPage() {
onOpenChange={setDialogOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
footer={<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />}
>
<LabForm
footerSlot={addFooterEl}
onSuccess={() => {
setDialogOpen(false);
queryClient.invalidateQueries({
+4 -1
View File
@@ -120,7 +120,10 @@ export default function MeasurementsPage() {
{t("measurements.subtitle")}
</p>
</div>
<Button className="shrink-0" onClick={() => setDialogOpen(true)}>
<Button
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setDialogOpen(true)}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
+38 -20
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -41,6 +42,13 @@ interface BiomarkerFormProps {
existing?: BiomarkerDto;
onSuccess?: (saved: BiomarkerDto) => void;
onCancel?: () => void;
/**
* When mounted inside a `<ResponsiveSheet>` the caller passes the sheet's
* footer slot element here; the Cancel / Save row portals into it so the
* bottom-sheet branch can sticky-pin it. The Save button stays tied to the
* `<form>` via the HTML `form` attribute.
*/
footerSlot?: HTMLElement | null;
}
/**
@@ -57,8 +65,10 @@ export function BiomarkerForm({
existing,
onSuccess,
onCancel,
footerSlot,
}: BiomarkerFormProps) {
const { t } = useTranslations();
const formId = useId();
const [name, setName] = useState(existing?.name ?? "");
const [unit, setUnit] = useState(existing?.unit ?? "");
@@ -129,8 +139,29 @@ export function BiomarkerForm({
}
}
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.biomarker.form.save")}
</Button>
</>
);
return (
<form onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
{!existing ? (
<div className="space-y-1.5">
<Label htmlFor="biomarker-seed">
@@ -258,24 +289,11 @@ export function BiomarkerForm({
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.biomarker.form.save")}
</Button>
</div>
{footerSlot ? (
createPortal(footerNode, footerSlot)
) : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
);
}
@@ -33,6 +33,7 @@ export function BiomarkerManager() {
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -165,9 +166,13 @@ export function BiomarkerManager() {
: t("labs.biomarker.defineTitle")
}
description={t("labs.biomarker.defineDescription")}
footer={
<div ref={setFormFooterEl} className="flex w-full justify-end gap-2" />
}
>
<BiomarkerForm
existing={editing ?? undefined}
footerSlot={formFooterEl}
onSuccess={afterSave}
onCancel={() => setFormOpen(false)}
/>
+69 -5
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Plus } from "lucide-react";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -15,6 +15,7 @@ import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import { LabBiomarkerChart } from "./lab-biomarker-chart";
import { LabForm } from "./lab-form";
import { LabHistoryList } from "./lab-history-list";
@@ -35,6 +36,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
// Sticky-footer slot for the add-value sheet (the form portals here).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
const { data: marker, isError: markerError } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
@@ -62,6 +67,9 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
});
const readings: LabResultDto[] = list?.results ?? [];
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
const latest =
readings.length > 0
? [...readings].sort(
@@ -75,6 +83,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
function afterEditMarker() {
setEditOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
// Resolved name / unit / range on every reading derives from the marker.
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
@@ -105,10 +123,25 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</p>
) : null}
</div>
<Button onClick={() => setAddOpen(true)} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
</div>
</div>
{latest ? (
@@ -156,6 +189,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
{readings.length > 0 ? (
<div className="space-y-2">
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", {
shown: readings.length,
total,
})}
</p>
) : null}
<Card>
<CardContent className="py-0">
<LabHistoryList readings={readings} />
@@ -169,13 +210,36 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onOpenChange={setAddOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
footer={
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
}
>
<LabForm
lockedBiomarkerId={biomarkerId}
footerSlot={addFooterEl}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
/>
</ResponsiveSheet>
<ResponsiveSheet
open={editOpen}
onOpenChange={setEditOpen}
title={t("labs.biomarker.editTitle")}
description={t("labs.biomarker.defineDescription")}
footer={
<div ref={setEditFooterEl} className="flex w-full justify-end gap-2" />
}
>
{marker ? (
<BiomarkerForm
existing={marker}
footerSlot={editFooterEl}
onSuccess={afterEditMarker}
onCancel={() => setEditOpen(false)}
/>
) : null}
</ResponsiveSheet>
</div>
);
}
+52 -20
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -54,6 +55,14 @@ interface LabFormProps {
lockedBiomarkerId?: string;
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
/**
* When mounted inside a `<ResponsiveSheet>` the caller passes the sheet's
* footer slot element here. The Cancel / Save action row is portalled into
* it so the bottom-sheet branch can sticky-pin it above the keyboard; the
* Save button stays tied to the `<form>` via the HTML `form` attribute so
* submit-on-Enter and portalled-click both still submit.
*/
footerSlot?: HTMLElement | null;
}
/**
@@ -70,9 +79,11 @@ export function LabForm({
lockedBiomarkerId,
onSuccess,
onCancel,
footerSlot,
}: LabFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const { data: catalog, isLoading: catalogLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -88,6 +99,9 @@ export function LabForm({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [defineOpen, setDefineOpen] = useState(false);
const [defineFooterEl, setDefineFooterEl] = useState<HTMLDivElement | null>(
null,
);
const markers = catalog?.biomarkers ?? [];
const selected = markers.find((m) => m.id === biomarkerId);
@@ -148,9 +162,30 @@ export function LabForm({
)
: "";
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</>
);
return (
<>
<form onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
<Select
@@ -239,33 +274,30 @@ export function LabForm({
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</div>
{/* When no footer slot is supplied (rare — e.g. a non-sheet host) the
action row renders inline; inside a sheet it portals into the
sticky footer. */}
{footerSlot ? null : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
{footerSlot ? createPortal(footerNode, footerSlot) : null}
<ResponsiveSheet
open={defineOpen}
onOpenChange={setDefineOpen}
title={t("labs.biomarker.defineTitle")}
description={t("labs.biomarker.defineDescription")}
footer={
<div
ref={setDefineFooterEl}
className="flex w-full justify-end gap-2"
/>
}
>
<BiomarkerForm
footerSlot={defineFooterEl}
onSuccess={afterDefine}
onCancel={() => setDefineOpen(false)}
/>