Files
deckyvault/components/ui/modal/modal.tsx
T

185 lines
5.4 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef, useLayoutEffect } from "react"
import { createPortal } from "react-dom"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
full: "max-w-full w-full h-full m-0 rounded-none",
}
type ModalSize = keyof typeof sizeClasses
export interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
description?: string
size?: ModalSize
children?: React.ReactNode
className?: string
showCloseButton?: boolean
}
// Use useLayoutEffect for DOM reads/writes to avoid hydration mismatches
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
export function Modal({
isOpen,
onClose,
title,
description,
size = "md",
children,
className,
showCloseButton = true,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousActiveElement = useRef<HTMLElement | null>(null)
// Escape handler
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
}
},
[onClose],
)
useEffect(() => {
if (!isOpen) return
document.addEventListener("keydown", handleEscape)
return () => document.removeEventListener("keydown", handleEscape)
}, [isOpen, handleEscape])
// Body scroll lock
useEffect(() => {
if (isOpen) {
const originalOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = originalOverflow
}
}
}, [isOpen])
// Focus management: store previously focused element and restore on close
useIsomorphicLayoutEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement
// Focus the modal header or the close button for accessibility
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
firstFocusable?.focus()
return () => {
previousActiveElement.current?.focus()
}
}
}, [isOpen])
// Focus trap
useEffect(() => {
if (!isOpen) return
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !modalRef.current) return
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
const first = focusableElements[0]
const last = focusableElements[focusableElements.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last?.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first?.focus()
}
}
}
document.addEventListener("keydown", handleTab)
return () => document.removeEventListener("keydown", handleTab)
}, [isOpen])
if (!isOpen) return null
return createPortal(
<div className="fixed inset-0 z-50" aria-hidden={!isOpen}>
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal wrapper */}
<div className="absolute inset-0 flex items-center justify-center overflow-y-auto p-4">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
aria-describedby={description ? "modal-description" : undefined}
className={cn(
"relative w-full rounded-xl border border-border bg-background text-text shadow-xl",
size !== "full" && "my-auto max-h-[90vh] flex flex-col",
size === "full" && "h-full flex flex-col",
sizeClasses[size],
className,
)}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 shrink-0">
<div className="flex-1 min-w-0">
{title && (
<h2
id="modal-title"
className="text-base font-semibold text-text truncate"
>
{title}
</h2>
)}
{description && (
<p id="modal-description" className="mt-0.5 text-sm text-text/60">
{description}
</p>
)}
</div>
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1.5 text-text/50 transition-colors hover:bg-text/5 hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 cursor-pointer"
aria-label="Close modal"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)}
{/* Body */}
<div className={cn("flex-1 overflow-y-auto", !title && !showCloseButton && "p-5", title || showCloseButton ? "px-5 py-4" : "")}>
{children}
</div>
</div>
</div>
</div>,
document.body,
)
}