import * as React from 'react'; import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react'; import { cn } from '../../../lib/utils'; import { Button } from './Button'; type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; export type ActionMenuItem = { key: string; label: string; description?: string; icon?: LucideIcon; onSelect: () => void; disabled?: boolean; loading?: boolean; isDanger?: boolean; showDividerBefore?: boolean; }; type ActionMenuProps = { label: string; items: ActionMenuItem[]; icon?: LucideIcon; ariaLabel?: string; align?: 'left' | 'right'; variant?: ButtonVariant; size?: ButtonSize; className?: string; triggerClassName?: string; disabled?: boolean; }; export default function ActionMenu({ label, items, icon: TriggerIcon, ariaLabel, align = 'right', variant = 'outline', size = 'sm', className, triggerClassName, disabled, }: ActionMenuProps) { const [isOpen, setIsOpen] = React.useState(false); const rootRef = React.useRef(null); const triggerRef = React.useRef(null); const menuRef = React.useRef(null); // Whether closing should move focus back to the trigger. Set for keyboard // (Escape) and item selection, but left false for outside pointer clicks so // focus is not stolen from wherever the user clicked. const restoreFocusRef = React.useRef(false); const wasOpenRef = React.useRef(false); const menuId = React.useId(); React.useEffect(() => { if (!isOpen) { return; } const closeOnOutsideClick = (event: MouseEvent) => { const target = event.target as Node; if (rootRef.current && !rootRef.current.contains(target)) { setIsOpen(false); } }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { restoreFocusRef.current = true; setIsOpen(false); } }; document.addEventListener('mousedown', closeOnOutsideClick); document.addEventListener('keydown', closeOnEscape); return () => { document.removeEventListener('mousedown', closeOnOutsideClick); document.removeEventListener('keydown', closeOnEscape); }; }, [isOpen]); // Move focus into the menu on open and back to the trigger on a keyboard or // selection close, so keyboard and screen-reader navigation match the menu role. React.useEffect(() => { if (isOpen) { wasOpenRef.current = true; const menu = menuRef.current; const firstItem = menu?.querySelector('[role="menuitem"]:not([disabled])'); (firstItem ?? menu)?.focus(); return; } if (wasOpenRef.current) { wasOpenRef.current = false; if (restoreFocusRef.current) { triggerRef.current?.focus(); } restoreFocusRef.current = false; } }, [isOpen]); const runItem = (item: ActionMenuItem) => { if (item.disabled || item.loading) { return; } restoreFocusRef.current = true; setIsOpen(false); item.onSelect(); }; return (
{isOpen && ( ); }