import * as React from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { cn } from "../../lib/utils"; interface DropdownContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; } const DropdownContext = React.createContext(null); function useDropdown() { const context = React.useContext(DropdownContext); if (!context) { throw new Error("Dropdown components must be used within a Dropdown"); } return context; } interface DropdownProps { children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; } const Dropdown: React.FC = ({ children, open: controlledOpen, onOpenChange, }) => { const [internalOpen, setInternalOpen] = useState(false); const triggerRef = useRef(null); const open = controlledOpen !== undefined ? controlledOpen : internalOpen; const setOpen = useCallback( (value: boolean) => { if (controlledOpen === undefined) { setInternalOpen(value); } onOpenChange?.(value); }, [controlledOpen, onOpenChange], ); useEffect(() => { const closeOnPageHidden = () => { if (document.visibilityState === "hidden") { setOpen(false); } }; document.addEventListener("visibilitychange", closeOnPageHidden); return () => document.removeEventListener("visibilitychange", closeOnPageHidden); }, [setOpen]); return ( {children} ); }; interface DropdownTriggerProps { children: React.ReactElement; asChild?: boolean; toggleOnClick?: boolean; } const DropdownTrigger: React.FC = ({ children, asChild, toggleOnClick = true, }) => { const { open, setOpen, triggerRef } = useDropdown(); const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); setOpen(!open); }; if (asChild && React.isValidElement(children)) { return React.cloneElement( children as React.ReactElement<{ ref?: React.Ref; onClick?: (e: React.MouseEvent) => void; }>, { ref: triggerRef, onClick: (e: React.MouseEvent) => { const childProps = children.props as { onClick?: (e: React.MouseEvent) => void; }; childProps?.onClick?.(e); if (toggleOnClick && !e.defaultPrevented) { handleClick(e); } }, }, ); } return ( ); }; interface DropdownContentProps { children: React.ReactNode; className?: string; align?: "start" | "center" | "end"; sideOffset?: number; side?: "top" | "bottom"; /** If true, align to the trigger's parent element instead of the trigger itself */ alignToParent?: boolean; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; } const DropdownContent: React.FC = ({ children, className, align = "start", sideOffset = 4, side = "bottom", alignToParent = false, onMouseEnter, onMouseLeave, }) => { const { open, setOpen, triggerRef } = useDropdown(); const contentRef = useRef(null); const [position, setPosition] = useState<{ top: number; left: number; maxHeight?: number; } | null>(null); // Calculate position function const calculatePosition = useCallback(() => { if (!triggerRef.current) return null; // Use parent element if alignToParent is true const anchorEl = alignToParent ? triggerRef.current.parentElement : triggerRef.current; if (!anchorEl) return null; const rect = anchorEl.getBoundingClientRect(); const triggerRect = triggerRef.current.getBoundingClientRect(); const contentEl = contentRef.current; const contentHeight = contentEl?.offsetHeight ?? 0; const contentWidth = contentEl?.offsetWidth ?? 0; const viewportMargin = 8; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const spaceBelow = Math.max( 0, viewportHeight - triggerRect.bottom - sideOffset - viewportMargin, ); const spaceAbove = Math.max( 0, triggerRect.top - sideOffset - viewportMargin, ); // Prefer requested side when it fits; otherwise pick the side with more room. // Never bounce oversized menus off-screen by flipping twice. let placeBelow: boolean; if (side === "bottom") { if (contentHeight <= spaceBelow || spaceBelow >= spaceAbove) { placeBelow = true; } else { placeBelow = false; } } else if (contentHeight <= spaceAbove || spaceAbove > spaceBelow) { placeBelow = false; } else { placeBelow = true; } const available = placeBelow ? spaceBelow : spaceAbove; const maxHeight = contentHeight > available && available > 0 ? available : undefined; const effectiveHeight = maxHeight ?? contentHeight; let top: number; if (placeBelow) { top = triggerRect.bottom + sideOffset; } else { top = triggerRect.top - effectiveHeight - sideOffset; if (top < viewportMargin) { top = viewportMargin; } } // Use anchor element (parent or trigger) for horizontal positioning let left: number; if (align === "start") { left = rect.left; } else if (align === "end") { left = contentEl ? rect.right - contentWidth : rect.right; } else { left = rect.left + rect.width / 2; if (contentEl) { left -= contentWidth / 2; } } if (contentEl) { if (left + contentWidth > viewportWidth - viewportMargin) { left = viewportWidth - contentWidth - viewportMargin; } if (left < viewportMargin) { left = viewportMargin; } } return { top, left, maxHeight }; }, [align, sideOffset, side, alignToParent, triggerRef]); // Calculate position synchronously after DOM updates useLayoutEffect(() => { if (open) { // Reset position first to hide content while calculating setPosition(null); // Use double requestAnimationFrame to ensure content is fully rendered // First frame: content is added to DOM // Second frame: layout is calculated, offsetWidth is available requestAnimationFrame(() => { requestAnimationFrame(() => { const pos = calculatePosition(); if (pos) setPosition(pos); }); }); } else { setPosition(null); } }, [open, calculatePosition]); // Close on click outside useEffect(() => { if (!open) return; const handleClickOutside = (e: MouseEvent) => { if ( contentRef.current && !contentRef.current.contains(e.target as Node) && triggerRef.current && !triggerRef.current.contains(e.target as Node) ) { setOpen(false); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); } }; // Use setTimeout to avoid closing immediately on the same click that opened it const timeoutId = setTimeout(() => { document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); }, 0); return () => { clearTimeout(timeoutId); document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [open, setOpen, triggerRef]); if (!open) return null; return createPortal(
{children}
, document.body, ); }; export { Dropdown, DropdownContent, DropdownTrigger };