From 6704579c25386302306cc6127916f643e771430a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 21:05:50 +0200 Subject: [PATCH] fix(forms): wire the ResponsiveSheet footer slot across every page-level form mount Until now every consumer (except export-section) inlined the form footer inside the body slot, so the primitive's sticky-pinned bottom rail on the Sheet branch only painted empty. The Save / Cancel row therefore scrolled with the body and slid out from under the soft keyboard on mobile. This rewires the action-row through the footer prop on all seven page-level mounts (dashboard quick-entry measurement + mood, /measurements, /mood, /medications, side-effects sheet, inventory add sheet, intake-history edit + create sheets, medication create/edit sheet). Each form receives an optional footerSlot prop; when set, the kebab + Cancel + Save row is portalled into the ResponsiveSheet footer slot so the bottom-sheet branch can sticky-pin it. The Save button keeps its
association via the HTML form attribute so submit-on-Enter and native validation continue to work end-to-end. --- src/app/measurements/page.tsx | 5 + src/app/medications/page.tsx | 6 + src/app/mood/page.tsx | 6 + src/app/page.tsx | 11 + .../measurements/measurement-form.tsx | 111 +++++---- .../medications/SideEffectsSection.tsx | 49 ++-- .../medications/intake-history-list.tsx | 176 ++++++++------ .../medications/inventory-section.tsx | 53 +++-- .../medications/medication-form.tsx | 216 ++++++++++-------- src/components/mood/mood-form.tsx | 113 +++++---- 10 files changed, 464 insertions(+), 282 deletions(-) diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 10fcd7fdd..87c770f2d 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -46,6 +46,9 @@ export default function MeasurementsPage() { undefined, ); const [consumedAddParam, setConsumedAddParam] = useState(null); + // v1.4.27 R4 RC2 — DOM handle the form portals its action row into so + // the Sheet branch can sticky-pin Save / Cancel. + const [footerEl, setFooterEl] = useState(null); if (addParam && addParam !== consumedAddParam) { setConsumedAddParam(addParam); @@ -96,6 +99,7 @@ export default function MeasurementsPage() { if (!open) setDefaultType(undefined); }} title={t("measurements.addMeasurement")} + footer={
} > diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index cf32e9197..690c103c7 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -79,6 +79,10 @@ export default function MedicationsPage() { id: string; name: string; } | null>(null); + // v1.4.27 R4 RC2 — DOM handle the medication form portals its + // action-row into so the sheet branch can sticky-pin Save / Cancel + // above the keyboard. + const [footerEl, setFooterEl] = useState(null); const { data: medications, @@ -267,8 +271,10 @@ export default function MedicationsPage() { : t("medications.newMedication") } className="sm:max-w-lg" + footer={
} > ` footer slot so the + // Sheet branch can sticky-pin Save / Cancel above the keyboard. + const [footerEl, setFooterEl] = useState(null); const { t } = useTranslations(); useEffect(() => { @@ -52,10 +56,12 @@ export default function MoodPage() { open={dialogOpen} onOpenChange={setDialogOpen} title={t("mood.addEntry")} + footer={
} > setDialogOpen(false)} onCancel={() => setDialogOpen(false)} + footerSlot={footerEl} /> diff --git a/src/app/page.tsx b/src/app/page.tsx index 1609f10fc..fdfa08f53 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -191,6 +191,11 @@ export default function DashboardPage() { const [quickEntryDialog, setQuickEntryDialog] = useState< "measurement" | "mood" | null >(null); + // v1.4.27 R4 RC2 — DOM handles for the form action-row portal target + // on each quick-entry sheet. The Sheet branch sticky-pins this slot. + const [measurementFooterEl, setMeasurementFooterEl] = + useState(null); + const [moodFooterEl, setMoodFooterEl] = useState(null); const { data } = useQuery({ queryKey: queryKeys.analytics(), @@ -524,20 +529,26 @@ export default function DashboardPage() { open={quickEntryDialog === "measurement"} onOpenChange={(open) => !open && setQuickEntryDialog(null)} title={t("measurements.addMeasurement")} + footer={ +
+ } > setQuickEntryDialog(null)} onCancel={() => setQuickEntryDialog(null)} + footerSlot={measurementFooterEl} /> !open && setQuickEntryDialog(null)} title={t("mood.addEntry")} + footer={
} > setQuickEntryDialog(null)} onCancel={() => setQuickEntryDialog(null)} + footerSlot={moodFooterEl} /> diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 75b3c7697..c74369d96 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useId, useState } from "react"; +import { createPortal } from "react-dom"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { @@ -104,6 +105,15 @@ interface MeasurementFormProps { onSuccess?: () => void; onCancel?: () => void; defaultType?: string; + /** + * v1.4.27 R4 RC2 — when the form is mounted inside a + * `` the caller passes the sheet's footer slot + * element here. The form's action-row (kebab + Cancel + Save) is + * portalled into that slot so the bottom-sheet branch can + * sticky-pin it; the Save button stays associated with the logical + * `` via the HTML `form` attribute. + */ + footerSlot?: HTMLElement | null; } function getDefaultMeasuredAtValue() { @@ -117,6 +127,7 @@ export function MeasurementForm({ onSuccess, onCancel, defaultType, + footerSlot, }: MeasurementFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -146,6 +157,10 @@ export function MeasurementForm({ const errorId = useId(); const errorDescriptor = error ? errorId : undefined; + // v1.4.27 R4 RC2 — stable id so the portalled Save button can keep + // its `` association via the HTML `form` attribute. + const formId = useId(); + const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type); const isBpMode = type === "BLOOD_PRESSURE"; const isGlucoseMode = type === "BLOOD_GLUCOSE"; @@ -250,8 +265,58 @@ export function MeasurementForm({ } } + const footerNode = ( +
+ + + + + + + + {t("measurements.formReset")} + + + + +
+ {onCancel && ( + + )} + +
+
+ ); + return ( - +
)} -
- - - - - - - - {t("measurements.formReset")} - - - - -
- {onCancel && ( - - )} - -
-
+ {footerSlot ? createPortal(footerNode, footerSlot) : footerNode} ); } diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/SideEffectsSection.tsx index c05054cc7..0aa22425f 100644 --- a/src/components/medications/SideEffectsSection.tsx +++ b/src/components/medications/SideEffectsSection.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Plus, Trash2 } from "lucide-react"; @@ -103,6 +104,11 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null); const [notes, setNotes] = useState(""); const [formError, setFormError] = useState(null); + // v1.4.27 R4 RC2 — sticky-pin the Save / Cancel row by portalling it + // into the `` footer slot. The submit button keeps + // its `
` association via the HTML `form` attribute. + const [footerEl, setFooterEl] = useState(null); + const formId = useId(); const listKey = [ "medications", @@ -328,8 +334,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { open={open} onOpenChange={setOpen} title={t("medications.sideEffects.sheetTitle")} + footer={
} > )} -
- - -
+ {footerEl + ? createPortal( +
+ + +
, + footerEl, + ) + : null} ); diff --git a/src/components/medications/intake-history-list.tsx b/src/components/medications/intake-history-list.tsx index f2517b2d9..5081e2cb3 100644 --- a/src/components/medications/intake-history-list.tsx +++ b/src/components/medications/intake-history-list.tsx @@ -49,6 +49,7 @@ import { AlertTriangle, } from "lucide-react"; import { useId, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; @@ -115,6 +116,17 @@ export function IntakeHistoryList({ const editErrorId = useId(); const editErrorDescriptor = editError ? editErrorId : undefined; + // v1.4.27 R4 RC2 — portal targets + stable form ids so the edit and + // create sheets sticky-pin Save / Cancel via the + // `` footer slot. Submit buttons keep their `
` + // association via the HTML `form` attribute. + const editFormId = useId(); + const createFormId = useId(); + const [editFooterEl, setEditFooterEl] = useState(null); + const [createFooterEl, setCreateFooterEl] = useState( + null, + ); + // Create state — controlled via props or internal const [internalCreating, setInternalCreating] = useState(false); const creating = createOpen ?? internalCreating; @@ -540,9 +552,14 @@ export function IntakeHistoryList({ open={!!editing} onOpenChange={(open) => !open && closeEdit()} title={t("medications.editIntake")} + footer={
} > {editing && ( - +
)} -
- - - - - - setEditDeleteDialogOpen(true)} - > - - {t("common.delete")} - - - + {editFooterEl + ? createPortal( +
+ + + + + + setEditDeleteDialogOpen(true)} + > + + {t("common.delete")} + + + -
- - -
-
+
+ + +
+
, + editFooterEl, + ) + : null} !open && closeCreate()} title={t("medications.newIntake")} + footer={
} > - +
)} -
- - -
+ {createFooterEl + ? createPortal( +
+ + +
, + createFooterEl, + ) + : null} ); diff --git a/src/components/medications/inventory-section.tsx b/src/components/medications/inventory-section.tsx index 3d6b55f56..e9c75f5b4 100644 --- a/src/components/medications/inventory-section.tsx +++ b/src/components/medications/inventory-section.tsx @@ -1,6 +1,7 @@ "use client"; import { useId, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react"; @@ -77,6 +78,10 @@ export function InventorySection({ // input via aria-describedby so screen readers announce the failure // when the form rejects an out-of-range value. const formErrorId = useId(); + // v1.4.27 R4 RC2 — portal target + stable form id wire the sticky + // Save / Cancel row into the `` footer slot. + const formId = useId(); + const [footerEl, setFooterEl] = useState(null); const [dosesTotal, setDosesTotal] = useState( defaultDosesPerUnit ? String(defaultDosesPerUnit) : "", ); @@ -430,8 +435,9 @@ export function InventorySection({ open={addOpen} onOpenChange={setAddOpen} title={t("medications.inventory.addPenTitle")} + footer={
} > -
+