// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { FloatingPortal, type Placement, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; import clsx from "clsx"; import { createRef, Fragment, memo, ReactNode, useRef, useState } from "react"; import ReactDOM from "react-dom"; import "./flyoutmenu.less"; type MenuProps = { items: MenuItem[]; className?: string; placement?: Placement; onOpenChange?: (isOpen: boolean) => void; children: ReactNode | ReactNode[]; renderMenu?: (subMenu: JSX.Element, props: any) => JSX.Element; renderMenuItem?: (item: MenuItem, props: any) => JSX.Element; }; const FlyoutMenuComponent = memo( ({ items, children, className, placement, onOpenChange, renderMenu, renderMenuItem }: MenuProps) => { const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: string]: any }>({}); const [hoveredItems, setHoveredItems] = useState([]); const [subMenuPosition, setSubMenuPosition] = useState<{ [key: string]: { top: number; left: number; label: string }; }>({}); const subMenuRefs = useRef<{ [key: string]: React.RefObject }>({}); const [isOpen, setIsOpen] = useState(false); const onOpenChangeMenu = (isOpen: boolean) => { setIsOpen(isOpen); onOpenChange?.(isOpen); }; const { refs, floatingStyles, context } = useFloating({ placement: placement ?? "bottom-start", open: isOpen, onOpenChange: onOpenChangeMenu, }); const dismiss = useDismiss(context); const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); items.forEach((_, idx) => { const key = `${idx}`; if (!subMenuRefs.current[key]) { subMenuRefs.current[key] = createRef(); } }); // Position submenus based on available space and scroll position const handleSubMenuPosition = (key: string, itemRect: DOMRect, label: string) => { setTimeout(() => { const subMenuRef = subMenuRefs.current[key]?.current; if (!subMenuRef) return; const scrollTop = window.scrollY || document.documentElement.scrollTop; const scrollLeft = window.scrollX || document.documentElement.scrollLeft; const submenuWidth = subMenuRef.offsetWidth; const submenuHeight = subMenuRef.offsetHeight; let left = itemRect.right + scrollLeft - 2; // Adjust for horizontal scroll let top = itemRect.top - 2 + scrollTop; // Adjust for vertical scroll // Adjust to the left if overflowing the right boundary if (left + submenuWidth > window.innerWidth + scrollLeft) { left = itemRect.left + scrollLeft - submenuWidth; } // Adjust if the submenu overflows the bottom boundary if (top + submenuHeight > window.innerHeight + scrollTop) { top = window.innerHeight + scrollTop - submenuHeight - 10; } setSubMenuPosition((prev) => ({ ...prev, [key]: { top, left, label }, })); }, 0); }; const handleMouseEnterItem = ( event: React.MouseEvent, parentKey: string | null, index: number, item: MenuItem ) => { event.stopPropagation(); const key = parentKey ? `${parentKey}-${index}` : `${index}`; setVisibleSubMenus((prev) => { const updatedState = { ...prev }; updatedState[key] = { visible: true, label: item.label }; const ancestors = key.split("-").reduce((acc, part, idx) => { if (idx === 0) return [part]; return [...acc, `${acc[idx - 1]}-${part}`]; }, [] as string[]); ancestors.forEach((ancestorKey) => { if (updatedState[ancestorKey]) { updatedState[ancestorKey].visible = true; } }); for (const pkey in updatedState) { if (!ancestors.includes(pkey) && pkey !== key) { updatedState[pkey].visible = false; } } return updatedState; }); const newHoveredItems = key.split("-").reduce((acc, part, idx) => { if (idx === 0) return [part]; return [...acc, `${acc[idx - 1]}-${part}`]; }, [] as string[]); setHoveredItems(newHoveredItems); const itemRect = event.currentTarget.getBoundingClientRect(); handleSubMenuPosition(key, itemRect, item.label); }; const handleOnClick = (e: React.MouseEvent, item: MenuItem) => { e.stopPropagation(); onOpenChangeMenu(false); item.onClick?.(e); }; return ( <>
onOpenChangeMenu(!isOpen)} > {children}
{isOpen && (
{items.map((item, index) => { const key = `${index}`; const isActive = hoveredItems.includes(key); const menuItemProps = { className: clsx("menu-item", { active: isActive }), onMouseEnter: (event: React.MouseEvent) => handleMouseEnterItem(event, null, index, item), onClick: (e: React.MouseEvent) => handleOnClick(e, item), }; const renderedItem = renderMenuItem ? ( renderMenuItem(item, menuItemProps) ) : (
{item.label} {item.subItems && }
); return ( {renderedItem} {visibleSubMenus[key]?.visible && item.subItems && ( )} ); })}
)} ); } ); const FlyoutMenu = memo(FlyoutMenuComponent) as typeof FlyoutMenuComponent; type SubMenuProps = { subItems: MenuItem[]; parentKey: string; subMenuPosition: { [key: string]: { top: number; left: number; label: string }; }; visibleSubMenus: { [key: string]: any }; hoveredItems: string[]; subMenuRefs: React.MutableRefObject<{ [key: string]: React.RefObject }>; handleMouseEnterItem: ( event: React.MouseEvent, parentKey: string | null, index: number, item: MenuItem ) => void; handleOnClick: (e: React.MouseEvent, item: MenuItem) => void; renderMenu?: (subMenu: JSX.Element, props: any) => JSX.Element; renderMenuItem?: (item: MenuItem, props: any) => JSX.Element; }; const SubMenu = memo( ({ subItems, parentKey, subMenuPosition, visibleSubMenus, hoveredItems, subMenuRefs, handleMouseEnterItem, handleOnClick, renderMenu, renderMenuItem, }: SubMenuProps) => { subItems.forEach((_, idx) => { const newKey = `${parentKey}-${idx}`; if (!subMenuRefs.current[newKey]) { subMenuRefs.current[newKey] = createRef(); } }); const position = subMenuPosition[parentKey]; const isPositioned = position && position.top !== undefined && position.left !== undefined; const subMenu = (
{subItems.map((item, idx) => { const newKey = `${parentKey}-${idx}`; const isActive = hoveredItems.includes(newKey); const menuItemProps = { className: clsx("menu-item", { active: isActive }), onMouseEnter: (event: React.MouseEvent) => handleMouseEnterItem(event, parentKey, idx, item), onClick: (e: React.MouseEvent) => handleOnClick(e, item), }; const renderedItem = renderMenuItem ? ( renderMenuItem(item, menuItemProps) // Remove portal here ) : (
{item.label} {item.subItems && }
); return ( {renderedItem} {visibleSubMenus[newKey]?.visible && item.subItems && ( )} ); })}
); return ReactDOM.createPortal(renderMenu ? renderMenu(subMenu, { parentKey }) : subMenu, document.body); } ); export { FlyoutMenu };