275 lines
6.4 KiB
TypeScript
275 lines
6.4 KiB
TypeScript
import React, { useState, useCallback, useContext, createContext } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface DialogContextType {
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
}
|
|
|
|
const DialogContext = createContext<DialogContextType | undefined>(undefined);
|
|
|
|
function useDialog() {
|
|
const context = useContext(DialogContext);
|
|
if (!context) {
|
|
throw new Error('Dialog components must be used within a Dialog');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
interface DialogProps {
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const isControlled = controlledOpen !== undefined;
|
|
const open = isControlled ? controlledOpen : internalOpen;
|
|
|
|
const setOpen = useCallback(
|
|
(newOpen: boolean) => {
|
|
if (!isControlled) {
|
|
setInternalOpen(newOpen);
|
|
}
|
|
onOpenChange?.(newOpen);
|
|
},
|
|
[isControlled, onOpenChange]
|
|
);
|
|
|
|
return (
|
|
<DialogContext.Provider value={{ open, setOpen }}>
|
|
{children}
|
|
</DialogContext.Provider>
|
|
);
|
|
}
|
|
|
|
function DialogTrigger({
|
|
children,
|
|
asChild,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
|
const { setOpen } = useDialog();
|
|
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(children as React.ReactElement, {
|
|
...props,
|
|
onClick: (e: React.MouseEvent) => {
|
|
setOpen(true);
|
|
children.props.onClick?.(e);
|
|
},
|
|
});
|
|
}
|
|
|
|
return (
|
|
<button
|
|
{...props}
|
|
onClick={(e) => {
|
|
setOpen(true);
|
|
props.onClick?.(e);
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function DialogPortal({ children }: { children: React.ReactNode }) {
|
|
const [mounted, setMounted] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(children, document.body);
|
|
}
|
|
|
|
function DialogClose({
|
|
children,
|
|
asChild,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
|
const { setOpen } = useDialog();
|
|
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(children as React.ReactElement, {
|
|
...props,
|
|
onClick: (e: React.MouseEvent) => {
|
|
setOpen(false);
|
|
children.props.onClick?.(e);
|
|
},
|
|
});
|
|
}
|
|
|
|
return (
|
|
<button
|
|
{...props}
|
|
onClick={(e) => {
|
|
setOpen(false);
|
|
props.onClick?.(e);
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
|
|
|
|
function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) {
|
|
const { setOpen } = useDialog();
|
|
|
|
return (
|
|
<motion.div
|
|
data-slot="dialog-overlay"
|
|
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={(e) => {
|
|
setOpen(false);
|
|
onClick?.(e as any);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
showCloseButton?: boolean;
|
|
}
|
|
|
|
function DialogContent({
|
|
className,
|
|
children,
|
|
showCloseButton = true,
|
|
...props
|
|
}: DialogContentProps) {
|
|
const { open } = useDialog();
|
|
|
|
return (
|
|
<DialogPortal>
|
|
<AnimatePresence>
|
|
{open && (
|
|
<>
|
|
<DialogOverlay />
|
|
<motion.div
|
|
data-slot="dialog-content"
|
|
className={cn(
|
|
'bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border p-6 shadow-lg',
|
|
className
|
|
)}
|
|
initial={{ opacity: 0, scale: 0.95, y: 0 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 0 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
{...props}
|
|
>
|
|
{children}
|
|
{showCloseButton && (
|
|
<DialogClose
|
|
data-slot="dialog-close"
|
|
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
aria-label="Close"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="size-4"
|
|
>
|
|
<path d="M18 6l-12 12M6 6l12 12" />
|
|
</svg>
|
|
</DialogClose>
|
|
)}
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</DialogPortal>
|
|
);
|
|
}
|
|
|
|
function DialogHeader({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
return (
|
|
<div
|
|
data-slot="dialog-header"
|
|
className={cn(
|
|
'flex flex-col gap-2 text-center sm:text-left',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DialogFooter({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
return (
|
|
<div
|
|
data-slot="dialog-footer"
|
|
className={cn(
|
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DialogTitle({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
|
return (
|
|
<h2
|
|
data-slot="dialog-title"
|
|
className={cn('text-lg leading-none font-semibold', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DialogDescription({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
return (
|
|
<p
|
|
data-slot="dialog-description"
|
|
className={cn('text-muted-foreground text-sm', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogOverlay,
|
|
DialogPortal,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
AnimatePresence,
|
|
};
|