mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Medication editor overhaul, route of administration, today-tile read-flip. - Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from treatmentClass; injection-site picker surfaces for any injection. Migration 0088 backfills ORAL via a constant column default. - One-time injection (oneShot + deliveryForm = INJECTION) editor path. - Profile-photo upload card on Settings → Account. - Today-tile read-flip: /api/medications/intake?scope=today and /api/dashboard/summary gate through the canonical recurrence engine, matching the reminder worker for bi-weekly, rolling, RRULE, and one-time cadences. - Measurement note cap 25 → 200; dashboard range colors on semantic tokens; admin sidebar icons + shared-helper consolidation; API-tokens multi-language snippets restored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
6.9 KiB
TypeScript
218 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
|
|
export interface ResponsiveSheetProps {
|
|
/** Controlled open state. */
|
|
open: boolean;
|
|
/** Open-state setter forwarded to the underlying Sheet / Dialog root. */
|
|
onOpenChange: (open: boolean) => void;
|
|
/**
|
|
* Required accessible title. Rendered inside the sheet / dialog
|
|
* header so screen-readers always announce the surface, even when
|
|
* the visual header is empty.
|
|
*/
|
|
title: React.ReactNode;
|
|
/** Optional short description rendered beneath the title. */
|
|
description?: React.ReactNode;
|
|
/**
|
|
* Hide the visual header (title + description). The title remains
|
|
* mounted in an `sr-only` block so the accessible name is still
|
|
* announced. Use when the consumer renders its own header inside
|
|
* the body.
|
|
*/
|
|
hideHeader?: boolean;
|
|
/**
|
|
* Footer slot. The Sheet branch sticky-pins the footer to the
|
|
* bottom of the bottom-sheet so the primary CTA stays reachable
|
|
* once the body scrolls; the Dialog branch lets the footer flow
|
|
* normally beneath the body.
|
|
*/
|
|
footer?: React.ReactNode;
|
|
/** Class name applied to the content surface (sheet / dialog). */
|
|
className?: string;
|
|
/** Class name applied to the body wrapper. */
|
|
bodyClassName?: string;
|
|
/**
|
|
* Forward to the underlying Sheet/Dialog `showCloseButton`
|
|
* primitive prop. Defaults to `true`; pass `false` when the
|
|
* consumer renders its own close affordance inside the header.
|
|
*/
|
|
showCloseButton?: boolean;
|
|
/**
|
|
* Desktop (Dialog branch) content width. The bottom-sheet branch
|
|
* always spans the viewport, so this prop only affects the `md+`
|
|
* Dialog. Defaults to `"md"` (448 px) to preserve every existing
|
|
* consumer; pass `"4xl"` for the wide medication editor's
|
|
* two-column layout. The class is the only width utility applied —
|
|
* the prior `cn("sm:max-w-md", className)` ordering let a
|
|
* consumer-passed width silently collide with the hardcoded
|
|
* default, so the width now resolves from this single source.
|
|
*/
|
|
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl";
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const CONTENT_WIDTH_CLASS: Record<
|
|
NonNullable<ResponsiveSheetProps["contentWidth"]>,
|
|
string
|
|
> = {
|
|
md: "sm:max-w-md",
|
|
lg: "sm:max-w-lg",
|
|
"2xl": "sm:max-w-2xl",
|
|
"3xl": "sm:max-w-3xl",
|
|
"4xl": "sm:max-w-4xl",
|
|
};
|
|
|
|
/**
|
|
* v1.4.27 R3d MB1 — shared mount primitive that flips between a
|
|
* bottom-anchored `<Sheet>` on narrow viewports and the existing
|
|
* centred `<Dialog>` on tablet/desktop. Picks the breakpoint via
|
|
* the `useIsMobile()` hook (Tailwind `md` — 768 px).
|
|
*
|
|
* On the Sheet branch we cap the content at `90dvh`, scroll the
|
|
* body on overflow, and sticky-pin the footer so Save / Cancel
|
|
* stay reachable when the keyboard pushes the bottom of the sheet
|
|
* up. The Dialog branch consumes the primitive's `max-h-[calc(100dvh-2rem)]`
|
|
* default so long forms scroll inside the dialog instead of
|
|
* spilling off-screen on tablet portrait.
|
|
*
|
|
* Consumers swap `<Dialog>` → `<ResponsiveSheet>` and feed the
|
|
* existing form markup through `children` / `footer`. The rest of
|
|
* the form's chrome is unchanged because both branches expose the
|
|
* same header / body / footer slot shape.
|
|
*/
|
|
export function ResponsiveSheet({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
hideHeader = false,
|
|
footer,
|
|
className,
|
|
bodyClassName,
|
|
showCloseButton = true,
|
|
contentWidth = "md",
|
|
children,
|
|
}: ResponsiveSheetProps) {
|
|
const isMobile = useIsMobile();
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent
|
|
side="bottom"
|
|
showCloseButton={showCloseButton}
|
|
data-slot="responsive-sheet-content"
|
|
data-variant="sheet"
|
|
className={cn(
|
|
// Cap at 90 dvh so the user sees a slice of the page
|
|
// behind the sheet (clear "this is a sheet, not a
|
|
// takeover") + the bottom-sheet rounded top edge that
|
|
// matches the iOS feel.
|
|
"flex max-h-[90dvh] flex-col gap-0 rounded-t-2xl p-0",
|
|
className,
|
|
)}
|
|
>
|
|
{hideHeader ? (
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>{title}</SheetTitle>
|
|
{description ? (
|
|
<SheetDescription>{description}</SheetDescription>
|
|
) : null}
|
|
</SheetHeader>
|
|
) : (
|
|
<SheetHeader
|
|
data-slot="responsive-sheet-header"
|
|
className="border-border/70 gap-1.5 border-b p-4 pr-12"
|
|
>
|
|
<SheetTitle className="text-base font-semibold">
|
|
{title}
|
|
</SheetTitle>
|
|
{description ? (
|
|
<SheetDescription className="text-muted-foreground text-xs">
|
|
{description}
|
|
</SheetDescription>
|
|
) : null}
|
|
</SheetHeader>
|
|
)}
|
|
<div
|
|
data-slot="responsive-sheet-body"
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
|
bodyClassName,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<SheetFooter
|
|
data-slot="responsive-sheet-footer"
|
|
className="border-border/70 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
|
>
|
|
{footer}
|
|
</SheetFooter>
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={showCloseButton}
|
|
data-slot="responsive-sheet-content"
|
|
data-variant="dialog"
|
|
className={cn(CONTENT_WIDTH_CLASS[contentWidth], className)}
|
|
>
|
|
{hideHeader ? (
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{description ? (
|
|
<DialogDescription>{description}</DialogDescription>
|
|
) : null}
|
|
</DialogHeader>
|
|
) : (
|
|
<DialogHeader data-slot="responsive-sheet-header">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{description ? (
|
|
<DialogDescription>{description}</DialogDescription>
|
|
) : null}
|
|
</DialogHeader>
|
|
)}
|
|
<div
|
|
data-slot="responsive-sheet-body"
|
|
className={cn("flex flex-col gap-4", bodyClassName)}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<DialogFooter data-slot="responsive-sheet-footer">
|
|
{footer}
|
|
</DialogFooter>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|