feat(ui): add shared Modal component with portal, overlay, escape handler
This commit is contained in:
@@ -0,0 +1,184 @@
|
|||||||
|
"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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user