import * as React from "react"; import { Loader2, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { ConfirmDialog } from "@/components/ConfirmDialog"; export interface SheetFormProps { open: boolean; onOpenChange: (open: boolean) => void; title: string; onSave: () => void; onCancel: () => void; /** Disable Save and show a pending spinner. */ isPending?: boolean; /** Override the Save button label (default "Save"). */ saveLabel?: string; /** Disable the Save button (e.g. when required fields are empty). */ saveDisabled?: boolean; /** * When true, any close attempt (Cancel button, header X, overlay click, * Escape) prompts a discard-confirmation instead of immediately closing. * Spec R4.5. */ isDirty?: boolean; children: React.ReactNode; /** Optional className applied to the scrolling body. */ bodyClassName?: string; } /** * Full-height form host for the mobile (`< md`) breakpoint. * * Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a * fixed footer (Cancel + Save). The body scrolls between them. Laid out as a * flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms, * which break sticky positioning — see OpenSpec change * `mobile-responsive-parity`, design §`SheetForm` / risks. * * Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize * jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`. */ export function SheetForm({ open, onOpenChange, title, onSave, onCancel, isPending = false, saveDisabled = false, saveLabel = "Save", isDirty = false, children, bodyClassName, }: SheetFormProps) { const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); // Route every close path (Cancel, header X, Radix overlay/Escape) through one // guard so the dirty-confirm is applied uniformly (spec R4.5). const attemptClose = React.useCallback(() => { if (isDirty) { setConfirmDiscardOpen(true); } else { onCancel(); } }, [isDirty, onCancel]); const handleOpenChange = React.useCallback( (next: boolean) => { if (!next) { attemptClose(); } else { onOpenChange(next); } }, [attemptClose, onOpenChange], ); return ( { // Prevent Radix's default Escape close so our guard runs instead. if (isDirty) { e.preventDefault(); attemptClose(); } }} onPointerDownOutside={(e) => { // Prevent overlay-click close so our guard runs instead. if (isDirty) { e.preventDefault(); attemptClose(); } }} > {/* Header — fixed at top */}
{title}
{/* Body — scrolls */}
{children}
{/* Footer — fixed at bottom */}
setConfirmDiscardOpen(false)} onConfirm={() => { setConfirmDiscardOpen(false); onCancel(); }} />
); }