"content": "'use client';\n\nimport React, {\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport {\n motion,\n AnimatePresence,\n MotionConfig,\n Transition,\n Variant,\n} from 'motion/react';\nimport { createPortal } from 'react-dom';\nimport { cn } from '@/lib/utils';\nimport { XIcon } from 'lucide-react';\nimport useClickOutside from '@/hooks/useClickOutside';\n\nexport type MorphingDialogContextType = {\n isOpen: boolean;\n setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;\n uniqueId: string;\n triggerRef: React.RefObject<HTMLDivElement>;\n};\n\nconst MorphingDialogContext =\n React.createContext<MorphingDialogContextType | null>(null);\n\nfunction useMorphingDialog() {\n const context = useContext(MorphingDialogContext);\n if (!context) {\n throw new Error(\n 'useMorphingDialog must be used within a MorphingDialogProvider'\n );\n }\n return context;\n}\n\nexport type MorphingDialogProviderProps = {\n children: React.ReactNode;\n transition?: Transition;\n};\n\nfunction MorphingDialogProvider({\n children,\n transition,\n}: MorphingDialogProviderProps) {\n const [isOpen, setIsOpen] = useState(false);\n const uniqueId = useId();\n const triggerRef = useRef<HTMLDivElement>(null!);\n\n const contextValue = useMemo(\n () => ({\n isOpen,\n setIsOpen,\n uniqueId,\n triggerRef,\n }),\n [isOpen, uniqueId]\n );\n\n return (\n <MorphingDialogContext.Provider value={contextValue}>\n <MotionConfig transition={transition}>{children}</MotionConfig>\n </MorphingDialogContext.Provider>\n );\n}\n\nexport type MorphingDialogProps = {\n children: React.ReactNode;\n transition?: Transition;\n};\n\nfunction MorphingDialog({ children, transition }: MorphingDialogProps) {\n return (\n <MorphingDialogProvider>\n <MotionConfig transition={transition}>{children}</MotionConfig>\n </MorphingDialogProvider>\n );\n}\n\nexport type MorphingDialogTriggerProps = {\n children: React.ReactNode;\n className?: string;\n style?: React.CSSProperties;\n triggerRef?: React.RefObject<HTMLDivElement>;\n};\n\nfunction MorphingDialogTrigger({\n children,\n className,\n style,\n triggerRef,\n}: MorphingDialogTriggerProps) {\n const { setIsOpen, isOpen, uniqueId } = useMorphingDialog();\n\n const handleClick = useCallback(() => {\n setIsOpen(!isOpen);\n }, [isOpen, setIsOpen]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n setIsOpen(!isOpen);\n }\n },\n [isOpen, setIsOpen]\n );\n\n return (\n <motion.div\n ref={triggerRef}\n layoutId={`dialog-${uniqueId}`}\n className={cn('relative cursor-pointer', className)}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n style={style}\n role='button'\n aria-haspopup='dialog'\n aria-expanded={isOpen}\n aria-controls={`motion-ui-morphing-dialog-content-${uniqueId}`}\n aria-label={`Open dialog ${uniqueId}`}\n >\n {children}\n </motion.div>\n );\n}\n\nexport type MorphingDialogContentProps = {\n children: React.ReactNode;\n className?: string;\n style?: React.CSSProperties;\n};\n\nfunction MorphingDialogContent({\n children,\n className,\n style,\n}: MorphingDialogContentProps) {\n const { setIsOpen, isOpen, uniqueId, triggerRef } = useMorphingDialog();\n const containerRef = useRef<HTMLDivElement>(null!);\n const [firstFocusableElement, setFirstFocusableElement] =\n useState<HTMLElement | null>(null);\n const [lastFocusableElement, setLastFocusableElement] =\n useState<HTMLElement | null>(null);\n\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n setIsOpen(false);\n }\n if (event.key === 'Tab') {\n if (!firstFocusableElement || !lastFocusableElement) return;\n\n if (event.shiftKey) {\n if (document.activeElement === firstFocusableElement) {\n event.preventDefault();\n lastFocusableElement.focus();\n }\n } else {\n if (document.activeElement === lastFocusableElement) {\n event.preventDefault();\n firstFocusableElement.focus();\n }\n }\n }\n };\n\n document.addEventListener('keydown', handleKeyDown);\n\n return () => {\n document.removeEventListener('keydown', handleKeyDown);\n };\n }, [setIsOpen, firstFocusableElement, lastFocusableElement]);\n\n useEffect(() => {\n if (isOpen) {\n document.body.classList.add('overflow-hidden');\n const focusableElements = containerRef.current?.querySelectorAll(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n );\n if (focusableElements && focusableElements.length > 0) {\n setFirstFocusableElement(focusableElements[0] as HTMLElement);\n setLastFocusableElement(\n focusableElements[focusableElements.length - 1] as HTMLElement\n );\n (focusableElements[0] as HTMLElement).focus();\n }\n } else {\n document.body.classList.remove('overflow-hidden');\n triggerRef.current?.focus();\n }\n }, [isOpen, triggerRef]);\n\n useClickOutside(containerRef, () => {\n if (isOpen) {\n setIsOpen(false);\n }\n });\n\n return (\n <motion.div\n ref={containerRef}\n layoutId={`dialog-${uniqueId}`}\n className={cn('overflow-hidden', className)}\n style={style}\n role='dialog'\n aria-modal='true'\n aria-labelledby={`motion-ui-morphing-dialog-title-${uniqueId}`}\n aria-describedby={`motion-ui-morphing-dialog-description-${uniqueId}`}\n >\n {children}\n </motion.div>\n );\n}\n\nexport type MorphingDialogContainerProps = {\n children: React.ReactNode;\n className?: string;\n style?: React.CSSProperties;\n};\n\nfunction MorphingDialogContainer({ children }: MorphingDialogContainerProps) {\n const { isOpen, uniqueId } = useMorphingDialog();\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n setMounted(true);\n return () => setMounted(false);\n }, []);\n\n if (!mounted) return null;\n\n return createPortal(\n <AnimatePresence initial={false} mode='sync'>\n {isOpen && (\n <>\n <motion.div\n key={`backdrop-${uniqueId}`}\n className='fixed inset-0 h-full w-full bg-white/40 backdrop-blur-xs dark:bg-black/40'\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n />\n <div className='fixed inset-0 z-50 flex items-center justify-center'>\n {children}\n </div>\n </>\n )}\n </AnimatePresence>,\n document.body\n );\n}\n\nexport type MorphingDialogTitleProps = {\n children: React.ReactNode;\n className?: string;\n style?: React.CSSProperties;\n};\n\nfunction MorphingDialogTitle({\n children,\n className,\n style,\n}: MorphingDialogTitleProps) {\n const { uniqueId } = useMorphingDialog();\n\n return (\n <motion.div\n layoutId={`dialog-title-container-${uniqueId}`}\n className={className}\n style={style}\n layout\n >\n {children}\n </motion.div>\n );\n}\n\nexport type MorphingDialogSubtitleProps = {\n children: React.ReactNode;\n className?: string;\n style?: React.CSSProperties;\n};\n\nfunction MorphingDialogSubtitle({\n children,\n className,\n style,\n}: MorphingDialogSubtitleProps) {\n const { uniqueId } = useMorphingDialog();\n\n return (\n <motion.div\n layoutId={`dialog-subtitle-container-${uniqueId}`}\n className={className}\n style={style}\n >\n {children}\n </motion.div>\n );\n}\n\nexport type MorphingDialogDescriptionProps = {\n children: React.ReactNode;\n className?: string;\n disableLayoutAnimation?: boolean;\n variants?: {\n initial: Variant;\n animate: Variant;\n exit: Variant;\n };\n};\n\nfunction MorphingDialogDescription({\n children,\n className,\n variants,\n disableLayoutAnimation,\n}: MorphingDialogDescriptionProps) {\n const { uniqueId } = useMorphingDialog();\n\n return (\n <motion.div\n key={`dialog-description-${uniqueId}`}\n layoutId={\n disableLayoutAnimation\n ? undefined\n : `dialog-description-content-${uniqueId}`\n }\n variants={variants}\n className={className}\n initial='initial'\n animate='animate'\n exit='exit'\n id={`dialog-description-${uniqueId}`}\n >\n {children}\n </motion.div>\n );\n}\n\nexport type MorphingDialogImageProps = {\n src: string;\n alt: string;\n className?: string;\n style?: React.CSSProperties;\n};\n\nfunction MorphingDialogImage({\n src,\n alt,\n className,\n style,\n}: MorphingDialogImageProps) {\n const { uniqueId } = useMorphingDialog();\n\n return (\n <motion.img\n src={src}\n alt={alt}\n className={cn(className)}\n layoutId={`dialog-img-${uniqueId}`}\n style={style}\n />\n );\n}\n\nexport type MorphingDialogCloseProps = {\n children?: React.ReactNode;\n className?: string;\n variants?: {\n initial: Variant;\n animate: Variant;\n exit: Variant;\n };\n};\n\nfunction MorphingDialogClose({\n children,\n className,\n variants,\n}: MorphingDialogCloseProps) {\n const { setIsOpen, uniqueId } = useMorphingDialog();\n\n const handleClose = useCallback(() => {\n setIsOpen(false);\n }, [setIsOpen]);\n\n return (\n <motion.button\n onClick={handleClose}\n type='button'\n aria-label='Close dialog'\n key={`dialog-close-${uniqueId}`}\n className={cn('absolute right-6 top-6', className)}\n initial='initial'\n animate='animate'\n exit='exit'\n variants={variants}\n >\n {children || <XIcon size={24} />}\n </motion.button>\n );\n}\n\nexport {\n MorphingDialog,\n MorphingDialogTrigger,\n MorphingDialogContainer,\n MorphingDialogContent,\n MorphingDialogClose,\n MorphingDialogTitle,\n MorphingDialogSubtitle,\n MorphingDialogDescription,\n MorphingDialogImage,\n};\n",
0 commit comments