4574c665ea
* Conform more code to strict null checking * Delint * Iterate PR based on feedback
654 lines
24 KiB
TypeScript
654 lines
24 KiB
TypeScript
/*
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
|
Copyright 2018 New Vector Ltd
|
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
|
|
|
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 React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
|
|
import ReactDOM from "react-dom";
|
|
import classNames from "classnames";
|
|
import FocusLock from "react-focus-lock";
|
|
|
|
import { Writeable } from "../../@types/common";
|
|
import UIStore from "../../stores/UIStore";
|
|
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
|
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
|
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
|
import Modal, { ModalManagerEvent } from "../../Modal";
|
|
|
|
// Shamelessly ripped off Modal.js. There's probably a better way
|
|
// of doing reusable widgets like dialog boxes & menus where we go and
|
|
// pass in a custom control as the actual body.
|
|
|
|
const WINDOW_PADDING = 10;
|
|
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
|
|
|
|
function getOrCreateContainer(): HTMLDivElement {
|
|
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
|
|
|
|
if (!container) {
|
|
container = document.createElement("div");
|
|
container.id = ContextualMenuContainerId;
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
return container;
|
|
}
|
|
|
|
export interface IPosition {
|
|
top?: number;
|
|
bottom?: number;
|
|
left?: number;
|
|
right?: number;
|
|
rightAligned?: boolean;
|
|
bottomAligned?: boolean;
|
|
}
|
|
|
|
export enum ChevronFace {
|
|
Top = "top",
|
|
Bottom = "bottom",
|
|
Left = "left",
|
|
Right = "right",
|
|
None = "none",
|
|
}
|
|
|
|
export interface MenuProps extends IPosition {
|
|
menuWidth?: number;
|
|
menuHeight?: number;
|
|
|
|
chevronOffset?: number;
|
|
chevronFace?: ChevronFace;
|
|
|
|
menuPaddingTop?: number;
|
|
menuPaddingBottom?: number;
|
|
menuPaddingLeft?: number;
|
|
menuPaddingRight?: number;
|
|
|
|
zIndex?: number;
|
|
}
|
|
|
|
export interface IProps extends MenuProps {
|
|
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
|
|
hasBackground?: boolean;
|
|
// whether this context menu should be focus managed. If false it must handle itself
|
|
managed?: boolean;
|
|
wrapperClassName?: string;
|
|
menuClassName?: string;
|
|
|
|
// If true, this context menu will be mounted as a child to the parent container. Otherwise
|
|
// it will be mounted to a container at the root of the DOM.
|
|
mountAsChild?: boolean;
|
|
|
|
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
|
|
// within an existing FocusLock e.g inside a modal.
|
|
focusLock?: boolean;
|
|
|
|
// call onFinished on any interaction with the menu
|
|
closeOnInteraction?: boolean;
|
|
|
|
// Function to be called on menu close
|
|
onFinished(): void;
|
|
// on resize callback
|
|
windowResize?(): void;
|
|
}
|
|
|
|
interface IState {
|
|
contextMenuElem?: HTMLDivElement;
|
|
}
|
|
|
|
// Generic ContextMenu Portal wrapper
|
|
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
|
|
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
|
|
export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
|
private readonly initialFocus: HTMLElement;
|
|
|
|
public static defaultProps = {
|
|
hasBackground: true,
|
|
managed: true,
|
|
};
|
|
|
|
public constructor(props: IProps) {
|
|
super(props);
|
|
|
|
this.state = {};
|
|
|
|
// persist what had focus when we got initialized so we can return it after
|
|
this.initialFocus = document.activeElement as HTMLElement;
|
|
}
|
|
|
|
public componentDidMount(): void {
|
|
Modal.on(ModalManagerEvent.Opened, this.onModalOpen);
|
|
}
|
|
|
|
public componentWillUnmount(): void {
|
|
Modal.off(ModalManagerEvent.Opened, this.onModalOpen);
|
|
// return focus to the thing which had it before us
|
|
this.initialFocus.focus();
|
|
}
|
|
|
|
private onModalOpen = (): void => {
|
|
this.props.onFinished?.();
|
|
};
|
|
|
|
private collectContextMenuRect = (element: HTMLDivElement): void => {
|
|
// We don't need to clean up when unmounting, so ignore
|
|
if (!element) return;
|
|
|
|
const first =
|
|
element.querySelector<HTMLElement>('[role^="menuitem"]') ||
|
|
element.querySelector<HTMLElement>("[tab-index]");
|
|
|
|
if (first) {
|
|
first.focus();
|
|
}
|
|
|
|
this.setState({
|
|
contextMenuElem: element,
|
|
});
|
|
};
|
|
|
|
private onContextMenu = (e: React.MouseEvent): void => {
|
|
if (this.props.onFinished) {
|
|
this.props.onFinished();
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const x = e.clientX;
|
|
const y = e.clientY;
|
|
|
|
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
|
|
// a context menu and its click-guard are up without completely rewriting how the context menus work.
|
|
setImmediate(() => {
|
|
const clickEvent = new MouseEvent("contextmenu", {
|
|
clientX: x,
|
|
clientY: y,
|
|
screenX: 0,
|
|
screenY: 0,
|
|
button: 0, // Left
|
|
relatedTarget: null,
|
|
});
|
|
document.elementFromPoint(x, y)?.dispatchEvent(clickEvent);
|
|
});
|
|
}
|
|
};
|
|
|
|
private onContextMenuPreventBubbling = (e: React.MouseEvent): void => {
|
|
// stop propagation so that any context menu handlers don't leak out of this context menu
|
|
// but do not inhibit the default browser menu
|
|
e.stopPropagation();
|
|
};
|
|
|
|
// Prevent clicks on the background from going through to the component which opened the menu.
|
|
private onFinished = (ev: React.MouseEvent): void => {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
this.props.onFinished?.();
|
|
};
|
|
|
|
private onClick = (ev: React.MouseEvent): void => {
|
|
// Don't allow clicks to escape the context menu wrapper
|
|
ev.stopPropagation();
|
|
|
|
if (this.props.closeOnInteraction) {
|
|
this.props.onFinished?.();
|
|
}
|
|
};
|
|
|
|
// We now only handle closing the ContextMenu in this keyDown handler.
|
|
// All of the item/option navigation is delegated to RovingTabIndex.
|
|
private onKeyDown = (ev: React.KeyboardEvent): void => {
|
|
ev.stopPropagation(); // prevent keyboard propagating out of the context menu, we're focus-locked
|
|
|
|
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
|
|
|
// If someone is managing their own focus, we will only exit for them with Escape.
|
|
// They are probably using props.focusLock along with this option as well.
|
|
if (!this.props.managed) {
|
|
if (action === KeyBindingAction.Escape) {
|
|
this.props.onFinished();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// When an <input> is focused, only handle the Escape key
|
|
if (checkInputableElement(ev.target as HTMLElement) && action !== KeyBindingAction.Escape) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
[
|
|
KeyBindingAction.Escape,
|
|
// You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex).
|
|
// Tabbing to the next section of the page, will close the ContextMenu.
|
|
KeyBindingAction.Tab,
|
|
// When someone moves left or right along a <Toolbar /> (like the
|
|
// MessageActionBar), we should close any ContextMenu that is open.
|
|
KeyBindingAction.ArrowLeft,
|
|
KeyBindingAction.ArrowRight,
|
|
].includes(action!)
|
|
) {
|
|
this.props.onFinished();
|
|
}
|
|
};
|
|
|
|
protected renderMenu(hasBackground = this.props.hasBackground): JSX.Element {
|
|
const position: Partial<Writeable<DOMRect>> = {};
|
|
const {
|
|
top,
|
|
bottom,
|
|
left,
|
|
right,
|
|
bottomAligned,
|
|
rightAligned,
|
|
menuClassName,
|
|
menuHeight,
|
|
menuWidth,
|
|
menuPaddingLeft,
|
|
menuPaddingRight,
|
|
menuPaddingBottom,
|
|
menuPaddingTop,
|
|
zIndex,
|
|
children,
|
|
focusLock,
|
|
managed,
|
|
wrapperClassName,
|
|
chevronFace: propsChevronFace,
|
|
chevronOffset: propsChevronOffset,
|
|
...props
|
|
} = this.props;
|
|
|
|
if (top) {
|
|
position.top = top;
|
|
} else {
|
|
position.bottom = bottom;
|
|
}
|
|
|
|
let chevronFace: ChevronFace;
|
|
if (left) {
|
|
position.left = left;
|
|
chevronFace = ChevronFace.Left;
|
|
} else {
|
|
position.right = right;
|
|
chevronFace = ChevronFace.Right;
|
|
}
|
|
|
|
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
|
|
|
|
const chevronOffset: CSSProperties = {};
|
|
if (propsChevronFace) {
|
|
chevronFace = propsChevronFace;
|
|
}
|
|
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
|
|
|
|
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
|
|
chevronOffset.left = propsChevronOffset;
|
|
} else {
|
|
chevronOffset.top = propsChevronOffset;
|
|
}
|
|
|
|
// If we know the dimensions of the context menu, adjust its position to
|
|
// keep it within the bounds of the (padded) window
|
|
const { windowWidth, windowHeight } = UIStore.instance;
|
|
if (contextMenuRect) {
|
|
if (position.top !== undefined) {
|
|
let maxTop = windowHeight - WINDOW_PADDING;
|
|
if (!bottomAligned) {
|
|
maxTop -= contextMenuRect.height;
|
|
}
|
|
position.top = Math.min(position.top, maxTop);
|
|
// Adjust the chevron if necessary
|
|
if (chevronOffset.top !== undefined) {
|
|
chevronOffset.top = propsChevronOffset! + top! - position.top;
|
|
}
|
|
} else if (position.bottom !== undefined) {
|
|
position.bottom = Math.min(position.bottom, windowHeight - contextMenuRect.height - WINDOW_PADDING);
|
|
if (chevronOffset.top !== undefined) {
|
|
chevronOffset.top = propsChevronOffset! + position.bottom - bottom!;
|
|
}
|
|
}
|
|
if (position.left !== undefined) {
|
|
let maxLeft = windowWidth - WINDOW_PADDING;
|
|
if (!rightAligned) {
|
|
maxLeft -= contextMenuRect.width;
|
|
}
|
|
position.left = Math.min(position.left, maxLeft);
|
|
if (chevronOffset.left !== undefined) {
|
|
chevronOffset.left = propsChevronOffset! + left! - position.left;
|
|
}
|
|
} else if (position.right !== undefined) {
|
|
position.right = Math.min(position.right, windowWidth - contextMenuRect.width - WINDOW_PADDING);
|
|
if (chevronOffset.left !== undefined) {
|
|
chevronOffset.left = propsChevronOffset! + position.right - right!;
|
|
}
|
|
}
|
|
}
|
|
|
|
let chevron;
|
|
if (hasChevron) {
|
|
chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
|
|
}
|
|
|
|
const menuClasses = classNames(
|
|
{
|
|
mx_ContextualMenu: true,
|
|
/**
|
|
* In some cases we may get the number of 0, which still means that we're supposed to properly
|
|
* add the specific position class, but as it was falsy things didn't work as intended.
|
|
* In addition, defensively check for counter cases where we may get more than one value,
|
|
* even if we shouldn't.
|
|
*/
|
|
mx_ContextualMenu_left: !hasChevron && position.left !== undefined && !position.right,
|
|
mx_ContextualMenu_right: !hasChevron && position.right !== undefined && !position.left,
|
|
mx_ContextualMenu_top: !hasChevron && position.top !== undefined && !position.bottom,
|
|
mx_ContextualMenu_bottom: !hasChevron && position.bottom !== undefined && !position.top,
|
|
mx_ContextualMenu_withChevron_left: chevronFace === ChevronFace.Left,
|
|
mx_ContextualMenu_withChevron_right: chevronFace === ChevronFace.Right,
|
|
mx_ContextualMenu_withChevron_top: chevronFace === ChevronFace.Top,
|
|
mx_ContextualMenu_withChevron_bottom: chevronFace === ChevronFace.Bottom,
|
|
mx_ContextualMenu_rightAligned: rightAligned === true,
|
|
mx_ContextualMenu_bottomAligned: bottomAligned === true,
|
|
},
|
|
menuClassName,
|
|
);
|
|
|
|
const menuStyle: CSSProperties = {};
|
|
if (menuWidth) {
|
|
menuStyle.width = menuWidth;
|
|
}
|
|
|
|
if (menuHeight) {
|
|
menuStyle.height = menuHeight;
|
|
}
|
|
|
|
if (!isNaN(Number(menuPaddingTop))) {
|
|
menuStyle["paddingTop"] = menuPaddingTop;
|
|
}
|
|
if (!isNaN(Number(menuPaddingLeft))) {
|
|
menuStyle["paddingLeft"] = menuPaddingLeft;
|
|
}
|
|
if (!isNaN(Number(menuPaddingBottom))) {
|
|
menuStyle["paddingBottom"] = menuPaddingBottom;
|
|
}
|
|
if (!isNaN(Number(menuPaddingRight))) {
|
|
menuStyle["paddingRight"] = menuPaddingRight;
|
|
}
|
|
|
|
const wrapperStyle: CSSProperties = {};
|
|
if (!isNaN(Number(zIndex))) {
|
|
menuStyle["zIndex"] = zIndex! + 1;
|
|
wrapperStyle["zIndex"] = zIndex;
|
|
}
|
|
|
|
let background: JSX.Element;
|
|
if (hasBackground) {
|
|
background = (
|
|
<div
|
|
className="mx_ContextualMenu_background"
|
|
style={wrapperStyle}
|
|
onClick={this.onFinished}
|
|
onContextMenu={this.onContextMenu}
|
|
/>
|
|
);
|
|
}
|
|
|
|
let body = (
|
|
<>
|
|
{chevron}
|
|
{children}
|
|
</>
|
|
);
|
|
|
|
if (focusLock) {
|
|
body = <FocusLock>{body}</FocusLock>;
|
|
}
|
|
|
|
// filter props that are invalid for DOM elements
|
|
const {
|
|
hasBackground: _hasBackground, // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
onFinished: _onFinished, // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
...divProps
|
|
} = props;
|
|
|
|
return (
|
|
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}>
|
|
{({ onKeyDownHandler }) => (
|
|
<div
|
|
className={classNames("mx_ContextualMenu_wrapper", wrapperClassName)}
|
|
style={{ ...position, ...wrapperStyle }}
|
|
onClick={this.onClick}
|
|
onKeyDown={onKeyDownHandler}
|
|
onContextMenu={this.onContextMenuPreventBubbling}
|
|
>
|
|
{background}
|
|
<div
|
|
className={menuClasses}
|
|
style={menuStyle}
|
|
ref={this.collectContextMenuRect}
|
|
role={managed ? "menu" : undefined}
|
|
{...divProps}
|
|
>
|
|
{body}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</RovingTabIndexProvider>
|
|
);
|
|
}
|
|
|
|
public render(): React.ReactChild {
|
|
if (this.props.mountAsChild) {
|
|
// Render as a child of the current parent
|
|
return this.renderMenu();
|
|
} else {
|
|
// Render as a child of a container at the root of the DOM
|
|
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
|
}
|
|
}
|
|
}
|
|
|
|
export type ToRightOf = {
|
|
left: number;
|
|
top: number;
|
|
chevronOffset: number;
|
|
};
|
|
|
|
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
|
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
|
|
const left = elementRect.right + window.scrollX + 3;
|
|
let top = elementRect.top + elementRect.height / 2 + window.scrollY;
|
|
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
|
return { left, top, chevronOffset };
|
|
};
|
|
|
|
export type ToLeftOf = {
|
|
chevronOffset: number;
|
|
right: number;
|
|
top: number;
|
|
};
|
|
|
|
// Placement method for <ContextMenu /> to position context menu to left of elementRect with chevronOffset
|
|
export const toLeftOf = (elementRect: DOMRect, chevronOffset = 12): ToLeftOf => {
|
|
const right = UIStore.instance.windowWidth - elementRect.left + window.scrollX - 3;
|
|
let top = elementRect.top + elementRect.height / 2 + window.scrollY;
|
|
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
|
return { right, top, chevronOffset };
|
|
};
|
|
|
|
/**
|
|
* Placement method for <ContextMenu /> to position context menu of or right of elementRect
|
|
* depending on which side has more space.
|
|
*/
|
|
export const toLeftOrRightOf = (elementRect: DOMRect, chevronOffset = 12): ToRightOf | ToLeftOf => {
|
|
const spaceToTheLeft = elementRect.left;
|
|
const spaceToTheRight = UIStore.instance.windowWidth - elementRect.right;
|
|
|
|
if (spaceToTheLeft > spaceToTheRight) {
|
|
return toLeftOf(elementRect, chevronOffset);
|
|
}
|
|
|
|
return toRightOf(elementRect, chevronOffset);
|
|
};
|
|
|
|
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
|
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
|
export const aboveLeftOf = (
|
|
elementRect: Pick<DOMRect, "right" | "top" | "bottom">,
|
|
chevronFace = ChevronFace.None,
|
|
vPadding = 0,
|
|
): MenuProps => {
|
|
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
|
|
|
const buttonRight = elementRect.right + window.scrollX;
|
|
const buttonBottom = elementRect.bottom + window.scrollY;
|
|
const buttonTop = elementRect.top + window.scrollY;
|
|
// Align the right edge of the menu to the right edge of the button
|
|
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
|
|
// Align the menu vertically on whichever side of the button has more space available.
|
|
if (buttonBottom < UIStore.instance.windowHeight / 2) {
|
|
menuOptions.top = buttonBottom + vPadding;
|
|
} else {
|
|
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
|
}
|
|
|
|
return menuOptions;
|
|
};
|
|
|
|
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect,
|
|
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowRightOf?)
|
|
export const aboveRightOf = (
|
|
elementRect: Pick<DOMRect, "left" | "top" | "bottom">,
|
|
chevronFace = ChevronFace.None,
|
|
vPadding = 0,
|
|
): MenuProps => {
|
|
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
|
|
|
const buttonLeft = elementRect.left + window.scrollX;
|
|
const buttonBottom = elementRect.bottom + window.scrollY;
|
|
const buttonTop = elementRect.top + window.scrollY;
|
|
// Align the left edge of the menu to the left edge of the button
|
|
menuOptions.left = buttonLeft;
|
|
// Align the menu vertically on whichever side of the button has more space available.
|
|
if (buttonBottom < UIStore.instance.windowHeight / 2) {
|
|
menuOptions.top = buttonBottom + vPadding;
|
|
} else {
|
|
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
|
}
|
|
|
|
return menuOptions;
|
|
};
|
|
|
|
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
|
// and always above elementRect
|
|
export const alwaysMenuProps = (
|
|
elementRect: Pick<DOMRect, "right" | "bottom" | "top">,
|
|
chevronFace = ChevronFace.None,
|
|
vPadding = 0,
|
|
): IPosition & { chevronFace: ChevronFace } => {
|
|
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
|
|
|
const buttonRight = elementRect.right + window.scrollX;
|
|
const buttonTop = elementRect.top + window.scrollY;
|
|
// Align the right edge of the menu to the right edge of the button
|
|
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
|
|
// Align the menu vertically above the menu
|
|
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
|
|
|
return menuOptions;
|
|
};
|
|
|
|
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
|
|
// and always above elementRect
|
|
export const alwaysAboveRightOf = (
|
|
elementRect: Pick<DOMRect, "left" | "top">,
|
|
chevronFace = ChevronFace.None,
|
|
vPadding = 0,
|
|
): IPosition & { chevronFace: ChevronFace } => {
|
|
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
|
|
|
const buttonLeft = elementRect.left + window.scrollX;
|
|
const buttonTop = elementRect.top + window.scrollY;
|
|
// Align the left edge of the menu to the left edge of the button
|
|
menuOptions.left = buttonLeft;
|
|
// Align the menu vertically above the menu
|
|
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
|
|
|
return menuOptions;
|
|
};
|
|
|
|
type ContextMenuTuple<T> = [
|
|
boolean,
|
|
RefObject<T>,
|
|
(ev?: SyntheticEvent) => void,
|
|
(ev?: SyntheticEvent) => void,
|
|
(val: boolean) => void,
|
|
];
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
|
|
export const useContextMenu = <T extends any = HTMLElement>(inputRef?: RefObject<T>): ContextMenuTuple<T> => {
|
|
let button = useRef<T>(null);
|
|
if (inputRef) {
|
|
// if we are given a ref, use it instead of ours
|
|
button = inputRef;
|
|
}
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const open = (ev?: SyntheticEvent): void => {
|
|
ev?.preventDefault();
|
|
ev?.stopPropagation();
|
|
setIsOpen(true);
|
|
};
|
|
const close = (ev?: SyntheticEvent): void => {
|
|
ev?.preventDefault();
|
|
ev?.stopPropagation();
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return [button.current ? isOpen : false, button, open, close, setIsOpen];
|
|
};
|
|
|
|
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
|
|
export function createMenu(
|
|
ElementClass: typeof React.Component,
|
|
props: Record<string, any>,
|
|
): { close: (...args: any[]) => void } {
|
|
const onFinished = function (...args: any[]): void {
|
|
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
|
|
props?.onFinished?.apply(null, args);
|
|
};
|
|
|
|
const menu = (
|
|
<ContextMenu
|
|
{...props}
|
|
mountAsChild={true}
|
|
hasBackground={false}
|
|
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
|
|
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
|
|
>
|
|
<ElementClass {...props} onFinished={onFinished} />
|
|
</ContextMenu>
|
|
);
|
|
|
|
ReactDOM.render(menu, getOrCreateContainer());
|
|
|
|
return { close: onFinished };
|
|
}
|
|
|
|
// re-export the semantic helper components for simplicity
|
|
export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
|
export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton";
|
|
export { MenuItem } from "../../accessibility/context_menu/MenuItem";
|
|
export { MenuItemCheckbox } from "../../accessibility/context_menu/MenuItemCheckbox";
|
|
export { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
|
|
export { StyledMenuItemCheckbox } from "../../accessibility/context_menu/StyledMenuItemCheckbox";
|
|
export { StyledMenuItemRadio } from "../../accessibility/context_menu/StyledMenuItemRadio";
|