import { Children, ComponentPropsWithRef, ReactElement, RefObject, cloneElement, isValidElement, useEffect, useEffectEvent, useRef, useState, } from "react"; import { FloatingFocusManager, FloatingPortal, autoUpdate, flip, offset, shift, size, useDismiss, useFloating, useInteractions, useListNavigation, useRole, useTypeahead, } from "@floating-ui/react"; import { ContextMenuItemProps } from "./ContextMenuItem"; import { Dialog } from "../dialog/Dialog"; import { useRefDebounce } from "../../hooks"; type CustomContextEvent = CustomEvent<{ emulated: boolean; originalEvent: Event }>; interface Props extends ComponentPropsWithRef<"div"> { targetRef?: RefObject; children: ReactElement[] | ReactElement; eventName?: "contextmenu" | string; debounceOpenning?: number; } export function ContextMenu({ targetRef, children, style, eventName = "contextmenu" }: Props) { const [activeIndex, setActiveIndex] = useState(null); const [isOpen, setIsOpen] = useState(false); const isOpenDebounceRef = useRefDebounce(isOpen, 200); const contextEvent = useRef(null); const listItemsRef = useRef<(HTMLButtonElement | null)[]>([]); const listContentRef = useRef( Children.map(children, (child) => isValidElement(child) ? child.props.label : null, ) as (string | null)[], ); const { refs, floatingStyles, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, middleware: [ offset({ mainAxis: 5, alignmentAxis: 4 }), flip(), size({ apply({ elements, availableHeight }) { const firstChild = elements.floating.firstElementChild as HTMLElement; if (firstChild) { firstChild.style.maxHeight = `${Math.min(availableHeight, 300)}px`; } }, padding: 10, }), shift({ padding: 10 }), ], /** * it is better to be less demanding on small screen sizes to see the content displayed correctly */ placement: window.matchMedia("(min-width: 700px)").matches ? "right-start" : undefined, strategy: "fixed", whileElementsMounted: autoUpdate, }); const role = useRole(context, { role: "menu" }); const dismiss = useDismiss(context); const listNavigation = useListNavigation(context, { listRef: listItemsRef, onNavigate: setActiveIndex, activeIndex, }); const typeahead = useTypeahead(context, { enabled: isOpen, listRef: listContentRef, onMatch: setActiveIndex, activeIndex, }); const { getFloatingProps, getItemProps } = useInteractions([ role, dismiss, listNavigation, typeahead, ]); const onContextMenuStable = useEffectEvent(function onContextMenu( e: MouseEvent | CustomContextEvent, ) { e.preventDefault(); /** * in certain cases (maplibre-gl) we will not be able to use the * native "contextmenu" event for touch screens and we will emulate this * with a simple "click" event * * with emulated event if the context menu has just been closed we probably * do not want a click to immediately reopen a new context menu */ const isEmulatedContextMenu = e.type !== "contextmenu" && (e as CustomContextEvent).detail.emulated; if (isEmulatedContextMenu && isOpenDebounceRef.current) { return; } const originalEvent = e instanceof MouseEvent ? e : (e.detail.originalEvent as MouseEvent); contextEvent.current = e; refs.setPositionReference({ getBoundingClientRect() { return { width: 0, height: 0, x: originalEvent.clientX, y: originalEvent.clientY, top: originalEvent.clientY, right: originalEvent.clientX, bottom: originalEvent.clientY, left: originalEvent.clientX, }; }, }); setIsOpen(true); isOpenDebounceRef.current = true; }); useEffect(() => { const stableTarget = targetRef?.current ?? document.documentElement; // console.log("add contextmenu", targetRef); stableTarget.addEventListener(eventName as "contextmenu", onContextMenuStable); return () => { // console.log("remove contextmenu", targetRef); stableTarget.removeEventListener(eventName as "contextmenu", onContextMenuStable); }; }, [eventName, targetRef]); return ( {isOpen && (
{Children.map( children, (child, index) => isValidElement(child) && cloneElement( child, getItemProps({ tabIndex: activeIndex === index ? 0 : -1, ref(node: HTMLButtonElement) { listItemsRef.current[index] = node; }, onMouseUp() { child.props.onClick?.(contextEvent.current); setIsOpen(false); }, }), ), )}
)}
); }