mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
6df0c64d85
commit
9c8c7051aa
6 files changed
+202
-58
No files matched your search
+34
-12
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user