From c040f36fe07597a19ca3070047a7d8c7569ae9f3 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 10 May 2026 00:32:46 +0800 Subject: [PATCH] feat(ui): add shared Modal component with portal, overlay, escape handler --- components/ui/modal/modal.tsx | 184 ++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 components/ui/modal/modal.tsx diff --git a/components/ui/modal/modal.tsx b/components/ui/modal/modal.tsx new file mode 100644 index 0000000..c88fca8 --- /dev/null +++ b/components/ui/modal/modal.tsx @@ -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(null) + const previousActiveElement = useRef(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( + '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( + '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( +
+ {/* Overlay */} + , + document.body, + ) +}