2025-08-04 17:45:44 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
|
|
|
import { X } from "lucide-react";
|
|
|
|
|
import {
|
2025-08-28 18:27:07 +02:00
|
|
|
type ComponentPropsWithoutRef,
|
|
|
|
|
createContext,
|
|
|
|
|
type Dispatch,
|
|
|
|
|
type ElementRef,
|
|
|
|
|
forwardRef,
|
|
|
|
|
type HTMLAttributes,
|
|
|
|
|
type PointerEvent,
|
|
|
|
|
type ReactNode,
|
|
|
|
|
type SetStateAction,
|
|
|
|
|
useContext,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
2025-08-04 17:45:44 +02:00
|
|
|
} from "react";
|
|
|
|
|
import { cn } from "~/lib/utils";
|
|
|
|
|
|
|
|
|
|
interface DialogContextValue {
|
2025-08-28 18:27:07 +02:00
|
|
|
innerOpen: boolean;
|
|
|
|
|
setInnerOpen: Dispatch<SetStateAction<boolean>>;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DialogContext = createContext<DialogContextValue | undefined>(undefined);
|
|
|
|
|
|
|
|
|
|
function Dialog({ children }: { children: ReactNode }) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const [outerOpen, setOuterOpen] = useState(false);
|
|
|
|
|
const [innerOpen, setInnerOpen] = useState(false);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
return (
|
|
|
|
|
<DialogContext.Provider value={{ innerOpen, setInnerOpen }}>
|
2025-08-29 16:18:32 +02:00
|
|
|
<DialogPrimitive.Root onOpenChange={setOuterOpen} open={outerOpen}>
|
2025-08-28 18:27:07 +02:00
|
|
|
{children}
|
|
|
|
|
</DialogPrimitive.Root>
|
|
|
|
|
</DialogContext.Provider>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DialogTrigger = DialogPrimitive.Trigger;
|
|
|
|
|
const DialogPortal = DialogPrimitive.Portal;
|
|
|
|
|
const DialogClose = DialogPrimitive.Close;
|
|
|
|
|
|
|
|
|
|
const DialogOverlay = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Overlay>,
|
|
|
|
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
2025-08-04 17:45:44 +02:00
|
|
|
>(({ className, ...props }, ref) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<DialogPrimitive.Overlay
|
|
|
|
|
className={cn(
|
|
|
|
|
"bg-background/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={ref}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
));
|
|
|
|
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
|
|
|
|
|
|
|
|
const DialogContent = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Content>,
|
|
|
|
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
2025-08-04 17:45:44 +02:00
|
|
|
>(({ className, children, ...props }, ref) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
const context = useContext(DialogContext);
|
|
|
|
|
if (!context) throw new Error("DialogContent must be used within a Dialog");
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
return (
|
|
|
|
|
<DialogPortal>
|
|
|
|
|
<DialogOverlay />
|
|
|
|
|
<DialogPrimitive.Content
|
|
|
|
|
className={cn(
|
|
|
|
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:rounded-lg",
|
|
|
|
|
context.innerOpen && "translate-y-[-55%] scale-[0.97]",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={ref}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
<DialogClose className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
|
|
|
|
<X className="size-4" />
|
|
|
|
|
<span className="sr-only">Close</span>
|
|
|
|
|
</DialogClose>
|
|
|
|
|
</DialogPrimitive.Content>
|
|
|
|
|
</DialogPortal>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
});
|
|
|
|
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
|
|
|
|
|
|
|
|
function InnerDialog({ children }: { children: ReactNode }) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const context = useContext(DialogContext);
|
|
|
|
|
if (!context) throw new Error("InnerDialog must be used within a Dialog");
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handleEscapeKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
if (event.key === "Escape" && context.innerOpen) {
|
|
|
|
|
context.setInnerOpen(false);
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
document.addEventListener("keydown", handleEscapeKeyDown);
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("keydown", handleEscapeKeyDown);
|
|
|
|
|
};
|
|
|
|
|
}, [context.innerOpen, context.setInnerOpen]);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
return (
|
|
|
|
|
<DialogPrimitive.Root
|
|
|
|
|
onOpenChange={context.setInnerOpen}
|
2025-08-29 16:18:32 +02:00
|
|
|
open={context.innerOpen}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</DialogPrimitive.Root>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const InnerDialogTrigger = DialogPrimitive.Trigger;
|
|
|
|
|
const InnerDialogClose = DialogPrimitive.Close;
|
|
|
|
|
|
|
|
|
|
interface InnerDialogContentProps
|
2025-08-28 18:27:07 +02:00
|
|
|
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
|
|
|
|
position?: "default" | "bottom" | "top" | "left" | "right";
|
|
|
|
|
draggable?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const InnerDialogContent = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Content>,
|
|
|
|
|
InnerDialogContentProps
|
2025-08-04 17:45:44 +02:00
|
|
|
>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(
|
|
|
|
|
{ className, children, position = "default", draggable = false, ...props },
|
|
|
|
|
ref,
|
|
|
|
|
) => {
|
|
|
|
|
const context = useContext(DialogContext);
|
|
|
|
|
if (!context)
|
|
|
|
|
throw new Error("InnerDialogContent must be used within a Dialog");
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
|
const [startY, setStartY] = useState(0);
|
|
|
|
|
const [currentY, setCurrentY] = useState(0);
|
|
|
|
|
const [isClosingByDrag, setIsClosingByDrag] = useState(false);
|
|
|
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (context.innerOpen) {
|
|
|
|
|
setCurrentY(0);
|
|
|
|
|
setIsClosingByDrag(false);
|
|
|
|
|
}
|
|
|
|
|
}, [context.innerOpen]);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
|
|
|
if (!draggable) return;
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
setStartY(e.clientY - currentY);
|
|
|
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
|
|
|
};
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
|
|
|
if (!isDragging || !draggable) return;
|
|
|
|
|
const newY = e.clientY - startY;
|
|
|
|
|
setCurrentY(newY > 0 ? newY : 0);
|
|
|
|
|
};
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const handlePointerUp = () => {
|
|
|
|
|
if (!draggable) return;
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
if (currentY > (contentRef.current?.offsetHeight || 0) / 2) {
|
|
|
|
|
setIsClosingByDrag(true);
|
|
|
|
|
context.setInnerOpen(false);
|
|
|
|
|
} else {
|
|
|
|
|
setCurrentY(0);
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
return (
|
|
|
|
|
<DialogPortal>
|
|
|
|
|
<DialogPrimitive.Content
|
|
|
|
|
className={cn(
|
|
|
|
|
"bg-background fixed top-[50%] left-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-45%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
|
|
|
|
isClosingByDrag
|
|
|
|
|
? "data-[state=closed]:fade-out-0 data-[state=closed]:animate-none"
|
|
|
|
|
: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
|
|
|
|
position === "default" &&
|
|
|
|
|
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
|
|
|
|
position === "bottom" &&
|
|
|
|
|
"data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-bottom-full",
|
|
|
|
|
position === "top" &&
|
|
|
|
|
"data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full",
|
|
|
|
|
position === "left" &&
|
|
|
|
|
"data-[state=closed]:slide-out-to-left-full data-[state=open]:slide-in-from-left-full",
|
|
|
|
|
position === "right" &&
|
|
|
|
|
"data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
|
|
|
|
|
draggable && "",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
onPointerDown={handlePointerDown}
|
|
|
|
|
onPointerMove={handlePointerMove}
|
|
|
|
|
onPointerUp={handlePointerUp}
|
|
|
|
|
ref={ref}
|
|
|
|
|
style={{
|
|
|
|
|
transform: `translate(-50%, calc(-50% + ${currentY}px))`,
|
|
|
|
|
transition: isDragging ? "none" : "transform 0.3s ease-out",
|
|
|
|
|
}}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
<div ref={contentRef}>{children}</div>
|
|
|
|
|
<InnerDialogClose className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
|
|
|
|
<X className="size-4" />
|
|
|
|
|
<span className="sr-only">Close</span>
|
|
|
|
|
</InnerDialogClose>
|
|
|
|
|
</DialogPrimitive.Content>
|
|
|
|
|
</DialogPortal>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
InnerDialogContent.displayName = "InnerDialogContent";
|
|
|
|
|
|
|
|
|
|
const InnerDialogHeader = ({
|
2025-08-28 18:27:07 +02:00
|
|
|
className,
|
|
|
|
|
...props
|
2025-08-04 17:45:44 +02:00
|
|
|
}: HTMLAttributes<HTMLDivElement>) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
InnerDialogHeader.displayName = "InnerDialogHeader";
|
|
|
|
|
|
|
|
|
|
const InnerDialogFooter = ({
|
2025-08-28 18:27:07 +02:00
|
|
|
className,
|
|
|
|
|
...props
|
2025-08-04 17:45:44 +02:00
|
|
|
}: HTMLAttributes<HTMLDivElement>) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<div
|
|
|
|
|
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
|
|
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
InnerDialogFooter.displayName = "InnerDialogFooter";
|
|
|
|
|
|
|
|
|
|
const InnerDialogTitle = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Title>,
|
|
|
|
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
2025-08-04 17:45:44 +02:00
|
|
|
>(({ className, ...props }, ref) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<DialogPrimitive.Title
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-lg leading-none font-semibold tracking-tight",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={ref}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
));
|
|
|
|
|
InnerDialogTitle.displayName = "InnerDialogTitle";
|
|
|
|
|
|
|
|
|
|
const InnerDialogDescription = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Description>,
|
|
|
|
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
2025-08-04 17:45:44 +02:00
|
|
|
>(({ className, ...props }, ref) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<DialogPrimitive.Description
|
|
|
|
|
className={cn("text-muted-foreground text-sm", className)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={ref}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
));
|
|
|
|
|
InnerDialogDescription.displayName = "InnerDialogDescription";
|
|
|
|
|
|
|
|
|
|
const DialogHeader = ({
|
2025-08-28 18:27:07 +02:00
|
|
|
className,
|
|
|
|
|
...props
|
2025-08-04 17:45:44 +02:00
|
|
|
}: HTMLAttributes<HTMLDivElement>) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
DialogHeader.displayName = "DialogHeader";
|
|
|
|
|
|
|
|
|
|
const DialogFooter = ({
|
2025-08-28 18:27:07 +02:00
|
|
|
className,
|
|
|
|
|
...props
|
2025-08-04 17:45:44 +02:00
|
|
|
}: HTMLAttributes<HTMLDivElement>) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<div
|
|
|
|
|
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
|
|
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
DialogFooter.displayName = "DialogFooter";
|
|
|
|
|
|
|
|
|
|
const DialogTitle = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Title>,
|
|
|
|
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
2025-08-04 17:45:44 +02:00
|
|
|
>(({ className, ...props }, ref) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<DialogPrimitive.Title
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-lg leading-none font-semibold tracking-tight",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={ref}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
));
|
|
|
|
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
|
|
|
|
|
|
|
|
const DialogDescription = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
ElementRef<typeof DialogPrimitive.Description>,
|
|
|
|
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
2025-08-04 17:45:44 +02:00
|
|
|
>(({ className, ...props }, ref) => (
|
2025-08-28 18:27:07 +02:00
|
|
|
<DialogPrimitive.Description
|
|
|
|
|
className={cn("text-muted-foreground text-sm", className)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={ref}
|
2025-08-28 18:27:07 +02:00
|
|
|
{...props}
|
|
|
|
|
/>
|
2025-08-04 17:45:44 +02:00
|
|
|
));
|
|
|
|
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
|
|
|
|
|
|
|
|
export type { InnerDialogContentProps };
|
|
|
|
|
export {
|
2025-08-28 18:27:07 +02:00
|
|
|
Dialog,
|
|
|
|
|
DialogTrigger,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogClose,
|
|
|
|
|
InnerDialog,
|
|
|
|
|
InnerDialogTrigger,
|
|
|
|
|
InnerDialogContent,
|
|
|
|
|
InnerDialogHeader,
|
|
|
|
|
InnerDialogFooter,
|
|
|
|
|
InnerDialogTitle,
|
|
|
|
|
InnerDialogDescription,
|
|
|
|
|
InnerDialogClose,
|
|
|
|
|
DialogPortal,
|
|
|
|
|
DialogOverlay,
|
2025-08-04 17:45:44 +02:00
|
|
|
};
|