Dialog

Example

Simple Modal Dialog Example

Confirmation Dialog Example

Code

import cn from '@/utils/cn'
import { applyPropsToChildrenOfType } from '@/utils/manipulateReactComponents'
import { cva, type VariantProps } from 'class-variance-authority'
import React, { forwardRef, useId, type CSSProperties } from 'react'
import { HiOutlineXMark } from 'react-icons/hi2'
import { DialogTrigger as BaseDialogTrigger, DialogState, DialogTriggerProps } from './client'

export type DialogProps = VariantProps<typeof dialog> & {
  hideCloseButton?: boolean
  customState?: boolean
  blockBodyScroll?: boolean
  closeOnBackdrop?: boolean
  wrapperClass?: string
  className?: string
  closeOnEscape?: boolean
  closeButton?: React.ReactNode
  transitionDuration?: number
  children: React.ReactNode
  backdrop?: boolean
  defaultOpen?: boolean
  id: string
  style?: CSSProperties
  ariaLabel?: string
}

const dialog = cva(
  [
    'group modal isolate fixed bg-transparent z-[999] p-0 outline-none invisible scale-0 opacity-0 w-fit h-fit max-w-[100vw] max-h-dvh',
    'peer-checked:scale-100 peer-checked:opacity-100 peer-checked:visible'
  ],
  {
    variants: {
      noInTransition: {
        true: 'peer-checked:!duration-0'
      },
      noOutTransition: {
        true: '[.peer:not(:checked)_~_&]:!duration-0'
      },
      alignX: {
        center: 'mx-auto left-0 right-0',
        start: 'mr-auto ml-0 left-0',
        end: 'ml-auto mr-0 right-0'
      },
      alignY: {
        center: 'my-auto top-0 bottom-0',
        start: 'mb-auto mt-0 top-0',
        end: 'mt-auto mb-0 bottom-0'
      },
      animationStyle: {
        scaleFade: 'scale-95 opacity-0 transition-all duration-[var(--animation-duration)] ease-in-out',
        scale: 'scale-95 transition-all duration-[var(--animation-duration)] ease-in-out',
        fade: 'opacity-0 transition-all duration-[var(--animation-duration)] ease-in-out',
        none: ''
      }
    },
    compoundVariants: [
      { alignX: 'center', alignY: 'center', class: 'origin-center' },
      { alignX: 'start', alignY: 'start', class: 'origin-top-left' },
      { alignX: 'start', alignY: 'center', class: 'origin-left' },
      { alignX: 'start', alignY: 'end', class: 'origin-bottom-left' },
      { alignX: 'center', alignY: 'start', class: 'origin-top' },
      { alignX: 'center', alignY: 'end', class: 'origin-bottom' },
      { alignX: 'end', alignY: 'start', class: 'origin-top-right' },
      { alignX: 'end', alignY: 'center', class: 'origin-right' },
      { alignX: 'end', alignY: 'end', class: 'origin-bottom-right' }
    ],
    defaultVariants: {
      noInTransition: false,
      noOutTransition: false,
      alignX: 'center',
      alignY: 'center',
      animationStyle: 'scaleFade'
    }
  }
)

const Dialog: React.FC<DialogProps> = ({ wrapperClass, customState = false, className, style, children, ...props }) => {
  const fallbackId = useId()
  const { noInTransition = false, noOutTransition = false, transitionDuration = 300, closeOnBackdrop = true, backdrop = true } = props
  const id = props.id ?? fallbackId
  const childrenWithExtraProps = applyPropsToChildrenOfType(children, { ...props, id, className }, [DialogContent])
  return (
    <div
      className="relative z-[999]"
      style={
        {
          '--animation-duration': `${transitionDuration}ms`,
          ...style
        } as React.CSSProperties
      }>
      {!customState ? <DialogState {...props} /> : null}
      {childrenWithExtraProps}
      {closeOnBackdrop || backdrop ? <DialogBackdrop {...{ id, closeOnBackdrop, noInTransition, noOutTransition }} /> : null}
    </div>
  )
}
Dialog.displayName = 'Dialog'

const DialogContent: React.FC<
  Omit<DialogProps, 'id'> & {
    id?: string
  }
> = ({ closeOnBackdrop, backdrop, noInTransition, noOutTransition, alignX, alignY, children, animationStyle, wrapperClass, className, ariaLabel }) => {
  return (
    <div
      role="dialog"
      aria-label={ariaLabel}
      aria-modal={closeOnBackdrop || backdrop}
      className={cn(
        dialog({
          noInTransition,
          noOutTransition,
          alignX,
          alignY,
          animationStyle
        }),
        wrapperClass
      )}>
      <div
        className={cn(
          'dialog-content relative z-[1] m-4 h-fit max-h-dvh w-fit max-w-[100dvw] overflow-y-auto rounded-lg p-4 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white',
          className
        )}>
        {children}
      </div>
    </div>
  )
}

DialogContent.displayName = 'DialogContent'

