admin/components/modals/Modal.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

194 lines
6.8 KiB
TypeScript

'use client'
import { Modal as HeroModal } from '@heroui/react'
import React, { type PropsWithChildren } from 'react'
import Button from '@/components/formElements/Button'
import { useMediaQuery } from '@/hooks/useMediaQuery'
import { cn } from '@/lib/cn'
import { texts } from '@/texts'
interface ModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
onClose?: () => void
onAccept?: () => void
onReject?: () => void
isLoading?: boolean
isDismissable?: boolean
title?: string
acceptBtnText?: string
rejectBtnText?: string
acceptBtnDisabled?: boolean
rejectBtnDisabled?: boolean
hideCloseButton?: boolean
hideHeader?: boolean
hideFooter?: boolean
className?: string
containerClassName?: string
bodyClassName?: string
headerClassName?: string
footerClassName?: string
headerChildren?: React.ReactNode
footerChildren?: React.ReactNode
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full'
radius?: 'none' | 'sm' | 'md' | 'lg'
shadow?: undefined | 'sm' | 'md' | 'lg'
/** `auto` (default): bottom sheet on mobile, centered on desktop. */
placement?: 'auto' | 'top' | 'center' | 'bottom'
scrollBehavior?: 'normal' | 'inside' | 'outside'
backdrop?: 'transparent' | 'opaque' | 'blur'
acceptDanger?: boolean
}
const Modal = ({
isOpen,
onOpenChange,
onClose,
onAccept,
onReject,
isLoading = false,
isDismissable = true,
title = '',
children,
acceptBtnText = texts.common.confirmPlain,
rejectBtnText = texts.common.close,
acceptBtnDisabled = false,
rejectBtnDisabled = false,
hideCloseButton = false,
hideHeader = false,
hideFooter = false,
className = '',
containerClassName = '',
bodyClassName = '',
headerClassName = '',
footerClassName = '',
headerChildren,
footerChildren,
size = 'md',
radius = 'md',
shadow = 'lg',
placement = 'auto',
scrollBehavior = 'inside',
backdrop = 'opaque',
acceptDanger = false,
}: PropsWithChildren<ModalProps>) => {
const isMobile = useMediaQuery('(max-width: 768px)')
const resolvedPlacement = placement === 'auto' ? (isMobile ? 'bottom' : 'center') : placement
const isBottomSheet = resolvedPlacement === 'bottom' && isMobile
const getMaxWidth = (modalSize: string): string => {
const sizeMap: Record<string, string> = {
xs: 'max-w-[320px]',
sm: 'max-w-[384px]',
md: 'max-w-[448px]',
lg: 'max-w-[512px]',
xl: 'max-w-[640px]',
'2xl': 'max-w-[768px]',
'3xl': 'max-w-[896px]',
'4xl': 'max-w-[1024px]',
'5xl': 'max-w-[1280px]',
full: 'max-w-full',
}
return sizeMap[modalSize] || 'max-w-full'
}
const closeHandler = () => {
onOpenChange(false)
onClose?.()
}
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen)
if (!nextOpen) onClose?.()
}
const normalizedSize = size === 'full' ? 'full' : ['xs', 'sm', 'md', 'lg'].includes(size) ? (size as 'xs' | 'sm' | 'md' | 'lg') : 'lg'
const radiusClass = radius === 'none' ? 'rounded-none' : radius === 'sm' ? 'rounded-lg' : radius === 'lg' ? 'rounded-3xl' : 'rounded-2xl'
const shadowClass = shadow === 'sm' ? 'shadow-sm' : shadow === 'md' ? 'shadow-md' : shadow === 'lg' ? 'shadow-lg' : ''
return (
<HeroModal
isOpen={isOpen}
onOpenChange={handleOpenChange}
>
<HeroModal.Backdrop
className="z-[60]"
isDismissable={!isLoading && isDismissable}
variant={backdrop}
>
<HeroModal.Container
className={cn(size === 'full' && 'p-0', isBottomSheet && 'p-0', containerClassName)}
placement={resolvedPlacement}
scroll={scrollBehavior === 'outside' ? 'outside' : 'inside'}
size={normalizedSize}
>
<HeroModal.Dialog
aria-label={hideHeader ? title || texts.common.dialogAria : undefined}
className={cn(
!isBottomSheet && getMaxWidth(size),
// Cap to the overlay (already `--visual-viewport-height`), not 90dvh —
// iOS `dvh` does not shrink with the keyboard.
'max-h-full border border-secondary-40 bg-white p-4 text-right sm:p-6',
// Reinforce HeroUI placement (callers must not pass `m-0` / `!m-0`).
resolvedPlacement === 'bottom' && 'mt-auto',
resolvedPlacement === 'center' && 'my-auto',
resolvedPlacement === 'top' && 'mt-0',
// Do not add `mb-[var(--keyboard-inset)]`: HeroUI's overlay is already
// sized to the visual viewport, so a keyboard margin double-counts and
// collapses the sheet into the header/footer.
isBottomSheet
? 'mx-0 w-full max-w-none rounded-b-none rounded-t-3xl border-b-0 pb-[max(1rem,env(safe-area-inset-bottom))]'
: radiusClass,
shadowClass,
className
)}
>
{!hideCloseButton && !isLoading ? <HeroModal.CloseTrigger className="absolute end-4 top-4 sm:end-6 sm:top-6" /> : null}
{!hideHeader && (
<HeroModal.Header
className={cn('mb-4 flex items-center p-0 pb-4 text-right text-md font-bold text-secondary-10', headerClassName)}
>
{headerChildren}
<HeroModal.Heading>{title}</HeroModal.Heading>
</HeroModal.Header>
)}
<HeroModal.Body className={cn('overflow-y-auto overflow-x-clip p-1 -m-1 text-right text-text-main', bodyClassName)}>
{children}
</HeroModal.Body>
{!hideFooter && (
<HeroModal.Footer className={cn('mt-4 flex justify-start gap-2 p-0', footerClassName)}>
{footerChildren ?? (
<>
<Button
className="w-full"
color="warning"
disabled={rejectBtnDisabled || isLoading}
isLoading={isLoading}
variant="flat"
onClick={onReject ?? closeHandler}
>
{rejectBtnText}
</Button>
<Button
className={acceptDanger ? 'w-full bg-fourth/10 text-fourth' : 'w-full'}
color={acceptDanger ? 'danger' : 'primary'}
disabled={acceptBtnDisabled || isLoading}
isLoading={isLoading}
variant={acceptDanger ? 'flat' : 'solid'}
onClick={onAccept}
>
{acceptBtnText}
</Button>
</>
)}
</HeroModal.Footer>
)}
</HeroModal.Dialog>
</HeroModal.Container>
</HeroModal.Backdrop>
</HeroModal>
)
}
export default Modal