fix(medications): render intake dialogs as bottom sheets on mobile

Port the six medication dialogs (log-intake, intake-edit, dose-history-add,
take-all-due, log-injection-site, intake-import) from the centred Dialog to
ResponsiveSheet with the sticky-footer slot, matching every other module's
add/edit form. Form-backed dialogs associate their footer submit button via a
useId form id so Enter-to-submit and the footer button share one form; the
nested slot-nudge confirm rides the same sheet. Primary actions now sit within
thumb reach on phones.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 00:13:08 +02:00
1 parent 1c57da7d44
commit 96c99f4dfa
6 files changed
+234 -286

No files matched your search

@@ -17,20 +17,13 @@
* ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Switch } from "@/components/ui/switch";
@@ -135,6 +128,7 @@ export function LedgerAddDialog({
}: LedgerAddDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date()));
const [skipped, setSkipped] = useState(false);
@@ -200,22 +194,40 @@ export function LedgerAddDialog({
}
return (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="ledger-add-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.addDialog.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.addDialog.description")}
</DialogDescription>
</DialogHeader>
<ResponsiveSheet
open={open}
onOpenChange={(o) => !busy && onOpenChange(o)}
title={t("medications.detail.verlauf.addDialog.title")}
description={t("medications.detail.verlauf.addDialog.description")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.detail.verlauf.addDialog.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
</>
}
>
{/* v1.16.4 — a real form so Enter in the datetime field submits;
the buttons carry explicit types so cancel never submits. The
nested nudge dialog renders through a portal, so its buttons
never land inside this form. */}
the buttons carry explicit types so cancel never submits. */}
<form
id={formId}
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
@@ -263,49 +275,17 @@ export function LedgerAddDialog({
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.detail.verlauf.addDialog.cancel")}
</Button>
<Button
type="submit"
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
</DialogFooter>
</form>
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
take as a standalone ad-hoc entry. */}
{nudgeOpen && nearest && (
<Dialog
<ResponsiveSheet
open={nudgeOpen}
onOpenChange={(o) => !busy && setNudgeOpen(o)}
>
<DialogContent data-slot="ledger-add-nudge-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.nudge.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.nudge.body", {
slot: nearest.slotHm,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
title={t("medications.detail.verlauf.nudge.title")}
footer={
<>
<Button
variant="outline"
onClick={() => void write(false)}
@@ -325,11 +305,16 @@ export function LedgerAddDialog({
)}
{t("medications.detail.verlauf.nudge.pin")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<p className="text-muted-foreground text-sm">
{t("medications.detail.verlauf.nudge.body", {
slot: nearest.slotHm,
})}
</p>
</ResponsiveSheet>
)}
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -21,19 +21,13 @@
* persisted it (audit LOW-10), so the dialog no longer pretends.
*/
import { useState } from "react";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -107,6 +101,7 @@ function IntakeEditDialogBody({
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const formId = useId();
const [takenAt, setTakenAt] = useState(() =>
toDateTimeLocal(event?.takenAt ?? null),
);
@@ -145,16 +140,38 @@ function IntakeEditDialogBody({
}
return (
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
<DialogContent data-slot="intake-edit-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.intake.edit.dialogTitle")}
</DialogTitle>
</DialogHeader>
<ResponsiveSheet
open={!!event}
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.intake.edit.dialogTitle")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("medications.detail.intake.edit.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.intake.edit.save")}
</Button>
</>
}
>
{/* v1.16.4 — a real form so Enter in the datetime field submits;
the buttons carry explicit types so cancel never submits. */}
<form
id={formId}
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
@@ -208,24 +225,7 @@ function IntakeEditDialogBody({
/>
</label>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("medications.detail.intake.edit.cancel")}
</Button>
<Button type="submit" disabled={busy} aria-busy={busy || undefined}>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.intake.edit.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -22,12 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -143,12 +138,58 @@ export function IntakeImportDialog({
}
return (
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<ResponsiveSheet
open={!!medicationId}
onOpenChange={() => handleClose()}
title={t("medications.importIntakes")}
contentWidth="lg"
footer={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="mr-auto h-9 w-9"
disabled={importing}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetImportForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
onClick={handleClose}
disabled={importing}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
className="min-h-11 sm:min-h-9"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-4 w-4" />
)}
{t("common.import")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("medications.importDescription")}
</p>
@@ -194,54 +235,7 @@ export function IntakeImportDialog({
{result}
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
disabled={importing}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetImportForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={importing}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
className="min-h-11 sm:min-h-9"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-4 w-4" />
)}
{t("common.import")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -4,14 +4,7 @@ import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
import {
@@ -85,7 +78,7 @@ export function LogInjectionSiteDialog({
}
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
// Hold the dialog while the PATCH is in flight so a backdrop tap /
@@ -93,18 +86,31 @@ export function LogInjectionSiteDialog({
if (submitting) return;
if (!next) onSkip();
}}
title={t("medications.logInjectionSiteTitle")}
description={t("medications.logInjectionSiteDescription", {
name: medicationName,
})}
footer={
<>
<Button variant="outline" onClick={onSkip} disabled={submitting}>
{t("medications.logInjectionSiteSkip")}
</Button>
<Button
disabled={selected === null || submitting}
aria-busy={submitting || undefined}
onClick={() => {
void handleConfirm();
}}
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.logInjectionSiteConfirm")}
</Button>
</>
}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
<DialogDescription>
{t("medications.logInjectionSiteDescription", {
name: medicationName,
})}
</DialogDescription>
</DialogHeader>
{allowed.length === 0 ? (
{allowed.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("medications.logInjectionSiteNoneAvailable")}
</p>
@@ -161,25 +167,6 @@ export function LogInjectionSiteDialog({
</ul>
</>
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onSkip} disabled={submitting}>
{t("medications.logInjectionSiteSkip")}
</Button>
<Button
disabled={selected === null || submitting}
aria-busy={submitting || undefined}
onClick={() => {
void handleConfirm();
}}
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.logInjectionSiteConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -19,20 +19,13 @@
* the form state + the medication/slot pickers.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
@@ -119,6 +112,7 @@ export function LogIntakeDialog({
}: LogIntakeDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const [medicationId, setMedicationId] = useState<string>(
() => medications[0]?.id ?? "",
@@ -208,18 +202,39 @@ export function LogIntakeDialog({
}
return (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="log-intake-dialog">
<DialogHeader>
<DialogTitle>{t("medications.logIntake.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.logIntake.dialogDescription")}
</DialogDescription>
</DialogHeader>
<ResponsiveSheet
open={open}
onOpenChange={(o) => !busy && onOpenChange(o)}
title={t("medications.logIntake.dialogTitle")}
description={t("medications.logIntake.dialogDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.logIntake.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</>
}
>
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
submits; mirrors the intake-edit and dose-history-add dialogs. */}
<form
id={formId}
onSubmit={(e) => {
e.preventDefault();
if (busy || medications.length === 0 || !medicationId) return;
@@ -326,29 +341,7 @@ export function LogIntakeDialog({
</label>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.logIntake.cancel")}
</Button>
<Button
type="submit"
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -19,14 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { CheckCheck, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import { formatTimeWindowRange } from "@/lib/time-window-format";
@@ -65,49 +58,17 @@ export function TakeAllDueDialog({
}
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
// Refuse to close while the take loop is in flight.
if (submitting && !next) return;
onOpenChange(next);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.takeAllDue.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.takeAllDue.dialogDescription")}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2" data-testid="take-all-due-list">
{dueMedications.map((med) => (
<li
key={med.id}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
<span className="font-medium">{med.name}</span>
{med.dose ? (
<span className="text-muted-foreground">
{" "}
— {formatDose(med.dose, t)}
</span>
) : null}
</span>
{med.window ? (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatTimeWindowRange(
med.window.start,
med.window.end,
locale,
)}
</span>
) : null}
</li>
))}
</ul>
<DialogFooter>
title={t("medications.takeAllDue.dialogTitle")}
description={t("medications.takeAllDue.dialogDescription")}
footer={
<>
<Button
type="button"
variant="outline"
@@ -136,8 +97,36 @@ export function TakeAllDueDialog({
count: dueMedications.length,
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<ul className="space-y-2" data-testid="take-all-due-list">
{dueMedications.map((med) => (
<li
key={med.id}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
<span className="font-medium">{med.name}</span>
{med.dose ? (
<span className="text-muted-foreground">
{" "}
— {formatDose(med.dose, t)}
</span>
) : null}
</span>
{med.window ? (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatTimeWindowRange(
med.window.start,
med.window.end,
locale,
)}
</span>
) : null}
</li>
))}
</ul>
</ResponsiveSheet>
);
}