import * as React from 'react'; import { createPortal } from 'react-dom'; import { cn } from '../../../lib/utils'; interface DialogContextValue { open: boolean; onOpenChange: (open: boolean) => void; triggerRef: React.MutableRefObject; } const DialogContext = React.createContext(null); function useDialog() { const ctx = React.useContext(DialogContext); if (!ctx) throw new Error('Dialog components must be used within '); return ctx; } interface DialogProps { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; children: React.ReactNode; } const Dialog: React.FC = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children }) => { const [internalOpen, setInternalOpen] = React.useState(defaultOpen); const triggerRef = React.useRef(null) as React.MutableRefObject; const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; const onOpenChange = React.useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); controlledOnOpenChange?.(next); }, [isControlled, controlledOnOpenChange] ); const value = React.useMemo(() => ({ open, onOpenChange, triggerRef }), [open, onOpenChange]); return {children}; }; const DialogTrigger = React.forwardRef & { asChild?: boolean }>( ({ onClick, children, asChild, ...props }, ref) => { const { onOpenChange, triggerRef } = useDialog(); const handleClick = React.useCallback( (e: React.MouseEvent) => { onOpenChange(true); onClick?.(e); }, [onOpenChange, onClick] ); // asChild: clone child element and compose onClick + capture ref if (asChild && React.isValidElement(children)) { const child = children as React.ReactElement; return React.cloneElement(child, { onClick: (e: React.MouseEvent) => { onOpenChange(true); child.props.onClick?.(e); }, ref: (node: HTMLElement | null) => { triggerRef.current = node; // Forward the outer ref if (typeof ref === 'function') ref(node as any); else if (ref) (ref as React.MutableRefObject).current = node; }, }); } return ( ); } ); DialogTrigger.displayName = 'DialogTrigger'; interface DialogContentProps extends React.HTMLAttributes { onEscapeKeyDown?: () => void; onPointerDownOutside?: () => void; } const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const DialogContent = React.forwardRef( ({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => { const { open, onOpenChange, triggerRef } = useDialog(); const contentRef = React.useRef(null); const previousFocusRef = React.useRef(null); // Save the element that had focus before opening, restore on close React.useEffect(() => { if (open) { previousFocusRef.current = document.activeElement as HTMLElement; } else if (previousFocusRef.current) { // Prefer the trigger, fall back to whatever was focused before const restoreTarget = triggerRef.current || previousFocusRef.current; restoreTarget?.focus(); previousFocusRef.current = null; } }, [open, triggerRef]); React.useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onEscapeKeyDown?.(); onOpenChange(false); return; } // Focus trap: Tab / Shift+Tab cycle within the dialog if (e.key === 'Tab' && contentRef.current) { const focusable = Array.from( contentRef.current.querySelectorAll(FOCUSABLE_SELECTOR) ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }; document.addEventListener('keydown', handleKeyDown, true); // Prevent body scroll const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleKeyDown, true); document.body.style.overflow = prev; }; }, [open, onOpenChange, onEscapeKeyDown]); // Auto-focus first focusable element on open React.useEffect(() => { if (open && contentRef.current) { // Small delay to let the portal render requestAnimationFrame(() => { const first = contentRef.current?.querySelector(FOCUSABLE_SELECTOR); first?.focus(); }); } }, [open]); if (!open) return null; return createPortal(
{/* Overlay */}
{ onPointerDownOutside?.(); onOpenChange(false); }} aria-hidden /> {/* Content */}
{ contentRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }} role="dialog" aria-modal="true" className={cn( 'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2', 'rounded-xl border bg-popover text-popover-foreground shadow-lg', 'animate-dialog-content-show', className )} {...props} > {children}
, document.body ); } ); DialogContent.displayName = 'DialogContent'; const DialogTitle = React.forwardRef>( ({ className, ...props }, ref) => (

) ); DialogTitle.displayName = 'DialogTitle'; export { Dialog, DialogTrigger, DialogContent, DialogTitle, useDialog };