infoalloggi-monorepo/apps/infoalloggi/src/components/custom_ui/nestableDialog.tsx

345 lines
11 KiB
TypeScript
Raw Normal View History

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 }}>
<DialogPrimitive.Root open={outerOpen} onOpenChange={setOuterOpen}>
{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
ref={ref}
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,
)}
{...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
ref={ref}
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,
)}
{...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
open={context.innerOpen}
onOpenChange={context.setInnerOpen}
>
{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
ref={ref}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
transform: `translate(-50%, calc(-50% + ${currentY}px))`,
transition: isDragging ? "none" : "transform 0.3s ease-out",
}}
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,
)}
{...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
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...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
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...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
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...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
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...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
};