import React, { useCallback, useRef, useState } from 'react'; import { useDismissablePopover } from '../hooks/useDismissablePopover'; interface ActionMenuProps { className?: string; panelClassName?: string; panelWidth?: 'default' | 'wide'; renderTrigger: (props: { isOpen: boolean; toggleMenu: () => void; }) => React.ReactNode; children: (props: { closeMenu: () => void }) => React.ReactNode; } export const ActionMenu: React.FC = ({ className, panelClassName, panelWidth = 'default', renderTrigger, children, }) => { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); // Shared dismissal (outside pointerdown + Escape). The hook consumes the // dismissing Escape, so closing an open Options menu no longer also runs // the host app's own Escape ladder — one Escape, one rung. const dismiss = useCallback(() => setIsOpen(false), []); useDismissablePopover({ enabled: isOpen, ref: menuRef, onDismiss: dismiss, }); return (
{renderTrigger({ isOpen, toggleMenu: () => setIsOpen(open => !open), })} {isOpen && (
{children({ closeMenu: () => setIsOpen(false) })}
)}
); }; interface ActionMenuItemProps { onClick: () => void; icon: React.ReactNode; label: string; subtitle?: string; badge?: React.ReactNode; disabled?: boolean; /** ARIA menu semantics for hosts that render a real `role="menu"` popover * (DecisionControl). Default undefined so existing consumers are unchanged. */ role?: 'menuitem'; /** Appended to the row's classes (e.g. a tone token). Default undefined so * existing consumers are byte-identical. */ className?: string; } export const ActionMenuItem: React.FC = ({ onClick, icon, label, subtitle, badge, disabled = false, role, className, }) => ( ); export const ActionMenuDivider: React.FC = () => (
); export const ActionMenuSectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
);