const DialogBackdrop: React.FC<{
  closeOnBackdrop?: DialogProps['closeOnBackdrop']
  noInTransition: DialogProps['noInTransition']
  noOutTransition: DialogProps['noOutTransition']
  className?: string
  id: string
}> = ({ id, closeOnBackdrop, className, noInTransition, noOutTransition }) => {
  const Component = closeOnBackdrop ? 'label' : 'div'
  const componentProps: Record<string, string> = {
    className: cn(
      'modal-backdrop fixed inset-0 min-h-[100vh] z-[0] min-w-[100vw] overflow-y-hidden text-transparent outline-none',
      'peer-checked:visible peer-checked:opacity-100 bg-black/50 opacity-0 invisible transition-all duration-[--animation-duration] ease-in-out',
      closeOnBackdrop && 'cursor-pointer',
      noInTransition && 'peer-checked:duration-0',
      noOutTransition && '[.peer:not(:checked)_~_&]:duration-0',
      className
    )
  }
  if (closeOnBackdrop) componentProps.htmlFor = id
  return React.createElement(Component, componentProps, null)
}
DialogBackdrop.displayName = 'DialogBackdrop'

const DialogClose = forwardRef<
  HTMLLabelElement,
  React.ComponentPropsWithRef<'label'> & {
    id: string
    asChild?: React.ElementType
  }
>(({ asChild: Component = 'label', id, children, ...props }, ref) => {
  return (
    <Component ref={ref} htmlFor={id} aria-label="Close" {...props}>
      {children}
    </Component>
  )
})

DialogClose.displayName = 'DialogClose'

const DialogXClose = React.forwardRef<HTMLLabelElement, React.ComponentPropsWithRef<typeof DialogClose> & { children?: React.ReactNode }>(
  ({ className, children = <HiOutlineXMark className="h-6 w-6" />, ...props }, ref) => (
    <DialogClose
      ref={ref}
      className={cn(
        'absolute right-2 top-2 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full !outline-none !ring-0 transition-colors duration-300 ease-in-out dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700',
        className
      )}
      {...props}>
      {children}
    </DialogClose>
  )
)

DialogXClose.displayName = 'DialogXClose'

const DialogTrigger = React.forwardRef<HTMLLabelElement, DialogTriggerProps>(({ className, ...props }, ref) => (
  <BaseDialogTrigger ref={ref} className={cn('inline-flex cursor-pointer rounded-md border border-zinc-700 bg-zinc-800 px-5 py-1.5 !outline-none !ring-0', className)} {...props} />
))

DialogTrigger.displayName = 'DialogTrigger'

export { Dialog, DialogBackdrop, DialogClose, DialogContent, DialogState, DialogTrigger, DialogXClose }
'use client'

import React, { forwardRef, useEffect, useRef, useState } from 'react'
interface DialogStateProps {
  id: string
  isOpen?: boolean
  defaultOpen?: boolean
  blockBodyScroll?: boolean
  onClose?: () => void
  closeOnEscape?: boolean
  onEscPress?: () => void
  ariaLabel?: string
}

const DialogState = forwardRef<HTMLInputElement, DialogStateProps>(
  ({ id, isOpen, defaultOpen = false, blockBodyScroll = false, onClose, closeOnEscape = true, onEscPress = () => {}, ariaLabel }, ref) => {
    const [checked, setChecked] = useState<boolean>(isOpen ?? defaultOpen)

    useEffect(() => {
      if (isOpen !== undefined) {
        setChecked(isOpen)
      }
    }, [isOpen])

    useEffect(() => {
      if (checked && blockBodyScroll) {
        document.body.style.setProperty('overflow', 'hidden')
      } else {
        document.body.style.removeProperty('overflow')
      }
    }, [checked, blockBodyScroll])

    useEffect(() => {
      const handleKeyPress = (e: KeyboardEvent) => {
        if (e.key === 'Escape' && closeOnEscape) {
          if (checked) {
            setChecked(false)
            onEscPress()
            onClose && onClose()
          }
        }
      }

      document.addEventListener('keydown', handleKeyPress)

      return () => {
        document.removeEventListener('keydown', handleKeyPress)
      }
    }, [closeOnEscape, onClose, checked, onEscPress])

    const handleChange = (e: any) => {
      const newChecked = e.target.checked
      setChecked(e.target.checked)
      if (!newChecked) onClose && onClose()
    }

    return <input ref={ref} type="checkbox" aria-label={`Toggle ${ariaLabel}`} id={id} checked={checked} onChange={handleChange} className="peer hidden" />
  }
)

DialogState.displayName = 'DialogState'

export type DialogTriggerProps = {
  children: React.ReactNode
  id?: string
  className?: string
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
}

const DialogTrigger = React.forwardRef<HTMLLabelElement, DialogTriggerProps>(({ children, id, className, onClick }, ref) => {
  return (
    <label
      ref={ref}
      htmlFor={id}
      aria-haspopup="dialog"
      onClick={(e: any) => {
        onClick && onClick(e)
      }}
      className={className}>
      {children}
    </label>
  )
})

DialogTrigger.displayName = 'DialogTrigger'

export { DialogTrigger, DialogState }

Props