2015-11-27 18:37:40 +03:00
|
|
|
/*
|
2020-07-02 01:52:49 +03:00
|
|
|
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.
|
|
|
|
*/
|
2020-07-02 01:51:12 +03:00
|
|
|
|
|
|
|
import React, {CSSProperties, useRef, useState} from "react";
|
|
|
|
import ReactDOM from "react-dom";
|
|
|
|
import classNames from "classnames";
|
|
|
|
|
2019-11-28 23:26:09 +03:00
|
|
|
import {Key} from "../../Keyboard";
|
2020-07-02 01:51:12 +03:00
|
|
|
import AccessibleButton, { IAccessibleButtonProps } from "../views/elements/AccessibleButton";
|
|
|
|
import {Writeable} from "../../@types/common";
|
2015-11-27 18:37:40 +03:00
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
2018-05-10 18:00:58 +03:00
|
|
|
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
function getOrCreateContainer(): HTMLDivElement {
|
|
|
|
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
|
2018-05-10 18:00:58 +03:00
|
|
|
|
|
|
|
if (!container) {
|
|
|
|
container = document.createElement("div");
|
|
|
|
container.id = ContextualMenuContainerId;
|
|
|
|
document.body.appendChild(container);
|
|
|
|
}
|
|
|
|
|
|
|
|
return container;
|
|
|
|
}
|
2015-11-27 18:37:40 +03:00
|
|
|
|
2019-11-11 20:53:17 +03:00
|
|
|
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
|
2020-07-02 01:51:12 +03:00
|
|
|
|
|
|
|
interface IPosition {
|
|
|
|
top?: number;
|
|
|
|
bottom?: number;
|
|
|
|
left?: number;
|
|
|
|
right?: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
export enum ChevronFace {
|
|
|
|
Top = "top",
|
|
|
|
Bottom = "bottom",
|
|
|
|
Left = "left",
|
|
|
|
Right = "right",
|
|
|
|
None = "none",
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IProps extends IPosition {
|
|
|
|
menuWidth?: number;
|
|
|
|
menuHeight?: number;
|
|
|
|
|
|
|
|
chevronOffset?: number;
|
|
|
|
chevronFace?: ChevronFace;
|
|
|
|
|
|
|
|
menuPaddingTop?: number;
|
|
|
|
menuPaddingBottom?: number;
|
|
|
|
menuPaddingLeft?: number;
|
|
|
|
menuPaddingRight?: number;
|
|
|
|
|
|
|
|
zIndex?: number;
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
// Function to be called on menu close
|
|
|
|
onFinished();
|
|
|
|
// on resize callback
|
|
|
|
windowResize();
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IState {
|
|
|
|
contextMenuElem: HTMLDivElement;
|
|
|
|
}
|
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
// 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.
|
2020-07-02 01:51:12 +03:00
|
|
|
export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|
|
|
private initialFocus: HTMLElement;
|
2019-11-11 20:53:17 +03:00
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
static defaultProps = {
|
|
|
|
hasBackground: true,
|
2019-12-19 10:23:05 +03:00
|
|
|
managed: true,
|
2019-11-28 21:16:59 +03:00
|
|
|
};
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
constructor(props, context) {
|
|
|
|
super(props, context);
|
2019-11-11 20:53:17 +03:00
|
|
|
this.state = {
|
2019-11-28 21:16:59 +03:00
|
|
|
contextMenuElem: null,
|
2019-11-11 20:53:17 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
// persist what had focus when we got initialized so we can return it after
|
2020-07-02 01:51:12 +03:00
|
|
|
this.initialFocus = document.activeElement as HTMLElement;
|
2019-11-11 20:53:17 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
// return focus to the thing which had it before us
|
|
|
|
this.initialFocus.focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
collectContextMenuRect = (element) => {
|
|
|
|
// We don't need to clean up when unmounting, so ignore
|
|
|
|
if (!element) return;
|
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
let first = element.querySelector('[role^="menuitem"]');
|
|
|
|
if (!first) {
|
|
|
|
first = element.querySelector('[tab-index]');
|
|
|
|
}
|
2019-11-11 20:53:17 +03:00
|
|
|
if (first) {
|
|
|
|
first.focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
this.setState({
|
2019-11-28 21:16:59 +03:00
|
|
|
contextMenuElem: element,
|
2019-11-11 20:53:17 +03:00
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
onContextMenu = (e) => {
|
2019-11-28 21:16:59 +03:00
|
|
|
if (this.props.onFinished) {
|
|
|
|
this.props.onFinished();
|
2019-11-11 20:53:17 +03:00
|
|
|
|
|
|
|
e.preventDefault();
|
2020-07-02 01:05:37 +03:00
|
|
|
e.stopPropagation();
|
2019-11-11 20:53:17 +03:00
|
|
|
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 = document.createEvent('MouseEvents');
|
|
|
|
clickEvent.initMouseEvent(
|
|
|
|
'contextmenu', true, true, window, 0,
|
|
|
|
0, 0, x, y, false, false,
|
|
|
|
false, false, 0, null,
|
|
|
|
);
|
|
|
|
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-07-02 01:05:37 +03:00
|
|
|
onContextMenuPreventBubbling = (e) => {
|
|
|
|
// 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();
|
|
|
|
};
|
|
|
|
|
2019-11-11 20:53:17 +03:00
|
|
|
_onMoveFocus = (element, up) => {
|
|
|
|
let descending = false; // are we currently descending or ascending through the DOM tree?
|
|
|
|
|
|
|
|
do {
|
|
|
|
const child = up ? element.lastElementChild : element.firstElementChild;
|
|
|
|
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
|
|
|
|
|
|
|
if (descending) {
|
|
|
|
if (child) {
|
|
|
|
element = child;
|
|
|
|
} else if (sibling) {
|
|
|
|
element = sibling;
|
|
|
|
} else {
|
|
|
|
descending = false;
|
|
|
|
element = element.parentElement;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (sibling) {
|
|
|
|
element = sibling;
|
|
|
|
descending = true;
|
|
|
|
} else {
|
|
|
|
element = element.parentElement;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (element) {
|
|
|
|
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
|
|
|
|
element = up ? element.lastElementChild : element.firstElementChild;
|
|
|
|
descending = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
|
|
|
|
|
|
|
|
if (element) {
|
|
|
|
element.focus();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
_onMoveFocusHomeEnd = (element, up) => {
|
|
|
|
let results = element.querySelectorAll('[role^="menuitem"]');
|
|
|
|
if (!results) {
|
|
|
|
results = element.querySelectorAll('[tab-index]');
|
|
|
|
}
|
|
|
|
if (results && results.length) {
|
|
|
|
if (up) {
|
|
|
|
results[0].focus();
|
|
|
|
} else {
|
|
|
|
results[results.length - 1].focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
2019-11-11 20:53:17 +03:00
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
_onKeyDown = (ev) => {
|
2019-12-19 10:23:05 +03:00
|
|
|
if (!this.props.managed) {
|
|
|
|
if (ev.key === Key.ESCAPE) {
|
|
|
|
this.props.onFinished();
|
|
|
|
ev.stopPropagation();
|
|
|
|
ev.preventDefault();
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-28 23:51:21 +03:00
|
|
|
let handled = true;
|
|
|
|
|
2019-11-11 20:53:17 +03:00
|
|
|
switch (ev.key) {
|
|
|
|
case Key.TAB:
|
|
|
|
case Key.ESCAPE:
|
2019-11-28 21:16:59 +03:00
|
|
|
this.props.onFinished();
|
2019-11-11 20:53:17 +03:00
|
|
|
break;
|
|
|
|
case Key.ARROW_UP:
|
|
|
|
this._onMoveFocus(ev.target, true);
|
|
|
|
break;
|
|
|
|
case Key.ARROW_DOWN:
|
|
|
|
this._onMoveFocus(ev.target, false);
|
|
|
|
break;
|
2019-11-28 21:16:59 +03:00
|
|
|
case Key.HOME:
|
|
|
|
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
|
|
|
break;
|
|
|
|
case Key.END:
|
|
|
|
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
|
|
|
break;
|
2019-11-28 23:51:21 +03:00
|
|
|
default:
|
|
|
|
handled = false;
|
2019-11-11 20:53:17 +03:00
|
|
|
}
|
|
|
|
|
2019-11-28 23:51:21 +03:00
|
|
|
if (handled) {
|
|
|
|
// consume all other keys in context menu
|
|
|
|
ev.stopPropagation();
|
|
|
|
ev.preventDefault();
|
|
|
|
}
|
2019-11-11 20:53:17 +03:00
|
|
|
};
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
renderMenu(hasBackground = this.props.hasBackground) {
|
|
|
|
const position: Partial<Writeable<DOMRect>> = {};
|
2019-11-11 20:53:17 +03:00
|
|
|
const props = this.props;
|
|
|
|
|
|
|
|
if (props.top) {
|
|
|
|
position.top = props.top;
|
|
|
|
} else {
|
|
|
|
position.bottom = props.bottom;
|
|
|
|
}
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
let chevronFace: IProps["chevronFace"];
|
2019-11-11 20:53:17 +03:00
|
|
|
if (props.left) {
|
|
|
|
position.left = props.left;
|
2020-07-02 01:51:12 +03:00
|
|
|
chevronFace = ChevronFace.Left;
|
2019-11-11 20:53:17 +03:00
|
|
|
} else {
|
|
|
|
position.right = props.right;
|
2020-07-02 01:51:12 +03:00
|
|
|
chevronFace = ChevronFace.Right;
|
2019-11-11 20:53:17 +03:00
|
|
|
}
|
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
|
2019-11-11 20:53:17 +03:00
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
const chevronOffset: CSSProperties = {};
|
2019-11-11 20:53:17 +03:00
|
|
|
if (props.chevronFace) {
|
|
|
|
chevronFace = props.chevronFace;
|
|
|
|
}
|
2020-07-02 01:51:12 +03:00
|
|
|
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
|
2019-11-11 20:53:17 +03:00
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
|
2019-11-11 20:53:17 +03:00
|
|
|
chevronOffset.left = props.chevronOffset;
|
2020-02-23 17:07:50 +03:00
|
|
|
} else if (position.top !== undefined) {
|
2019-11-11 20:53:17 +03:00
|
|
|
const target = position.top;
|
|
|
|
|
|
|
|
// By default, no adjustment is made
|
|
|
|
let adjusted = target;
|
|
|
|
|
|
|
|
// If we know the dimensions of the context menu, adjust its position
|
|
|
|
// such that it does not leave the (padded) window.
|
|
|
|
if (contextMenuRect) {
|
2020-04-14 19:34:39 +03:00
|
|
|
const padding = 10;
|
|
|
|
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding);
|
2019-11-11 20:53:17 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
position.top = adjusted;
|
|
|
|
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
|
|
|
|
}
|
|
|
|
|
|
|
|
let chevron;
|
|
|
|
if (hasChevron) {
|
|
|
|
chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
|
|
|
|
}
|
|
|
|
|
|
|
|
const menuClasses = classNames({
|
|
|
|
'mx_ContextualMenu': true,
|
|
|
|
'mx_ContextualMenu_left': !hasChevron && position.left,
|
|
|
|
'mx_ContextualMenu_right': !hasChevron && position.right,
|
|
|
|
'mx_ContextualMenu_top': !hasChevron && position.top,
|
|
|
|
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
2020-07-02 01:51:12 +03:00
|
|
|
'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,
|
2019-11-11 20:53:17 +03:00
|
|
|
});
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
const menuStyle: CSSProperties = {};
|
2019-11-11 20:53:17 +03:00
|
|
|
if (props.menuWidth) {
|
|
|
|
menuStyle.width = props.menuWidth;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (props.menuHeight) {
|
|
|
|
menuStyle.height = props.menuHeight;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isNaN(Number(props.menuPaddingTop))) {
|
|
|
|
menuStyle["paddingTop"] = props.menuPaddingTop;
|
|
|
|
}
|
|
|
|
if (!isNaN(Number(props.menuPaddingLeft))) {
|
|
|
|
menuStyle["paddingLeft"] = props.menuPaddingLeft;
|
|
|
|
}
|
|
|
|
if (!isNaN(Number(props.menuPaddingBottom))) {
|
|
|
|
menuStyle["paddingBottom"] = props.menuPaddingBottom;
|
|
|
|
}
|
|
|
|
if (!isNaN(Number(props.menuPaddingRight))) {
|
|
|
|
menuStyle["paddingRight"] = props.menuPaddingRight;
|
|
|
|
}
|
|
|
|
|
|
|
|
const wrapperStyle = {};
|
|
|
|
if (!isNaN(Number(props.zIndex))) {
|
|
|
|
menuStyle["zIndex"] = props.zIndex + 1;
|
|
|
|
wrapperStyle["zIndex"] = props.zIndex;
|
|
|
|
}
|
|
|
|
|
|
|
|
let background;
|
2019-11-28 23:26:09 +03:00
|
|
|
if (hasBackground) {
|
2019-11-11 20:53:17 +03:00
|
|
|
background = (
|
2020-07-02 01:51:12 +03:00
|
|
|
<div
|
|
|
|
className="mx_ContextualMenu_background"
|
|
|
|
style={wrapperStyle}
|
|
|
|
onClick={props.onFinished}
|
|
|
|
onContextMenu={this.onContextMenu}
|
|
|
|
/>
|
2019-11-11 20:53:17 +03:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-11-28 23:26:09 +03:00
|
|
|
return (
|
2020-07-02 01:51:12 +03:00
|
|
|
<div
|
|
|
|
className="mx_ContextualMenu_wrapper"
|
|
|
|
style={{...position, ...wrapperStyle}}
|
|
|
|
onKeyDown={this._onKeyDown}
|
|
|
|
onContextMenu={this.onContextMenuPreventBubbling}
|
|
|
|
>
|
|
|
|
<div
|
|
|
|
className={menuClasses}
|
|
|
|
style={menuStyle}
|
|
|
|
ref={this.collectContextMenuRect}
|
|
|
|
role={this.props.managed ? "menu" : undefined}
|
|
|
|
>
|
2019-11-11 20:53:17 +03:00
|
|
|
{ chevron }
|
|
|
|
{ props.children }
|
|
|
|
</div>
|
|
|
|
{ background }
|
|
|
|
</div>
|
|
|
|
);
|
2019-11-28 23:26:09 +03:00
|
|
|
}
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
render(): React.ReactChild {
|
2019-11-28 23:26:09 +03:00
|
|
|
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
2019-11-11 20:53:17 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-02 01:51:12 +03:00
|
|
|
interface IContextMenuButtonProps extends IAccessibleButtonProps {
|
|
|
|
label?: string;
|
|
|
|
// whether or not the context menu is currently open
|
|
|
|
isExpanded: boolean;
|
|
|
|
}
|
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
2020-07-02 01:51:12 +03:00
|
|
|
export const ContextMenuButton: React.FC<IContextMenuButtonProps> = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
|
2019-11-28 21:16:59 +03:00
|
|
|
return (
|
2020-07-02 01:16:54 +03:00
|
|
|
<AccessibleButton
|
|
|
|
{...props}
|
|
|
|
onClick={onClick}
|
|
|
|
onContextMenu={onContextMenu || onClick}
|
|
|
|
title={label}
|
|
|
|
aria-label={label}
|
|
|
|
aria-haspopup={true}
|
|
|
|
aria-expanded={isExpanded}
|
|
|
|
>
|
2019-11-28 21:16:59 +03:00
|
|
|
{ children }
|
|
|
|
</AccessibleButton>
|
|
|
|
);
|
|
|
|
};
|
2020-07-02 01:51:12 +03:00
|
|
|
|
|
|
|
interface IMenuItemProps extends IAccessibleButtonProps {
|
|
|
|
label?: string;
|
|
|
|
className?: string;
|
|
|
|
onClick();
|
|
|
|
}
|
2019-11-11 20:53:17 +03:00
|
|
|
|
|
|
|
// Semantic component for representing a role=menuitem
|
2020-07-02 01:51:12 +03:00
|
|
|
export const MenuItem: React.FC<IMenuItemProps> = ({children, label, ...props}) => {
|
2019-11-11 20:53:17 +03:00
|
|
|
return (
|
|
|
|
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
|
|
|
|
{ children }
|
|
|
|
</AccessibleButton>
|
|
|
|
);
|
|
|
|
};
|
2020-07-02 01:51:12 +03:00
|
|
|
|
|
|
|
interface IMenuGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
|
label: string;
|
|
|
|
className?: string;
|
|
|
|
}
|
2019-11-11 20:53:17 +03:00
|
|
|
|
|
|
|
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
2020-07-02 01:51:12 +03:00
|
|
|
export const MenuGroup: React.FC<IMenuGroupProps> = ({children, label, ...props}) => {
|
2019-11-11 20:53:17 +03:00
|
|
|
return <div {...props} role="group" aria-label={label}>
|
|
|
|
{ children }
|
|
|
|
</div>;
|
|
|
|
};
|
2020-07-02 01:51:12 +03:00
|
|
|
|
|
|
|
interface IMenuItemCheckboxProps extends IAccessibleButtonProps {
|
|
|
|
label?: string;
|
|
|
|
active: boolean;
|
|
|
|
disabled?: boolean;
|
|
|
|
className?: string;
|
|
|
|
onClick();
|
|
|
|
}
|
2019-11-11 20:53:17 +03:00
|
|
|
|
|
|
|
// Semantic component for representing a role=menuitemcheckbox
|
2020-07-02 01:51:12 +03:00
|
|
|
export const MenuItemCheckbox: React.FC<IMenuItemCheckboxProps> = ({children, label, active = false, disabled = false, ...props}) => {
|
2019-11-11 20:53:17 +03:00
|
|
|
return (
|
|
|
|
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
|
|
|
{ children }
|
|
|
|
</AccessibleButton>
|
|
|
|
);
|
|
|
|
};
|
2020-07-02 01:51:12 +03:00
|
|
|
|
|
|
|
interface IMenuItemRadioProps extends IAccessibleButtonProps {
|
|
|
|
label?: string;
|
|
|
|
active: boolean;
|
|
|
|
disabled?: boolean;
|
|
|
|
className?: string;
|
|
|
|
onClick();
|
|
|
|
}
|
2019-11-11 20:53:17 +03:00
|
|
|
|
|
|
|
// Semantic component for representing a role=menuitemradio
|
2020-07-02 01:51:12 +03:00
|
|
|
export const MenuItemRadio: React.FC<IMenuItemRadioProps> = ({children, label, active = false, disabled = false, ...props}) => {
|
2019-11-11 20:53:17 +03:00
|
|
|
return (
|
|
|
|
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
|
|
|
{ children }
|
|
|
|
</AccessibleButton>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2019-11-12 14:24:14 +03:00
|
|
|
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
2020-07-02 01:51:12 +03:00
|
|
|
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
|
2019-11-12 14:24:14 +03:00
|
|
|
const left = elementRect.right + window.pageXOffset + 3;
|
2019-11-28 23:26:09 +03:00
|
|
|
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
|
|
|
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
2019-12-10 02:58:09 +03:00
|
|
|
return {left, top, chevronOffset};
|
2019-11-12 14:24:14 +03:00
|
|
|
};
|
|
|
|
|
2019-11-28 21:16:59 +03:00
|
|
|
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
2020-07-02 01:51:12 +03:00
|
|
|
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
|
|
|
|
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
2019-11-28 21:16:59 +03:00
|
|
|
|
|
|
|
const buttonRight = elementRect.right + window.pageXOffset;
|
|
|
|
const buttonBottom = elementRect.bottom + window.pageYOffset;
|
|
|
|
const buttonTop = elementRect.top + window.pageYOffset;
|
|
|
|
// Align the right edge of the menu to the right edge of the button
|
|
|
|
menuOptions.right = window.innerWidth - buttonRight;
|
|
|
|
// Align the menu vertically on whichever side of the button has more space available.
|
|
|
|
if (buttonBottom < window.innerHeight / 2) {
|
|
|
|
menuOptions.top = buttonBottom;
|
|
|
|
} else {
|
|
|
|
menuOptions.bottom = window.innerHeight - buttonTop;
|
|
|
|
}
|
|
|
|
|
|
|
|
return menuOptions;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const useContextMenu = () => {
|
2019-12-03 13:53:32 +03:00
|
|
|
const button = useRef(null);
|
2019-11-28 21:16:59 +03:00
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const open = () => {
|
|
|
|
setIsOpen(true);
|
|
|
|
};
|
|
|
|
const close = () => {
|
|
|
|
setIsOpen(false);
|
|
|
|
};
|
|
|
|
|
2019-12-03 13:53:32 +03:00
|
|
|
return [isOpen, button, open, close, setIsOpen];
|
2019-11-28 21:16:59 +03:00
|
|
|
};
|
|
|
|
|
2019-11-28 23:26:09 +03:00
|
|
|
export default class LegacyContextMenu extends ContextMenu {
|
|
|
|
render() {
|
|
|
|
return this.renderMenu(false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
|
|
|
|
export function createMenu(ElementClass, props) {
|
|
|
|
const onFinished = function(...args) {
|
2018-05-10 19:51:49 +03:00
|
|
|
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
|
2018-05-10 18:00:58 +03:00
|
|
|
|
2018-05-10 19:51:49 +03:00
|
|
|
if (props && props.onFinished) {
|
|
|
|
props.onFinished.apply(null, args);
|
|
|
|
}
|
|
|
|
};
|
2018-05-10 18:00:58 +03:00
|
|
|
|
2019-11-28 23:26:09 +03:00
|
|
|
const menu = <LegacyContextMenu
|
2018-05-10 19:51:49 +03:00
|
|
|
{...props}
|
2019-11-28 23:26:09 +03:00
|
|
|
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
|
|
|
|
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
|
|
|
|
>
|
|
|
|
<ElementClass {...props} onFinished={onFinished} />
|
|
|
|
</LegacyContextMenu>;
|
2015-11-27 18:37:40 +03:00
|
|
|
|
2018-05-10 19:51:49 +03:00
|
|
|
ReactDOM.render(menu, getOrCreateContainer());
|
2015-11-27 18:37:40 +03:00
|
|
|
|
2019-11-28 23:26:09 +03:00
|
|
|
return {close: onFinished};
|
2018-05-10 19:51:49 +03:00
|
|
|
}
|