mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Icons inside buttons drop their manual margins in favour of the button's built-in gap (two hundred call sites), form field stacks settle on one spacing step, card titles on exactly two sizes, inline field errors on one text size, and a dozen hand-rolled loading pulses move onto the shared skeleton primitive while keeping their silhouettes. The intake dialog wording joins the rest of the app on 'übersprungen', and the UI guidelines now document the two deliberate footer and touch-target systems.
229 lines
7.6 KiB
TypeScript
229 lines
7.6 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. Pass the action `<Button>`s directly (a fragment is
|
|
* fine) — never a wrapper `<div className="flex …">`. The two
|
|
* branches deliberately stack differently and both rely on owning
|
|
* the flex context: the Sheet branch pins a `flex-row justify-end`
|
|
* bar so Cancel/Save sit side by side above the keyboard, while the
|
|
* Dialog branch defers to `DialogFooter`'s `flex-col-reverse
|
|
* sm:flex-row` so narrow dialogs stack the primary action on top.
|
|
* A consumer-supplied wrapper div collapses both into a single flex
|
|
* child and breaks that responsive stacking. This split is a
|
|
* decision, not drift — don't unify the two branches.
|
|
*
|
|
* 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 bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 backdrop-blur"
|
|
>
|
|
{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>
|
|
);
|
|
}
|