mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(forms): wire the ResponsiveSheet footer slot across every page-level form mount
Until now every <ResponsiveSheet> 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 <form> association via the HTML form attribute so submit-on-Enter and native validation continue to work end-to-end.
This commit is contained in:
1 parent
9266e405ca
commit
6704579c25
10 files changed
+464
-282
No files matched your search
@@ -46,6 +46,9 @@ export default function MeasurementsPage() {
|
||||
undefined,
|
||||
);
|
||||
const [consumedAddParam, setConsumedAddParam] = useState<string | null>(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<HTMLDivElement | null>(null);
|
||||
|
||||
if (addParam && addParam !== consumedAddParam) {
|
||||
setConsumedAddParam(addParam);
|
||||
@@ -96,6 +99,7 @@ export default function MeasurementsPage() {
|
||||
if (!open) setDefaultType(undefined);
|
||||
}}
|
||||
title={t("measurements.addMeasurement")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MeasurementForm
|
||||
defaultType={defaultType}
|
||||
@@ -107,6 +111,7 @@ export default function MeasurementsPage() {
|
||||
setDialogOpen(false);
|
||||
setDefaultType(undefined);
|
||||
}}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
data: medications,
|
||||
@@ -267,8 +271,10 @@ export default function MedicationsPage() {
|
||||
: t("medications.newMedication")
|
||||
}
|
||||
className="sm:max-w-lg"
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MedicationForm
|
||||
footerSlot={footerEl}
|
||||
initial={
|
||||
editingMed
|
||||
? {
|
||||
|
||||
@@ -15,6 +15,10 @@ export default function MoodPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
|
||||
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
|
||||
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,10 +56,12 @@ export default function MoodPage() {
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
onSuccess={() => setDialogOpen(false)}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const [moodFooterEl, setMoodFooterEl] = useState<HTMLDivElement | null>(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={
|
||||
<div ref={setMeasurementFooterEl} className="flex w-full" />
|
||||
}
|
||||
>
|
||||
<MeasurementForm
|
||||
onSuccess={() => setQuickEntryDialog(null)}
|
||||
onCancel={() => setQuickEntryDialog(null)}
|
||||
footerSlot={measurementFooterEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
<ResponsiveSheet
|
||||
open={quickEntryDialog === "mood"}
|
||||
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
onSuccess={() => setQuickEntryDialog(null)}
|
||||
onCancel={() => setQuickEntryDialog(null)}
|
||||
footerSlot={moodFooterEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
|
||||
@@ -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
|
||||
* `<ResponsiveSheet>` 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
|
||||
* `<form>` 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 `<form>` 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 = (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={loading}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("measurements.formReset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="measurement-type" className="shrink-0">
|
||||
{t("measurements.type")}
|
||||
@@ -429,49 +494,7 @@ export function MeasurementForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={loading}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("measurements.formReset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string>("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// v1.4.27 R4 RC2 — sticky-pin the Save / Cancel row by portalling it
|
||||
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
|
||||
// its `<form>` association via the HTML `form` attribute.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(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={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
aria-label={t("medications.sideEffects.sheetTitle")}
|
||||
@@ -459,22 +467,31 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
// `<ResponsiveSheet>` footer slot. Submit buttons keep their `<form>`
|
||||
// association via the HTML `form` attribute.
|
||||
const editFormId = useId();
|
||||
const createFormId = useId();
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [createFooterEl, setCreateFooterEl] = useState<HTMLDivElement | null>(
|
||||
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={<div ref={setEditFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{editing && (
|
||||
<form onSubmit={submitEdit} className="space-y-4">
|
||||
<form
|
||||
id={editFormId}
|
||||
onSubmit={submitEdit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-scheduledFor">
|
||||
{t("medications.intakeScheduledFor")}
|
||||
@@ -597,57 +614,66 @@ export function IntakeHistoryList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{editFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={editFormId}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
editFooterEl,
|
||||
)
|
||||
: null}
|
||||
|
||||
<AlertDialog
|
||||
open={editDeleteDialogOpen}
|
||||
@@ -688,8 +714,13 @@ export function IntakeHistoryList({
|
||||
open={creating}
|
||||
onOpenChange={(open) => !open && closeCreate()}
|
||||
title={t("medications.newIntake")}
|
||||
footer={<div ref={setCreateFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form onSubmit={submitCreate} className="space-y-4">
|
||||
<form
|
||||
id={createFormId}
|
||||
onSubmit={submitCreate}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-scheduledFor">
|
||||
{t("medications.intakeScheduledFor")}
|
||||
@@ -744,23 +775,32 @@ export function IntakeHistoryList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeCreate}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{createFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeCreate}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={createFormId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>,
|
||||
createFooterEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 `<ResponsiveSheet>` footer slot.
|
||||
const formId = useId();
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [dosesTotal, setDosesTotal] = useState<string>(
|
||||
defaultDosesPerUnit ? String(defaultDosesPerUnit) : "",
|
||||
);
|
||||
@@ -430,8 +435,9 @@ export function InventorySection({
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title={t("medications.inventory.addPenTitle")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="inv-doses-total">
|
||||
{t("medications.inventory.fieldDosesTotal")}
|
||||
@@ -509,25 +515,34 @@ export function InventorySection({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetAddForm();
|
||||
setAddOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("medications.inventory.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.inventory.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetAddForm();
|
||||
setAddOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("medications.inventory.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.inventory.save")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 { Input } from "@/components/ui/input";
|
||||
@@ -160,6 +161,14 @@ interface Schedule {
|
||||
interface MedicationFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* v1.4.27 R4 RC2 — when the form is mounted inside a
|
||||
* `<ResponsiveSheet>` the caller passes the sheet's footer slot
|
||||
* element here. The kebab + Cancel + Save row portals into that slot
|
||||
* so the bottom-sheet branch can sticky-pin it; the Save button keeps
|
||||
* its `<form>` association via the HTML `form` attribute.
|
||||
*/
|
||||
footerSlot?: HTMLElement | null;
|
||||
editActions?: {
|
||||
onImportIntakes: () => void;
|
||||
onApiAccess: () => void;
|
||||
@@ -236,6 +245,7 @@ function sortSchedules(list: Schedule[]): Schedule[] {
|
||||
export function MedicationForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
editActions,
|
||||
initial,
|
||||
}: MedicationFormProps) {
|
||||
@@ -243,6 +253,11 @@ export function MedicationForm({
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
// v1.4.27 R4 RC2 — stable form id so the portalled Save button keeps
|
||||
// its `<form>` association via the HTML `form` attribute even when
|
||||
// DOM-mounted in the `<ResponsiveSheet>` footer slot.
|
||||
const formId = useId();
|
||||
|
||||
const doseUnits = [
|
||||
"mg",
|
||||
"g",
|
||||
@@ -523,7 +538,7 @@ export function MedicationForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="med-name">{t("medications.name")}</Label>
|
||||
<Input
|
||||
@@ -967,102 +982,123 @@ export function MedicationForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
disabled={loading || deleting}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{isEdit ? (
|
||||
<>
|
||||
{editActions && (
|
||||
{(() => {
|
||||
const footerNode = (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
disabled={loading || deleting}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{isEdit ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={editActions.onImportIntakes}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("medications.importIntakesAction")}
|
||||
{editActions && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={editActions.onImportIntakes}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("medications.importIntakesAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={editActions.onApiAccess}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
{t("medications.apiEndpointAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPhaseConfigOpen(true)}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
{t("medications.phaseConfig")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActive((prev) => !prev)}
|
||||
>
|
||||
{active ? (
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{active
|
||||
? t("medications.pause")
|
||||
: t("medications.activate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={editActions.onApiAccess}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
{t("medications.apiEndpointAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setPhaseConfigOpen(true)}>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
{t("medications.phaseConfig")}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setNotificationsEnabled((prev) => !prev)
|
||||
}
|
||||
>
|
||||
{notificationsEnabled ? (
|
||||
<BellOff className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{notificationsEnabled
|
||||
? t("medications.disableNotifications")
|
||||
: t("medications.enableNotifications")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setPurgeDialogOpen(true)}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
{t("medications.purgeRecords")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={resetCreateForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("medications.formReset")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setActive((prev) => !prev)}>
|
||||
{active ? (
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{active ? t("medications.pause") : t("medications.activate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setNotificationsEnabled((prev) => !prev)}
|
||||
>
|
||||
{notificationsEnabled ? (
|
||||
<BellOff className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{notificationsEnabled
|
||||
? t("medications.disableNotifications")
|
||||
: t("medications.enableNotifications")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setPurgeDialogOpen(true)}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
{t("medications.purgeRecords")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={resetCreateForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("medications.formReset")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading || deleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={loading || deleting}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
|
||||
{isEdit ? t("common.save") : t("medications.createMedication")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading || deleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={loading || deleting}
|
||||
>
|
||||
{loading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{isEdit
|
||||
? t("common.save")
|
||||
: t("medications.createMedication")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return footerSlot ? createPortal(footerNode, footerSlot) : footerNode;
|
||||
})()}
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -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 {
|
||||
@@ -46,6 +47,15 @@ const GLP1_SIDE_EFFECT_KEYS = [
|
||||
interface MoodFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* v1.4.27 R4 RC2 — when the form is mounted inside a
|
||||
* `<ResponsiveSheet>` the caller can pass the sheet's footer slot
|
||||
* element here. The action-row (kebab + Cancel + Save) is portalled
|
||||
* into that slot so the bottom-sheet branch can sticky-pin it; the
|
||||
* Save button stays inside the logical `<form>` via the HTML `form`
|
||||
* attribute so submit-on-Enter still works.
|
||||
*/
|
||||
footerSlot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
function getDefaultMoodLoggedAtValue() {
|
||||
@@ -55,7 +65,7 @@ function getDefaultMoodLoggedAtValue() {
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
|
||||
export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -69,6 +79,11 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
|
||||
// v1.4.27 R4 RC2 — stable form id so the portalled Save button can
|
||||
// associate with this `<form>` element via the HTML `form` attribute
|
||||
// even when DOM-mounted inside the `<ResponsiveSheet>` footer slot.
|
||||
const formId = useId();
|
||||
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setTagsInput("");
|
||||
@@ -117,8 +132,58 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const footerNode = (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={loading}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("mood.formReset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={loading || !mood}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label id="mood-level-label">{t("mood.moodLevel")}</Label>
|
||||
<div
|
||||
@@ -247,49 +312,7 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={loading}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("mood.formReset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={loading || !mood}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user