import { useEffect, useRef } from 'react'; import { onToggleArrowKeydownDefault, Popper } from '../../helpers'; import type { PopperOptions } from '../../helpers/Popper/Popper'; /** @deprecated Use PopperOptions instead */ export type MenuPopperProps = PopperOptions; export interface MenuContainerProps { /** Menu to be rendered */ menu: React.ReactElement>; /** Reference to the menu */ menuRef: React.RefObject; /** Toggle to be rendered */ toggle: React.ReactNode; /** Reference to the toggle */ toggleRef: React.RefObject; /** Flag to indicate if menu is opened.*/ isOpen: boolean; /** Callback to change the open state of the menu. * Triggered by clicking outside of the menu, or by pressing any keys specified in onOpenChangeKeys. */ onOpenChange?: (isOpen: boolean) => void; /** Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */ onOpenChangeKeys?: string[]; /** Callback to override the toggle keydown behavior. By default, when the toggle has focus and the menu is open, pressing the up/down arrow keys will focus a valid non-disabled menu item - the first item for the down arrow key and last item for the up arrow key. */ onToggleKeydown?: (event: KeyboardEvent) => void; /** z-index of the dropdown menu */ zIndex?: number; /** Additional properties to pass to the Popper */ popperProps?: PopperOptions; /** @beta Flag indicating the first menu item should be focused after opening the dropdown. */ shouldFocusFirstItemOnOpen?: boolean; /** Flag indicating if scroll on focus of the first menu item should occur. */ shouldPreventScrollOnItemFocus?: boolean; /** Time in ms to wait before firing the toggles' focus event. Defaults to 0 */ focusTimeoutDelay?: number; } /** * Container that links a menu and menu toggle together, to handle basic keyboard input and control the opening and closing of a menu. */ export const MenuContainer: React.FunctionComponent = ({ menu, menuRef, isOpen, toggle, toggleRef, onOpenChange, onToggleKeydown, zIndex = 9999, popperProps, onOpenChangeKeys = ['Escape', 'Tab'], shouldFocusFirstItemOnOpen = false, shouldPreventScrollOnItemFocus = true, focusTimeoutDelay = 0 }: MenuContainerProps) => { const prevIsOpen = useRef(isOpen); useEffect(() => { // menu was opened, focus on first menu item if (prevIsOpen.current === false && isOpen === true && shouldFocusFirstItemOnOpen) { setTimeout(() => { const firstElement = menuRef?.current?.querySelector( 'li button:not(:disabled),li input:not(:disabled),li a:not([aria-disabled="true"])' ); firstElement && (firstElement as HTMLElement).focus({ preventScroll: shouldPreventScrollOnItemFocus }); }, focusTimeoutDelay); } prevIsOpen.current = isOpen; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); useEffect(() => { const handleMenuKeys = (event: KeyboardEvent) => { // Close the menu on tab or escape if onOpenChange is provided if ( (isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) || toggleRef.current?.contains(event.target as Node) ) { if (onOpenChangeKeys.includes(event.key)) { onOpenChange(false); toggleRef.current?.focus(); } } if (toggleRef.current?.contains(event.target as Node)) { if (onToggleKeydown) { onToggleKeydown(event); } else if (isOpen) { onToggleArrowKeydownDefault(event, menuRef); } } }; const handleClick = (event: MouseEvent) => { // If the event is not on the toggle and onOpenChange callback is provided, close the menu if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) { if (isOpen && !menuRef.current?.contains(event.target as Node)) { onOpenChange(false); } } }; window.addEventListener('keydown', handleMenuKeys); window.addEventListener('click', handleClick); return () => { window.removeEventListener('keydown', handleMenuKeys); window.removeEventListener('click', handleClick); }; }, [ focusTimeoutDelay, isOpen, menuRef, onOpenChange, onOpenChangeKeys, onToggleKeydown, shouldPreventScrollOnItemFocus, toggleRef ]); return ( ); }; MenuContainer.displayName = 'MenuContainer';