19import { createPortal } from 'react-dom';
20import { cn } from '@/lib/utils';
21// import useClickOutside from '@/hooks/useClickOutside';
22import { XIcon } from 'lucide-react';
24interface DialogContextType {
26 setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
28 triggerRef: React.RefObject<HTMLDivElement>;
31const DialogContext = React.createContext<DialogContextType | null>(null);
34 const context = useContext(DialogContext);
36 throw new Error('useDialog must be used within a DialogProvider');
41type DialogProviderProps = {
42 children: React.ReactNode;
46function DialogProvider({ children, transition }: DialogProviderProps) {
47 const [isOpen, setIsOpen] = useState(false);
48 const uniqueId = useId();
49 const triggerRef = useRef<HTMLDivElement>(null);
51 const contextValue = useMemo(
52 () => ({ isOpen, setIsOpen, uniqueId, triggerRef }),
57 <DialogContext.Provider value={contextValue}>
58 <MotionConfig transition={transition}>{children}</MotionConfig>
59 </DialogContext.Provider>
64 children: React.ReactNode;
68function Dialog({ children, transition }: DialogProps) {
71 <MotionConfig transition={transition}>{children}</MotionConfig>
76type DialogTriggerProps = {
77 children: React.ReactNode;
79 style?: React.CSSProperties;
80 triggerRef?: React.RefObject<HTMLDivElement>;
89 const { setIsOpen, isOpen, uniqueId } = useDialog();
91 const handleClick = useCallback(() => {
95 const handleKeyDown = useCallback(
96 (event: React.KeyboardEvent) => {
97 if (event.key === 'Enter' || event.key === ' ') {
108 layoutId={`dialog-${uniqueId}`}
109 className={cn('relative cursor-pointer', className)}
111 onKeyDown={handleKeyDown}
116 aria-controls={`dialog-content-${uniqueId}`}
124 children: React.ReactNode;
126 style?: React.CSSProperties;
129function DialogContent({ children, className, style }: DialogContent) {
130 const { setIsOpen, isOpen, uniqueId, triggerRef } = useDialog();
131 const containerRef = useRef<HTMLDivElement>(null);
132 const [firstFocusableElement, setFirstFocusableElement] =
133 useState<HTMLElement | null>(null);
134 const [lastFocusableElement, setLastFocusableElement] =
135 useState<HTMLElement | null>(null);
138 const handleKeyDown = (event: KeyboardEvent) => {
139 if (event.key === 'Escape') {
142 if (event.key === 'Tab') {
143 if (!firstFocusableElement || !lastFocusableElement) return;
146 if (document.activeElement === firstFocusableElement) {
148 lastFocusableElement.focus();
151 if (document.activeElement === lastFocusableElement) {
153 firstFocusableElement.focus();
159 document.addEventListener('keydown', handleKeyDown);
162 document.removeEventListener('keydown', handleKeyDown);
164 }, [setIsOpen, firstFocusableElement, lastFocusableElement]);
168 document.body.classList.add('overflow-hidden');
169 const focusableElements = containerRef.current?.querySelectorAll(
170 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
172 if (focusableElements && focusableElements.length > 0) {
173 setFirstFocusableElement(focusableElements[0] as HTMLElement);
175 focusableElements[focusableElements.length - 1] as HTMLElement
177 (focusableElements[0] as HTMLElement).focus();
179 // Scroll to the top when dialog opens
180 if (containerRef.current) {
181 containerRef.current.scrollTop = 0;
184 document.body.classList.remove('overflow-hidden');
185 triggerRef.current?.focus();
187 }, [isOpen, triggerRef]);
193 layoutId={`dialog-${uniqueId}`}
194 className={cn('overflow-hidden', className)}
198 aria-labelledby={`dialog-title-${uniqueId}`}
199 aria-describedby={`dialog-description-${uniqueId}`}
207type DialogContainerProps = {
208 children: React.ReactNode;
210 style?: React.CSSProperties;
213function DialogContainer({ children, className }: DialogContainerProps) {
214 const { isOpen, setIsOpen, uniqueId } = useDialog();
215 const [mounted, setMounted] = useState(false);
222 return () => setMounted(false);
225 if (!mounted) return null;
228 <AnimatePresence initial={false} mode='sync'>
232 key={`backdrop-${uniqueId}`}
233 className='fixed inset-0 h-full z-50 w-full bg-white/40 backdrop-blur-sm dark:bg-black/40 '
237 onClick={() => setIsOpen(false)}
239 <div className={cn(`fixed inset-0 z-50 w-fit mx-auto`, className)}>
250type DialogTitleProps = {
251 children: React.ReactNode;
253 style?: React.CSSProperties;
256function DialogTitle({ children, className, style }: DialogTitleProps) {
257 const { uniqueId } = useDialog();
261 layoutId={`dialog-title-container-${uniqueId}`}
271type DialogSubtitleProps = {
272 children: React.ReactNode;
274 style?: React.CSSProperties;
277function DialogSubtitle({ children, className, style }: DialogSubtitleProps) {
278 const { uniqueId } = useDialog();
282 layoutId={`dialog-subtitle-container-${uniqueId}`}
291type DialogDescriptionProps = {
292 children: React.ReactNode;
294 disableLayoutAnimation?: boolean;
302function DialogDescription({
307}: DialogDescriptionProps) {
308 const { uniqueId } = useDialog();
312 key={`dialog-description-${uniqueId}`}
316 : `dialog-description-content-${uniqueId}`
323 id={`dialog-description-${uniqueId}`}
330type DialogImageProps = {
334 style?: React.CSSProperties;
337function DialogImage({ src, alt, className, style }: DialogImageProps) {
338 const { uniqueId } = useDialog();
344 className={cn(className)}
345 layoutId={`dialog-img-${uniqueId}`}
351type DialogCloseProps = {
352 children?: React.ReactNode;
361function DialogClose({ children, className, variants }: DialogCloseProps) {
362 const { setIsOpen, uniqueId } = useDialog();
364 const handleClose = useCallback(() => {
372 aria-label='Close dialog'
373 key={`dialog-close-${uniqueId}`}
374 className={cn('absolute right-6 top-6', className)}
380 {children || <XIcon size={24} />}