' use client '
import React, {
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from ' react '
import {
motion,
AnimatePresence,
MotionConfig,
Transition,
Variant,
} from ' framer-motion '
import { createPortal } from ' react-dom '
import { cn } from ' @/lib/utils '
// import useClickOutside from '@/hooks/useClickOutside';
import { XIcon } from ' lucide-react '
interface DialogContextType {
isOpen : boolean
setIsOpen : React . Dispatch < React . SetStateAction < boolean >>
uniqueId : string
triggerRef : React . RefObject < HTMLDivElement >
}
const DialogContext = React . createContext < DialogContextType | null > ( null )
function useDialog () {
const context = useContext ( DialogContext )
if ( ! context) {
throw new Error ( ' useDialog must be used within a DialogProvider ' )
}
return context
}
type DialogProviderProps = {
children : React . ReactNode
transition ?: Transition
}
function DialogProvider ( { children , transition } : DialogProviderProps ) {
const [ isOpen , setIsOpen ] = useState ( false )
const uniqueId = useId ()
const triggerRef = useRef < HTMLDivElement > ( null )
const contextValue = useMemo (
() => ( { isOpen , setIsOpen , uniqueId , triggerRef } ) ,
[ isOpen , uniqueId ]
)
return (
< DialogContext.Provider value = { contextValue } >
< MotionConfig transition = { transition } > { children } </ MotionConfig >
</ DialogContext.Provider >
)
}
type DialogProps = {
children : React . ReactNode
transition ?: Transition
}
function Dialog ( { children , transition } : DialogProps ) {
return (
< DialogProvider >
< MotionConfig transition = { transition } > { children } </ MotionConfig >
</ DialogProvider >
)
}
type DialogTriggerProps = {
children : React . ReactNode
className ?: string
style ?: React . CSSProperties
triggerRef ?: React . RefObject < HTMLDivElement >
}
function DialogTrigger ( {
children ,
className ,
style ,
triggerRef ,
} : DialogTriggerProps ) {
const { setIsOpen , isOpen , uniqueId } = useDialog ()
const handleClick = useCallback ( () => {
setIsOpen ( ! isOpen )
} , [ isOpen , setIsOpen ])
const handleKeyDown = useCallback (
( event : React . KeyboardEvent ) => {
if ( event . key === ' Enter ' || event . key === ' ' ) {
event . preventDefault ()
setIsOpen ( ! isOpen )
}
} ,
[ isOpen , setIsOpen ]
)
return (
< motion.div
ref = { triggerRef }
layoutId = { ` dialog- ${ uniqueId } ` }
className = { cn ( ' relative cursor-pointer ' , className) }
onClick = { handleClick }
onKeyDown = { handleKeyDown }
style = { style }
role = " button "
aria-haspopup = " dialog "
aria-expanded = { isOpen }
aria-controls = { ` dialog-content- ${ uniqueId } ` }
>
{ children }
</ motion.div >
)
}
type DialogContent = {
children : React . ReactNode
className ?: string
style ?: React . CSSProperties
}
function DialogContent ( { children , className , style } : DialogContent ) {
const { setIsOpen , isOpen , uniqueId , triggerRef } = useDialog ()
const containerRef = useRef < HTMLDivElement > ( null )
const [ firstFocusableElement , setFirstFocusableElement ] =
useState < HTMLElement | null > ( null )
const [ lastFocusableElement , setLastFocusableElement ] =
useState < HTMLElement | null > ( null )
useEffect ( () => {
const handleKeyDown = ( event : KeyboardEvent ) => {
if ( event . key === ' Escape ' ) {
setIsOpen ( false )
}
if ( event . key === ' Tab ' ) {
if ( ! firstFocusableElement || ! lastFocusableElement ) return
if ( event . shiftKey ) {
if ( document . activeElement === firstFocusableElement ) {
event . preventDefault ()
lastFocusableElement . focus ()
}
} else {
if ( document . activeElement === lastFocusableElement ) {
event . preventDefault ()
firstFocusableElement . focus ()
}
}
}
}
document . addEventListener ( ' keydown ' , handleKeyDown)
return () => {
document . removeEventListener ( ' keydown ' , handleKeyDown)
}
} , [setIsOpen , firstFocusableElement , lastFocusableElement])
useEffect ( () => {
if (isOpen) {
document . body . classList . add ( ' overflow-hidden ' )
const focusableElements = containerRef . current ?. querySelectorAll (
' button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]) '
)
if (focusableElements && focusableElements . length > 0 ) {
setFirstFocusableElement (focusableElements[ 0 ] as HTMLElement )
setLastFocusableElement (
focusableElements[focusableElements . length - 1 ] as HTMLElement
)
;(focusableElements[ 0 ] as HTMLElement ) . focus ()
}
// Scroll to the top when dialog opens
if (containerRef . current ) {
containerRef . current . scrollTop = 0
}
} else {
document . body . classList . remove ( ' overflow-hidden ' )
triggerRef . current ?. focus ()
}
} , [isOpen , triggerRef])
return (
<>
< motion.div
ref = { containerRef }
layoutId = { ` dialog- ${ uniqueId } ` }
className = { cn ( ' overflow-hidden ' , className) }
style = { style }
role = " dialog "
aria-modal = " true "
aria-labelledby = { ` dialog-title- ${ uniqueId } ` }
aria-describedby = { ` dialog-description- ${ uniqueId } ` }
>
{ children }
</ motion.div >
</>
)
}
type DialogContainerProps = {
children : React . ReactNode
className ?: string
style ?: React . CSSProperties
}
function DialogContainer ( { children , className } : DialogContainerProps ) {
const { isOpen , setIsOpen , uniqueId } = useDialog ()
const [ mounted , setMounted ] = useState ( false )
useEffect ( () => {
if (isOpen) {
window . scrollTo ( 0 , 0 )
}
setMounted ( true )
return () => setMounted ( false )
} , [])
if ( ! mounted) return null
return createPortal (
< AnimatePresence initial = { false } mode = " sync " >
{ isOpen && (
<>
< motion.div
key = { ` backdrop- ${ uniqueId } ` }
className = " fixed inset-0 h-full w-full bg-white/40 backdrop-blur-sm dark:bg-black/40 "
initial = { { opacity: 0 } }
animate = { { opacity: 1 } }
exit = { { opacity: 0 } }
onClick = { () => setIsOpen ( false ) }
/>
< div className = { cn ( ` fixed inset-0 z-50 w-fit mx-auto ` , className) } >
{ children }
</ div >
</>
) }
</ AnimatePresence > ,
document . body
)
}
type DialogTitleProps = {
children : React . ReactNode
className ?: string
style ?: React . CSSProperties
}
function DialogTitle ( { children , className , style } : DialogTitleProps ) {
const { uniqueId } = useDialog ()
return (
< motion.div
layoutId = { ` dialog-title-container- ${ uniqueId } ` }
className = { className }
style = { style }
layout
>
{ children }
</ motion.div >
)
}
type DialogSubtitleProps = {
children : React . ReactNode
className ?: string
style ?: React . CSSProperties
}
function DialogSubtitle ( { children , className , style } : DialogSubtitleProps ) {
const { uniqueId } = useDialog ()
return (
< motion.div
layoutId = { ` dialog-subtitle-container- ${ uniqueId } ` }
className = { className }
style = { style }
>
{ children }
</ motion.div >
)
}
type DialogDescriptionProps = {
children : React . ReactNode
className ?: string
disableLayoutAnimation ?: boolean
variants ?: {
initial : Variant
animate : Variant
exit : Variant
}
}
function DialogDescription ( {
children ,
className ,
variants ,
disableLayoutAnimation ,
} : DialogDescriptionProps ) {
const { uniqueId } = useDialog ()
return (
< motion.div
key = { ` dialog-description- ${ uniqueId } ` }
layoutId = {
disableLayoutAnimation
? undefined
: ` dialog-description-content- ${ uniqueId } `
}
variants = { variants }
className = { className }
initial = " initial "
animate = " animate "
exit = " exit "
id = { ` dialog-description- ${ uniqueId } ` }
>
{ children }
</ motion.div >
)
}
type DialogImageProps = {
src : string
alt : string
className ?: string
style ?: React . CSSProperties
}
function DialogImage ( { src , alt , className , style } : DialogImageProps ) {
const { uniqueId } = useDialog ()
return (
< motion.img
src = { src }
alt = { alt }
className = { cn (className) }
layoutId = { ` dialog-img- ${ uniqueId } ` }
style = { style }
/>
)
}
type DialogCloseProps = {
children ?: React . ReactNode
className ?: string
variants ?: {
initial : Variant
animate : Variant
exit : Variant
}
}
function DialogClose ( { children , className , variants } : DialogCloseProps ) {
const { setIsOpen , uniqueId } = useDialog ()
const handleClose = useCallback ( () => {
setIsOpen ( false )
} , [ setIsOpen ])
return (
< motion.button
onClick = { handleClose }
type = " button "
aria-label = " Close dialog "
key = { ` dialog-close- ${ uniqueId } ` }
className = { cn ( ' absolute right-6 top-6 ' , className) }
initial = " initial "
animate = " animate "
exit = " exit "
variants = { variants }
>
{ children || < XIcon size = { 24 } /> }
</ motion.button >
)
}
export {
Dialog,
DialogTrigger,
DialogContainer,
DialogContent,
DialogClose,
DialogTitle,
DialogSubtitle,
DialogDescription,
DialogImage,
}
Copy