Openstatus www.openstatus.dev
at main 237 lines 5.6 kB view raw
1"use client"; 2 3import { 4 AlertDialog, 5 AlertDialogAction, 6 AlertDialogCancel, 7 AlertDialogContent, 8 AlertDialogDescription, 9 AlertDialogFooter, 10 AlertDialogHeader, 11 AlertDialogTitle, 12} from "@/components/ui/alert-dialog"; 13import { 14 Sheet, 15 SheetContent, 16 SheetDescription, 17 SheetFooter, 18 SheetHeader, 19 SheetTitle, 20 SheetTrigger, 21} from "@/components/ui/sheet"; 22import { cn } from "@/lib/utils"; 23import React, { 24 createContext, 25 useContext, 26 useEffect, 27 useRef, 28 useState, 29} from "react"; 30 31export function FormSheetContent({ 32 children, 33 className, 34 ...props 35}: React.ComponentProps<typeof SheetContent>) { 36 return ( 37 <SheetContent className={cn("max-h-screen gap-0", className)} {...props}> 38 {children} 39 </SheetContent> 40 ); 41} 42 43export function FormSheetHeader({ 44 children, 45 className, 46 ...props 47}: React.ComponentProps<typeof SheetHeader>) { 48 return ( 49 <SheetHeader 50 className={cn("sticky top-0 border-b bg-background", className)} 51 {...props} 52 > 53 {children} 54 </SheetHeader> 55 ); 56} 57 58export function FormSheetFooter({ 59 children, 60 className, 61 ...props 62}: React.ComponentProps<typeof SheetFooter>) { 63 return ( 64 <SheetFooter 65 className={cn("sticky bottom-0 border-t bg-background", className)} 66 {...props} 67 > 68 {children} 69 </SheetFooter> 70 ); 71} 72 73export function FormSheetFooterInfo({ 74 children, 75 className, 76 ...props 77}: React.ComponentProps<"p">) { 78 return ( 79 <p className={cn("text-muted-foreground/70 text-xs", className)} {...props}> 80 {children} 81 </p> 82 ); 83} 84 85export function FormSheetTrigger({ 86 children, 87 className, 88 disabled, 89 ...props 90}: React.ComponentProps<typeof SheetTrigger>) { 91 return ( 92 <SheetTrigger 93 className={cn( 94 "cursor-pointer data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50", 95 className, 96 )} 97 data-disabled={disabled} 98 disabled={disabled} 99 {...props} 100 > 101 {children} 102 </SheetTrigger> 103 ); 104} 105 106export function FormSheetAlertDialog({ 107 onConfirm, 108 ...props 109}: React.ComponentProps<typeof AlertDialog> & { 110 onConfirm: () => void; 111}) { 112 return ( 113 <AlertDialog {...props}> 114 <AlertDialogContent> 115 <AlertDialogHeader> 116 <AlertDialogTitle>Discard changes?</AlertDialogTitle> 117 <AlertDialogDescription> 118 You have unsaved changes. Are you sure you want to discard them? 119 </AlertDialogDescription> 120 </AlertDialogHeader> 121 <AlertDialogFooter> 122 <AlertDialogCancel>Continue editing</AlertDialogCancel> 123 <AlertDialogAction onClick={onConfirm}> 124 Discard changes 125 </AlertDialogAction> 126 </AlertDialogFooter> 127 </AlertDialogContent> 128 </AlertDialog> 129 ); 130} 131 132const FormSheetDirtyContext = createContext<{ 133 isDirty: boolean; 134 setIsDirty: (dirty: boolean) => void; 135} | null>(null); 136 137export function useFormSheetDirty() { 138 const context = useContext(FormSheetDirtyContext); 139 if (!context) { 140 throw new Error( 141 "useFormSheetDirty must be used within FormSheetWithDirtyProtection", 142 ); 143 } 144 return context; 145} 146 147export function FormSheetWithDirtyProtection({ 148 children, 149 open: controlledOpen, 150 onOpenChange: controlledOnOpenChange, 151}: { 152 children: React.ReactNode; 153 open?: boolean; 154 onOpenChange?: (open: boolean) => void; 155}) { 156 const [internalOpen, setInternalOpen] = useState(false); 157 const [isDirty, setIsDirty] = useState(false); 158 const [showAlert, setShowAlert] = useState(false); 159 const shouldBypassAlert = useRef(false); 160 161 const open = controlledOpen ?? internalOpen; 162 const setOpen = controlledOnOpenChange ?? setInternalOpen; 163 164 // Reset states when sheet closes 165 useEffect(() => { 166 if (!open) { 167 setIsDirty(false); 168 shouldBypassAlert.current = false; 169 } 170 }, [open]); 171 172 const handleOpenChange = (newOpen: boolean) => { 173 if (!newOpen && isDirty && !shouldBypassAlert.current) { 174 // User is trying to close with unsaved changes 175 setShowAlert(true); 176 } else { 177 setOpen(newOpen); 178 } 179 }; 180 181 const handleDiscardChanges = () => { 182 shouldBypassAlert.current = true; 183 setShowAlert(false); 184 setOpen(false); 185 }; 186 187 const handleInteractOutside = (e: Event) => { 188 if (isDirty) { 189 e.preventDefault(); 190 setShowAlert(true); 191 } 192 }; 193 194 const handleEscapeKeyDown = (e: KeyboardEvent) => { 195 if (isDirty) { 196 e.preventDefault(); 197 setShowAlert(true); 198 } 199 }; 200 201 return ( 202 <FormSheetDirtyContext.Provider value={{ isDirty, setIsDirty }}> 203 <Sheet open={open} onOpenChange={handleOpenChange}> 204 {/* Clone children and inject event handlers if it's SheetContent */} 205 {React.Children.map(children, (child) => { 206 if ( 207 React.isValidElement(child) && 208 (child.type === FormSheetContent || child.type === SheetContent) 209 ) { 210 return React.cloneElement( 211 child as React.ReactElement<{ 212 onInteractOutside?: (e: Event) => void; 213 onEscapeKeyDown?: (e: KeyboardEvent) => void; 214 }>, 215 { 216 onInteractOutside: handleInteractOutside, 217 onEscapeKeyDown: handleEscapeKeyDown, 218 }, 219 ); 220 } 221 return child; 222 })} 223 </Sheet> 224 <FormSheetAlertDialog 225 open={showAlert} 226 onOpenChange={setShowAlert} 227 onConfirm={handleDiscardChanges} 228 /> 229 </FormSheetDirtyContext.Provider> 230 ); 231} 232 233export { 234 SheetTitle as FormSheetTitle, 235 SheetDescription as FormSheetDescription, 236 Sheet as FormSheet, 237};