import React, { Fragment, ReactNode, MouseEvent, useState, useLayoutEffect, useId, useEffect, } from "react"; import FocusLock from "react-focus-lock"; import { usePopper } from "react-popper"; import { animated, useTransition } from "@react-spring/web"; import { easeCubicInOut } from "d3-ease"; import { useKeyPress, useOutsideClick } from "../../hooks"; import { KEY_CODE, PopperPlacement } from "../../types"; import { Box, BoxProps } from "../Box"; import { Portal } from "../Portal"; import { MenuContext, MenuContextProps, RenderProps } from "./context"; interface TriggerProps { id: string; onClick: (event: Event | MouseEvent) => void; "aria-haspopup": "menu"; "aria-expanded": boolean; "aria-controls": string; role: string; tabIndex: number; } export type MenuRendererTriggerProps = TriggerProps; interface TriggerState { isOpen: boolean; onClose: () => void; } export type MenuRendererTriggerState = TriggerState; type TriggerFunction = (props: TriggerProps, state: TriggerState) => ReactNode; type RenderFunction = (props: RenderProps) => ReactNode; export interface MenuProps extends Omit { /** * Render function to create the menu button; `triggerProps` props must be * spread onto the element that's being returned. */ trigger: TriggerFunction; /** * Render function to create the menu list and options */ children: RenderFunction | ReactNode; /** * Where the menu list popover will be placed relative to the button */ placement?: PopperPlacement; /** * Tell the menu list popover how to be positioned */ position?: "absolute" | "fixed"; /** * Popper.js modifiers */ popperModifiers?: Record[]; /** * Whether or not to set focus to the first menu item when the menu appears */ autoFocus?: boolean; /** * Whether or not to render the popover within a portal; useful when dealing * with competing z-index values or other portaled components. This will * render the menu at the highest z-index value (z-toast). */ hasPortal?: boolean; /** * Allow the menu list to be open when the component mounts */ isOpen?: boolean; /** * Called whenever the menu is closed */ onClose?: () => void; /** * Control the open state externally */ isMenuOpen?: boolean; /** * Control the open state externally */ openMenu?: (open: boolean) => void; /** * Determine whether or not the animated menu popover is inline or block */ menuDisplay?: "inline-block" | "block"; /** * Should focus lock be disabled */ disableFocusLock?: boolean; /** * If true, the menu will close when a menu item is clicked. */ closeOnSelect?: boolean; /** * If true, the menu will not close when the ESC key is pressed. */ disableEscClose?: boolean; } const MENU_OFFSET = 8; const TRANSITION_START = { transform: `translateY(-${MENU_OFFSET}px)`, opacity: 0, }; const TRANSITION_END = { transform: "translateY(0)", opacity: 1 }; /** * @deprecated */ export const MenuRenderer = ({ children, className, placement = "bottom-start", position = "absolute", trigger, popperModifiers = [], autoFocus = false, hasPortal = false, isOpen: isOpenInitial = false, isMenuOpen: isOpenControlled, onClose: onCloseProp, openMenu: setOpenControlled, menuDisplay = "inline-block", disableFocusLock = false, closeOnSelect = true, disableEscClose = false, ...rest }: MenuProps) => { const [isOpenUncontrolled, setOpenUncontrolled] = useState(isOpenInitial); const open = isOpenControlled ?? isOpenUncontrolled; const setOpen = setOpenControlled ?? setOpenUncontrolled; const menuElement = React.useRef(null); const menuListElement = React.useRef(null); const id = `menu-${useId()}`; const menuId = `${id}-menu`; const buttonId = `${id}-menubutton`; const defaultPopperModifiers = [ { name: "offset", options: { offset: [0, MENU_OFFSET], }, }, ]; const [minWidth, setMinWidth] = useState(0); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); const { styles, attributes, update } = usePopper( referenceElement, popperElement, { placement, modifiers: [...defaultPopperModifiers, ...popperModifiers], strategy: position, }, ); const handleClose = () => { if (onCloseProp) { onCloseProp(); } setOpen(false); }; const handleOpen = () => { setOpen(true); }; const triggerProps: TriggerProps = { id: buttonId, onClick: (event: Event | MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (open) { handleClose(); } else { handleOpen(); } }, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": menuId, role: "button", tabIndex: 0, }; const triggerState: TriggerState = { isOpen: open, onClose: handleClose, }; const listProps = { id: menuId, role: "menu", tabIndex: -1, "aria-labelledby": buttonId, }; const itemProps = { tabIndex: 0, role: "menuitem", }; const renderProps = { listProps, itemProps, isOpen: open, onClose: handleClose, onOpen: handleOpen, onToggle: open ? handleClose : handleOpen, }; const context: MenuContextProps = { menuId, buttonId, placement, closeOnSelect, ...renderProps, }; useOutsideClick([menuElement, menuListElement], () => { handleClose(); }); const [esc, setEsc] = useState(disableEscClose); useEffect(() => { setEsc(disableEscClose); }, [disableEscClose]); useKeyPress(KEY_CODE.ESC, () => { if (!esc && open) { handleClose(); } }); useLayoutEffect(() => { if (menuElement && menuElement.current) { const calculatedMinWidth = Math.round( menuElement.current.getBoundingClientRect().width, ); setMinWidth(calculatedMinWidth); } }, []); /** * Call Popper’s update function to reposition the popover when the * dimensions of the children change. */ useLayoutEffect(() => { if (typeof update === "function") { update(); } }, [children, update]); const PortalComponent = hasPortal ? Portal : Fragment; const transition = useTransition(open, { from: TRANSITION_START, enter: TRANSITION_END, leave: TRANSITION_START, config: { duration: 250, easing: easeCubicInOut, }, }); return ( {trigger(triggerProps, triggerState)} {transition( (style, item) => item && ( {typeof children === "function" ? children(renderProps) : children} ), )} ); };