import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { cn } from '@/utils/cn' export interface DropdownMenuProps { trigger: React.ReactNode children: React.ReactNode open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void align?: 'start' | 'center' | 'end' side?: 'top' | 'right' | 'bottom' | 'left' width?: 'trigger' | 'sm' | 'md' | 'lg' className?: string } export interface DropdownMenuItemProps { leftIcon?: string rightIcon?: string rightSlot?: React.ReactNode description?: string disabled?: boolean tone?: 'default' | 'danger' onSelect?: () => void children: React.ReactNode } interface DropdownMenuContextValue { closeMenu: (restoreFocus?: boolean) => void } const DropdownMenuContext = React.createContext(null) const widthStyles: Record, string> = { sm: 'w-48', md: 'w-60', lg: 'w-80', } type MenuPosition = { top: number left: number minWidth?: number } type TriggerElementProps = React.HTMLAttributes & React.RefAttributes function assignRef(ref: React.Ref | undefined, value: T | null) { if (!ref) return if (typeof ref === 'function') { ref(value) return } ;(ref as React.MutableRefObject).current = value } function composeRefs(...refs: Array | undefined>) { return (value: T | null) => { refs.forEach((ref) => assignRef(ref, value)) } } function getEnabledItems(menu: HTMLElement | null): HTMLElement[] { if (!menu) return [] return Array.from( menu.querySelectorAll('[data-cp-dropdown-item="true"]:not([aria-disabled="true"])') ) } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } const DropdownMenuRoot: React.FC = ({ trigger, children, open, defaultOpen = false, onOpenChange, align = 'start', side = 'bottom', width = 'sm', className, }) => { const isControlled = open !== undefined const [internalOpen, setInternalOpen] = useState(defaultOpen) const isOpen = isControlled ? Boolean(open) : internalOpen const triggerRef = useRef(null) const menuRef = useRef(null) const focusOnOpenRef = useRef<'first' | 'last' | null>(null) const [position, setPosition] = useState({ top: 0, left: 0 }) const setOpen = useCallback( (nextOpen: boolean) => { if (!isControlled) setInternalOpen(nextOpen) onOpenChange?.(nextOpen) }, [isControlled, onOpenChange] ) const closeMenu = useCallback( (restoreFocus = true) => { setOpen(false) if (restoreFocus) triggerRef.current?.focus() }, [setOpen] ) const updatePosition = useCallback(() => { const triggerEl = triggerRef.current const menuEl = menuRef.current if (!triggerEl || !menuEl || typeof window === 'undefined') return const margin = 8 const gap = 6 const rect = triggerEl.getBoundingClientRect() const menuWidth = width === 'trigger' ? rect.width : menuEl.offsetWidth const menuHeight = menuEl.offsetHeight let top = rect.bottom + gap let left = rect.left if (side === 'top') { top = rect.top - menuHeight - gap } else if (side === 'right') { top = rect.top + rect.height / 2 - menuHeight / 2 left = rect.right + gap } else if (side === 'left') { top = rect.top + rect.height / 2 - menuHeight / 2 left = rect.left - menuWidth - gap } if (side === 'top' || side === 'bottom') { if (align === 'center') left = rect.left + rect.width / 2 - menuWidth / 2 if (align === 'end') left = rect.right - menuWidth } else { if (align === 'center') top = rect.top + rect.height / 2 - menuHeight / 2 if (align === 'end') top = rect.bottom - menuHeight } setPosition({ top: clamp(top, margin, window.innerHeight - menuHeight - margin), left: clamp(left, margin, window.innerWidth - menuWidth - margin), minWidth: width === 'trigger' ? rect.width : undefined, }) }, [align, side, width]) const focusMenuItem = useCallback((target: 'first' | 'last' = 'first') => { const enabledItems = getEnabledItems(menuRef.current) const item = target === 'last' ? enabledItems[enabledItems.length - 1] : enabledItems[0] item?.focus() }, []) useLayoutEffect(() => { if (!isOpen) return updatePosition() }, [isOpen, updatePosition, children]) useLayoutEffect(() => { if (!isOpen || !focusOnOpenRef.current) return const target = focusOnOpenRef.current focusOnOpenRef.current = null focusMenuItem(target) }, [focusMenuItem, isOpen, children]) useEffect(() => { if (!isOpen || typeof document === 'undefined') return const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return closeMenu(false) } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() closeMenu(true) } } document.addEventListener('mousedown', handlePointerDown) document.addEventListener('keydown', handleKeyDown) window.addEventListener('resize', updatePosition) window.addEventListener('scroll', updatePosition, true) return () => { document.removeEventListener('mousedown', handlePointerDown) document.removeEventListener('keydown', handleKeyDown) window.removeEventListener('resize', updatePosition) window.removeEventListener('scroll', updatePosition, true) } }, [closeMenu, isOpen, updatePosition]) const handleMenuKeyDown = (event: React.KeyboardEvent) => { const enabledItems = getEnabledItems(menuRef.current) if (enabledItems.length === 0) return const activeIndex = enabledItems.indexOf(document.activeElement as HTMLElement) if (event.key === 'ArrowDown') { event.preventDefault() enabledItems[(activeIndex + 1) % enabledItems.length]?.focus() } else if (event.key === 'ArrowUp') { event.preventDefault() enabledItems[(activeIndex - 1 + enabledItems.length) % enabledItems.length]?.focus() } else if (event.key === 'Home') { event.preventDefault() enabledItems[0]?.focus() } else if (event.key === 'End') { event.preventDefault() enabledItems[enabledItems.length - 1]?.focus() } else if (event.key === 'Enter' || event.key === ' ') { if (document.activeElement instanceof HTMLElement && enabledItems.includes(document.activeElement)) { event.preventDefault() document.activeElement.click() } } } const handleTriggerKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return event.preventDefault() const target = event.key === 'ArrowUp' ? 'last' : 'first' if (isOpen) { focusMenuItem(target) return } focusOnOpenRef.current = target setOpen(true) } const contextValue = useMemo(() => ({ closeMenu }), [closeMenu]) const triggerElement = React.isValidElement(trigger) ? (() => { const element = trigger as React.ReactElement & { ref?: React.Ref } return React.cloneElement(element, { ref: composeRefs(element.ref, triggerRef), 'aria-haspopup': 'menu', 'aria-expanded': isOpen, onClick: (event: React.MouseEvent) => { element.props.onClick?.(event) if (!event.defaultPrevented) setOpen(!isOpen) }, onKeyDown: (event: React.KeyboardEvent) => { element.props.onKeyDown?.(event) if (!event.defaultPrevented) handleTriggerKeyDown(event) }, }) })() : ( ) const menu = isOpen ? (
{children}
) : null return ( <> {triggerElement} {typeof document !== 'undefined' && menu ? createPortal(menu, document.body) : menu} ) } const DropdownMenuItem: React.FC = ({ leftIcon, rightIcon, rightSlot, description, disabled = false, tone = 'default', onSelect, children, }) => { const context = React.useContext(DropdownMenuContext) const handleClick = () => { if (disabled) return onSelect?.() context?.closeMenu(true) } return ( ) } const DropdownMenuLabel: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className, }) => (
{children}
) const DropdownMenuSeparator: React.FC<{ className?: string }> = ({ className }) => (
) const DropdownMenuGroup: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className, }) => (
{children}
) export const DropdownMenu = Object.assign(DropdownMenuRoot, { Item: DropdownMenuItem, Label: DropdownMenuLabel, Separator: DropdownMenuSeparator, Group: DropdownMenuGroup, })