/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ import { Menu } from "@headlessui/react"; import { MoreHorizontal } from "lucide-react"; import * as React from "react"; import ReactDOM from "react-dom"; import { usePopper } from "react-popper"; import { useOutsideClickDetector } from "@plane/hooks"; import { ChevronDownIcon, ChevronRightIcon } from "@plane/propel/icons"; // plane helpers // helpers import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; import { cn } from "../utils"; // hooks // types import type { ICustomMenuDropdownProps, ICustomMenuItemProps, ICustomSubMenuProps, ICustomSubMenuTriggerProps, ICustomSubMenuContentProps, } from "./helper"; interface PortalProps { children: React.ReactNode; container?: Element | null; asChild?: boolean; } function Portal({ children, container, asChild = false }: PortalProps) { const [mounted, setMounted] = React.useState(false); React.useEffect(() => { setMounted(true); return () => setMounted(false); }, []); if (!mounted) { return null; } const targetContainer = container || document.body; if (asChild) { return ReactDOM.createPortal(children, targetContainer); } return ReactDOM.createPortal(
{children}
, targetContainer); } // Context for main menu to communicate with submenus const MenuContext = React.createContext<{ closeAllSubmenus: () => void; registerSubmenu: (closeSubmenu: () => void) => () => void; } | null>(null); function CustomMenu(props: ICustomMenuDropdownProps) { const { ariaLabel, buttonClassName = "", customButtonClassName = "", customButtonTabIndex = 0, placement, children, className = "", customButton, disabled = false, ellipsis = false, label, maxHeight = "md", noBorder = false, noChevron = false, optionsClassName = "", menuItemsClassName = "", verticalEllipsis = false, portalElement, menuButtonOnClick, onMenuClose, tabIndex, closeOnSelect, openOnHover = false, useCaptureForOutsideClick = false, } = props; const [referenceElement, setReferenceElement] = React.useState(null); const [popperElement, setPopperElement] = React.useState(null); const [isOpen, setIsOpen] = React.useState(false); // refs const dropdownRef = React.useRef(null); const submenuClosersRef = React.useRef void>>(new Set()); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", }); const closeAllSubmenus = React.useCallback(() => { submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu()); }, []); const registerSubmenu = React.useCallback((closeSubmenu: () => void) => { submenuClosersRef.current.add(closeSubmenu); return () => { submenuClosersRef.current.delete(closeSubmenu); }; }, []); const openDropdown = () => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; const closeDropdown = React.useCallback(() => { if (isOpen) { closeAllSubmenus(); onMenuClose?.(); } setIsOpen(false); }, [isOpen, closeAllSubmenus, onMenuClose]); const selectActiveItem = () => { const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( `[data-headlessui-state="active"] button` ); activeItem?.click(); }; const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem); const handleOnClick = () => { if (closeOnSelect) closeDropdown(); }; const handleMenuButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); if (isOpen) { closeDropdown(); } else { openDropdown(); } if (menuButtonOnClick) menuButtonOnClick(); }; const handleMouseEnter = () => { if (openOnHover) openDropdown(); }; const handleMouseLeave = () => { if (openOnHover && isOpen) { setTimeout(() => { // Only close if menu is still open if (isOpen) { closeDropdown(); } }, 150); // Small delay to allow moving to submenu } }; useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick); // Custom handler for submenu portal clicks React.useEffect(() => { const handleDocumentClick = (event: MouseEvent) => { const target = event.target as HTMLElement; const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]'); const isMainMenuClick = dropdownRef.current?.contains(target); // If it's a submenu click or main menu click, don't close if (isSubmenuClick || isMainMenuClick) { return; } // If menu is open and it's an outside click, close it if (isOpen) { closeDropdown(); } }; if (isOpen) { document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick); return () => { document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick); }; } }, [isOpen, closeDropdown, useCaptureForOutsideClick]); let menuItems = (
{children}
); if (portalElement) { menuItems = ReactDOM.createPortal(menuItems, portalElement); } return ( { e.stopPropagation(); e.preventDefault(); handleOnClick(); }} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} data-main-menu="true" > {({ open }) => ( <> {customButton ? ( ) : ( <> {ellipsis || verticalEllipsis ? ( ) : ( )} )} {isOpen && menuItems} )} ); } // SubMenu context for closing submenu from nested items const SubMenuContext = React.createContext<{ closeSubmenu: () => void } | null>(null); // Hook to use submenu context const useSubMenu = () => React.useContext(SubMenuContext); // SubMenu implementation function SubMenu(props: ICustomSubMenuProps) { const { children, trigger, disabled = false, className = "", contentClassName = "", placement = "right-start", } = props; const [isOpen, setIsOpen] = React.useState(false); const [referenceElement, setReferenceElement] = React.useState(null); const [popperElement, setPopperElement] = React.useState(null); const submenuRef = React.useRef(null); const menuContext = React.useContext(MenuContext); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement, strategy: "fixed", // Use fixed positioning to escape overflow constraints modifiers: [ { name: "offset", options: { offset: [0, 4], }, }, { name: "flip", options: { fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"], }, }, { name: "preventOverflow", options: { padding: 8, }, }, ], }); const closeSubmenu = React.useCallback(() => { setIsOpen(false); }, []); // Register this submenu with the main menu context React.useEffect(() => { if (menuContext) { return menuContext.registerSubmenu(closeSubmenu); } }, [menuContext, closeSubmenu]); const toggleSubmenu = () => { if (!disabled) { // Close other submenus when opening this one if (!isOpen && menuContext) { menuContext.closeAllSubmenus(); } setIsOpen(!isOpen); } }; const handleClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); toggleSubmenu(); }; // Close submenu when clicking on other menu items React.useEffect(() => { const handleMenuItemClick = (e: Event) => { const target = e.target as HTMLElement; // Check if the click is on a menu item that's not part of this submenu if (target.closest('[role="menuitem"]') && !submenuRef.current?.contains(target)) { closeSubmenu(); } }; document.addEventListener("click", handleMenuItemClick); return () => { document.removeEventListener("click", handleMenuItemClick); }; }, [closeSubmenu]); return (
{({ active }) => (
{trigger}
)}
{isOpen && (
{ // Notify parent menu that we're hovering over submenu const mainMenuElement = document.querySelector('[data-main-menu="true"]'); if (mainMenuElement) { const mouseEnterEvent = new MouseEvent("mouseenter", { bubbles: true }); mainMenuElement.dispatchEvent(mouseEnterEvent); } }} onMouseLeave={() => { // Notify parent menu that we're leaving submenu const mainMenuElement = document.querySelector('[data-main-menu="true"]'); if (mainMenuElement) { const mouseLeaveEvent = new MouseEvent("mouseleave", { bubbles: true }); mainMenuElement.dispatchEvent(mouseLeaveEvent); } }} > {children}
)}
); } function MenuItem(props: ICustomMenuItemProps) { const { children, disabled = false, onClick, className } = props; const submenuContext = useSubMenu(); return ( {({ active, close }) => ( )} ); } function SubMenuTrigger(props: ICustomSubMenuTriggerProps) { const { children, disabled = false, className } = props; return ( {({ active }) => (
{children}
)}
); } function SubMenuContent(props: ICustomSubMenuContentProps) { const { children, className } = props; return (
{children}
); } // Add all components as static properties for external use CustomMenu.Portal = Portal; CustomMenu.MenuItem = MenuItem; CustomMenu.SubMenu = SubMenu; CustomMenu.SubMenuTrigger = SubMenuTrigger; CustomMenu.SubMenuContent = SubMenuContent; export { CustomMenu };