import React, { useRef, forwardRef, useMemo, useCallback, useEffect } from 'react'; import { getWebProps } from 'react-native-unistyles/web'; import { menuStyles } from './Menu.styles'; import type { MenuProps } from './types'; import MenuItem from './MenuItem.web'; import useMergeRefs from '../hooks/useMergeRefs'; import { PositionedPortal } from '../internal/PositionedPortal'; import { getWebInteractiveAriaProps, generateAccessibilityId, MENU_KEYS, matchesKey } from '../utils/accessibility'; import type { IdealystElement } from '../utils/refTypes'; /** * Dropdown menu for actions and navigation triggered by a button or element. * Includes keyboard navigation, icons, and separator support. */ const Menu = forwardRef(({ children, anchor, items, open = false, onOpenChange, placement = 'bottom-start', closeOnSelection = true, size = 'md', style, testID, id, // Accessibility props accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityExpanded, accessibilityControls, accessibilityHasPopup, }, ref) => { const isAnchorMode = anchor != null && 'current' in anchor; const triggerRef = useRef(null); const menuRef = useRef(null); const menuItemRefs = useRef>(new Map()); const focusedIndex = useRef(-1); const effectiveAnchorRef = isAnchorMode ? (anchor as React.RefObject) : (triggerRef as React.RefObject); // Generate unique ID for menu const menuId = useMemo(() => id || generateAccessibilityId('menu'), [id]); // Get enabled items for keyboard navigation const enabledItems = useMemo(() => items.map((item, index) => ({ ...item, index })).filter(item => !item.disabled && !item.separator), [items] ); // Focus first menu item when menu opens useEffect(() => { if (open && enabledItems.length > 0) { // Small delay to ensure menu is rendered requestAnimationFrame(() => { const firstItem = menuItemRefs.current.get(enabledItems[0].index); if (firstItem) { firstItem.focus(); focusedIndex.current = 0; } }); } else { focusedIndex.current = -1; } }, [open, enabledItems]); // Keyboard navigation handler const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (matchesKey(e, MENU_KEYS.close)) { e.preventDefault(); onOpenChange?.(false); // Return focus to trigger or anchor effectiveAnchorRef.current?.focus(); return; } if (enabledItems.length === 0) return; let nextIndex = focusedIndex.current; if (matchesKey(e, MENU_KEYS.next)) { e.preventDefault(); nextIndex = focusedIndex.current < enabledItems.length - 1 ? focusedIndex.current + 1 : 0; } else if (matchesKey(e, MENU_KEYS.prev)) { e.preventDefault(); nextIndex = focusedIndex.current > 0 ? focusedIndex.current - 1 : enabledItems.length - 1; } else if (matchesKey(e, MENU_KEYS.first)) { e.preventDefault(); nextIndex = 0; } else if (matchesKey(e, MENU_KEYS.last)) { e.preventDefault(); nextIndex = enabledItems.length - 1; } if (nextIndex !== focusedIndex.current && nextIndex >= 0) { focusedIndex.current = nextIndex; const item = enabledItems[nextIndex]; const button = menuItemRefs.current.get(item.index); button?.focus(); } }, [enabledItems, onOpenChange]); // Generate ARIA props for menu const ariaProps = useMemo(() => { return getWebInteractiveAriaProps({ accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole: accessibilityRole ?? 'menu', accessibilityExpanded: accessibilityExpanded ?? open, accessibilityControls, }); }, [accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityExpanded, open, accessibilityControls]); menuStyles.useVariants({ size, }); const overlayProps = getWebProps([(menuStyles.overlay as any)({})]); const menuProps = getWebProps([(menuStyles.menu as any)({}), style as any]); const separatorProps = getWebProps([menuStyles.separator as any]); const handleTriggerClick = () => { onOpenChange?.(!open); }; const handleItemClick = (item: typeof items[0]) => { if (item.disabled) return; item.onClick?.(); if (closeOnSelection) { onOpenChange?.(false); } }; const mergedMenuRef = useMergeRefs(ref, menuRef); return ( <> {!isAnchorMode && (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTriggerClick(); } }} > {children}
)} {open &&
} onOpenChange?.(false)} onEscapeKey={() => onOpenChange?.(false)} zIndex={1000} >