From 9c8c7051aa023a42e5869b7f475ba7819bd338e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:12:04 +0200 Subject: [PATCH] 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. --- src/app/labs/page.tsx | 46 ++++++++---- src/app/measurements/page.tsx | 5 +- src/components/labs/biomarker-form.tsx | 58 +++++++++------ src/components/labs/biomarker-manager.tsx | 5 ++ src/components/labs/lab-biomarker-detail.tsx | 74 ++++++++++++++++++-- src/components/labs/lab-form.tsx | 72 +++++++++++++------ 6 files changed, 202 insertions(+), 58 deletions(-) diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index d0b685f1c..8ef9835e3 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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(null); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -61,17 +70,7 @@ export default function LabsPage() { {t("labs.subtitle")}

-
- +
+ {/* Secondary actions fold into one overflow kebab beside the primary + Add, mirroring the medication-card header (one cluster, not a + two-button row). */} + + + + + + setManageOpen(true)}> + + {t("labs.biomarker.manage")} + + +
@@ -87,8 +107,10 @@ export default function LabsPage() { onOpenChange={setDialogOpen} title={t("labs.addResult")} description={t("labs.addDescription")} + footer={
} > { setDialogOpen(false); queryClient.invalidateQueries({ diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index c1dfcfacd..1689ed852 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -120,7 +120,10 @@ export default function MeasurementsPage() { {t("measurements.subtitle")}

- diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index e814ef717..056de1a3c 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -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 `` 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 + * `
` 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 ? ( + + ) : null} + + + ); + return ( - + {!existing ? (
{latest ? ( @@ -156,6 +189,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {readings.length > 0 ? (

{t("labs.detail.history")}

+ {truncated ? ( +

+ {t("labs.showingLatestOf", { + shown: readings.length, + total, + })} +

+ ) : null} @@ -169,13 +210,36 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onOpenChange={setAddOpen} title={t("labs.addResult")} description={t("labs.addDescription")} + footer={ +
+ } > setAddOpen(false)} /> + + + } + > + {marker ? ( + setEditOpen(false)} + /> + ) : null} +
); } diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 04aebe218..b36ceb170 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -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 `` 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 `
` 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(null); const [defineOpen, setDefineOpen] = useState(false); + const [defineFooterEl, setDefineFooterEl] = useState( + null, + ); const markers = catalog?.biomarkers ?? []; const selected = markers.find((m) => m.id === biomarkerId); @@ -148,9 +162,30 @@ export function LabForm({ ) : ""; + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + return ( <> - +