/* * Copyright 2021 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import classNames from "classnames"; import { createElement, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Classes, DISPLAYNAME_PREFIX, mergeRefs, type Props, Utils } from "../../common"; import { TooltipContext, TooltipProvider } from "../popover/tooltipContext"; import { ContextMenuPopover } from "./contextMenuPopover"; import type { ContextMenuPopoverOptions, Offset } from "./contextMenuShared"; /** * Render props relevant to the _content_ of a context menu (rendered as the underlying Popover's content). */ export interface ContextMenuContentProps { /** Whether the context menu is currently open. */ isOpen: boolean; /** * The computed target offset (x, y) coordinates for the context menu click event. * On first render, before any context menu click event has occurred, this will be undefined. */ targetOffset: Offset | undefined; /** The context menu click event. If isOpen is false, this will be undefined. */ mouseEvent: React.MouseEvent | undefined; } /** * Render props for advanced usage of ContextMenu. */ export interface ContextMenuChildrenProps { /** Context menu container element class */ className: string; /** Render props relevant to the content of this context menu */ contentProps: ContextMenuContentProps; /** Context menu handler which implements the custom context menu interaction */ onContextMenu: React.MouseEventHandler; /** Popover element rendered by ContextMenu, used to establish a click target to position the menu */ popover: React.JSX.Element | undefined; /** DOM ref for the context menu target, used to detect dark theme */ ref: React.Ref; } export interface ContextMenuProps extends Omit, "children" | "className" | "content" | "onContextMenu">, React.RefAttributes, Props { /** * Menu content. This will usually be a Blueprint `` component. * This optionally functions as a render prop so you can use component state to render content. */ content: React.JSX.Element | ((props: ContextMenuContentProps) => React.JSX.Element | undefined) | undefined; /** * The context menu target. This may optionally be a render function so you can use * component state to render the target. */ children: React.ReactNode | ((props: ContextMenuChildrenProps) => React.ReactElement); /** * Whether the context menu is disabled. * * @default false */ disabled?: boolean; /** * Callback invoked when the popover overlay closes. */ onClose?: () => void; /** * An optional context menu event handler. This can be useful if you want to do something with the * mouse event unrelated to rendering the context menu itself, especially if that involves setting * React state (which is an error to do in the render code path of this component). */ onContextMenu?: React.MouseEventHandler; /** * A limited subset of props to forward along to the popover overlay generated by this component. */ popoverProps?: ContextMenuPopoverOptions; /** * HTML tag to use for container element. Only used if this component's children are specified as * React node(s), not when it is a render function (in that case, you get to render whatever tag * you wish). * * @default "div" */ tagName?: keyof React.JSX.IntrinsicElements; } /** * Context menu component. * * @see https://blueprintjs.com/docs/#core/components/context-menu */ export const ContextMenu: React.FC = forwardRef((props, userRef) => { const { className, children, content, disabled = false, onClose, onContextMenu, popoverProps, tagName = "div", ...restProps } = props; // ancestor TooltipContext state doesn't affect us since we don't care about parent ContextMenus, we only want to // force disable parent Tooltips in certain cases through dispatching actions // N.B. any calls to this dispatch function will be no-ops if there is no TooltipProvider ancestor of this component const [, tooltipCtxDispatch] = useContext(TooltipContext); // click target offset relative to the viewport (e.clientX/clientY), since the target will be rendered in a Portal const [targetOffset, setTargetOffset] = useState(undefined); // hold a reference to the click mouse event to pass to content/child render functions const [mouseEvent, setMouseEvent] = useState>(); const [isOpen, setIsOpen] = useState(false); // we need a ref on the child element (or the wrapper we generate) to check for dark theme const childRef = useRef(null); // If disabled prop is changed, we don't want our old context menu to stick around. // If it has just been enabled (disabled = false), then the menu ought to be opened by // a new mouse event. Users should not be updating this prop in the onContextMenu callback // for this component (that will lead to unpredictable behavior). useEffect(() => { setIsOpen(false); tooltipCtxDispatch({ type: "RESET_DISABLED_STATE" }); }, [disabled, tooltipCtxDispatch]); const handlePopoverClose = useCallback(() => { setIsOpen(false); setMouseEvent(undefined); tooltipCtxDispatch({ type: "RESET_DISABLED_STATE" }); onClose?.(); }, [onClose, tooltipCtxDispatch]); // if the menu was just opened, we should check for dark theme (but don't do this on every render) // eslint-disable-next-line react-hooks/exhaustive-deps const isDarkTheme = useMemo(() => Utils.isDarkTheme(childRef.current), [childRef, isOpen]); const contentProps: ContextMenuContentProps = useMemo( () => ({ isOpen, mouseEvent, targetOffset, }), [isOpen, mouseEvent, targetOffset], ); // create a memoized function to render the menu so that we can call it if necessary in the "contextmenu" event // handler which runs before this render function has a chance to re-run and update the `menu` variable const renderMenu = useCallback( (menuContentProps: ContextMenuContentProps) => disabled ? undefined : Utils.isFunction(content) ? content(menuContentProps) : content, [disabled, content], ); const menuContent = useMemo(() => renderMenu(contentProps), [contentProps, renderMenu]); // only render the popover if there is content in the context menu; // this avoid doing unnecessary rendering & computation const maybePopover = menuContent === undefined ? undefined : ( ); const handleContextMenu = useCallback( (e: React.MouseEvent) => { // support nested menus (inner menu target would have called preventDefault()) if (e.defaultPrevented) { return; } // If disabled, we should avoid the extra work in this event handler. // Otherwise: if using the child or content function APIs, we need to make sure contentProps gets updated, // so we handle the event regardless of whether the consumer returned an undefined menu. const shouldHandleEvent = !disabled && (Utils.isFunction(children) || Utils.isFunction(content) || content !== undefined); if (shouldHandleEvent) { setIsOpen(true); e.persist(); setMouseEvent(e); const newTargetOffset = { left: e.clientX, top: e.clientY }; setTargetOffset(newTargetOffset); tooltipCtxDispatch({ type: "FORCE_DISABLED_STATE" }); const newMenuContent = renderMenu({ isOpen: true, mouseEvent: e, targetOffset: newTargetOffset }); if (newMenuContent === undefined) { // If there is no menu content, we shouldn't automatically swallow the contextmenu event, since the // user probably wants to fall back to default browser behavior. If they still want to disable the // native context menu in that case, they can do so with their own `onContextMenu` handler. } else { e.preventDefault(); } } onContextMenu?.(e); }, [disabled, children, content, onContextMenu, tooltipCtxDispatch, renderMenu], ); const containerClassName = classNames(className, Classes.CONTEXT_MENU); const child = Utils.isFunction(children) ? ( children({ className: containerClassName, contentProps, onContextMenu: handleContextMenu, popover: maybePopover, ref: childRef, }) ) : ( <> {maybePopover} {createElement & React.ClassAttributes>( tagName, { className: containerClassName, onContextMenu: handleContextMenu, ref: mergeRefs(childRef, userRef), ...restProps, }, children, )} ); // force descendant Tooltips to be disabled when this context menu is open return {child}; }); ContextMenu.displayName = `${DISPLAYNAME_PREFIX}.ContextMenu`;