Openstatus
www.openstatus.dev
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};