import React, { Fragment, ReactNode, MouseEvent, useState, useLayoutEffect, } from "react"; import { useId } from "@reach/auto-id"; import FocusLock from "react-focus-lock"; import { usePopper } from "react-popper-2"; import { animated, useTransition } from "react-spring"; import { easeCubicInOut } from "d3-ease"; import { useDisclosure, 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; } interface TriggerState { isOpen: boolean; onClose: () => void; } type TriggerFunction = (props: TriggerProps, state: TriggerState) => ReactNode; type RenderFunction = (props: RenderProps) => ReactNode; export interface MenuProps extends BoxProps { /** * 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; } const MENU_OFFSET = 8; const TRANSITION_START = { transform: `translateY(-${MENU_OFFSET}px)`, opacity: 0, }; const TRANSITION_END = { transform: "translateY(0)", opacity: 1 }; export const MenuRenderer = ({ children, className, placement = "bottom-start", position = "absolute", trigger, popperModifiers = [], autoFocus = false, hasPortal = false, isOpen: isOpenProp = false, onClose: onCloseProp, ...rest }: MenuProps) => { const { isOpen, onOpen, onClose, onToggle } = useDisclosure(isOpenProp); 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 triggerProps: TriggerProps = { id: buttonId, onClick: (event: Event | MouseEvent) => { event.stopPropagation(); event.preventDefault(); onToggle(); }, "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": menuId, role: "button", tabIndex: 0, }; const handleClose = () => { if (onCloseProp) { onCloseProp(); } onClose(); }; const triggerState: TriggerState = { isOpen, onClose: handleClose, }; const listProps = { id: menuId, role: "menu", tabIndex: -1, "aria-labelledby": buttonId, }; const itemProps = { tabIndex: 0, role: "menuitem", }; const renderProps = { listProps, itemProps, isOpen, onClose: handleClose, }; const context: MenuContextProps = { menuId, buttonId, onToggle, onOpen, placement, ...renderProps, }; useOutsideClick([menuElement, menuListElement], () => { handleClose(); }); useKeyPress(KEY_CODE.ESC, () => { 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 transitions = useTransition(isOpen, null, { from: TRANSITION_START, enter: TRANSITION_END, leave: TRANSITION_START, config: { duration: 250, easing: easeCubicInOut, }, }); return ( {trigger(triggerProps, triggerState)} {transitions.map( ({ item, key, props: transitionProps }) => item && ( {typeof children === "function" ? children(renderProps) : children} ), )} ); };