diff --git a/res/css/views/context_menus/_TopLeftMenu.scss b/res/css/views/context_menus/_TopLeftMenu.scss index 9d258bcf55..d17d683e7e 100644 --- a/res/css/views/context_menus/_TopLeftMenu.scss +++ b/res/css/views/context_menus/_TopLeftMenu.scss @@ -49,23 +49,23 @@ limitations under the License. padding: 0; list-style: none; - li.mx_TopLeftMenu_icon_home::after { + .mx_TopLeftMenu_icon_home::after { mask-image: url('$(res)/img/feather-customised/home.svg'); } - li.mx_TopLeftMenu_icon_settings::after { + .mx_TopLeftMenu_icon_settings::after { mask-image: url('$(res)/img/feather-customised/settings.svg'); } - li.mx_TopLeftMenu_icon_signin::after { + .mx_TopLeftMenu_icon_signin::after { mask-image: url('$(res)/img/feather-customised/sign-in.svg'); } - li.mx_TopLeftMenu_icon_signout::after { + .mx_TopLeftMenu_icon_signout::after { mask-image: url('$(res)/img/feather-customised/sign-out.svg'); } - li::after { + .mx_AccessibleButton::after { mask-repeat: no-repeat; mask-position: 0 center; mask-size: 16px; @@ -78,14 +78,14 @@ limitations under the License. background-color: $primary-fg-color; } - li { + .mx_AccessibleButton { position: relative; cursor: pointer; white-space: nowrap; padding: 5px 20px 5px 43px; } - li:hover { + .mx_AccessibleButton:hover { background-color: $menu-selected-color; } } diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js new file mode 100644 index 0000000000..1087f48b28 --- /dev/null +++ b/src/components/structures/ContextMenu.js @@ -0,0 +1,476 @@ +/* +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, {useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import {Key} from "../../Keyboard"; +import sdk from "../../index"; +import AccessibleButton from "../views/elements/AccessibleButton"; + +// 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 ContextualMenuContainerId = "mx_ContextualMenu_Container"; + +function getOrCreateContainer() { + let container = document.getElementById(ContextualMenuContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = ContextualMenuContainerId; + document.body.appendChild(container); + } + + return container; +} + +const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); +// 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 class ContextMenu extends React.Component { + propTypes: { + top: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + menuWidth: PropTypes.number, + menuHeight: PropTypes.number, + chevronOffset: PropTypes.number, + chevronFace: PropTypes.string, // top, bottom, left, right or none + // Function to be called on menu close + onFinished: PropTypes.func.isRequired, + menuPaddingTop: PropTypes.number, + menuPaddingRight: PropTypes.number, + menuPaddingBottom: PropTypes.number, + menuPaddingLeft: PropTypes.number, + zIndex: PropTypes.number, + + // If true, insert an invisible screen-sized element behind the + // menu that when clicked will close it. + hasBackground: PropTypes.bool, + + // on resize callback + windowResize: PropTypes.func, + }; + + static defaultProps = { + hasBackground: true, + }; + + constructor() { + super(); + this.state = { + contextMenuElem: null, + }; + + // persist what had focus when we got initialized so we can return it after + this.initialFocus = document.activeElement; + } + + 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; + + let first = element.querySelector('[role^="menuitem"]'); + if (!first) { + first = element.querySelector('[tab-index]'); + } + if (first) { + first.focus(); + } + + this.setState({ + contextMenuElem: element, + }); + }; + + onContextMenu = (e) => { + if (this.props.onFinished) { + this.props.onFinished(); + + e.preventDefault(); + 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); + }); + } + }; + + _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(); + } + }; + + _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(); + } + } + }; + + _onKeyDown = (ev) => { + let handled = true; + + switch (ev.key) { + case Key.TAB: + case Key.ESCAPE: + this.props.onFinished(); + break; + case Key.ARROW_UP: + this._onMoveFocus(ev.target, true); + break; + case Key.ARROW_DOWN: + this._onMoveFocus(ev.target, false); + break; + case Key.HOME: + this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); + break; + case Key.END: + this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); + break; + default: + handled = false; + } + + if (handled) { + // consume all other keys in context menu + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + renderMenu(hasBackground=this.props.hasBackground) { + const position = {}; + let chevronFace = null; + const props = this.props; + + if (props.top) { + position.top = props.top; + } else { + position.bottom = props.bottom; + } + + if (props.left) { + position.left = props.left; + chevronFace = 'left'; + } else { + position.right = props.right; + chevronFace = 'right'; + } + + const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; + const padding = 10; + + const chevronOffset = {}; + if (props.chevronFace) { + chevronFace = props.chevronFace; + } + const hasChevron = chevronFace && chevronFace !== "none"; + + if (chevronFace === 'top' || chevronFace === 'bottom') { + chevronOffset.left = props.chevronOffset; + } else { + 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) { + adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); + } + + position.top = adjusted; + chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted); + } + + let chevron; + if (hasChevron) { + chevron =
; + } + + 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, + 'mx_ContextualMenu_withChevron_left': chevronFace === 'left', + 'mx_ContextualMenu_withChevron_right': chevronFace === 'right', + 'mx_ContextualMenu_withChevron_top': chevronFace === 'top', + 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', + }); + + const menuStyle = {}; + 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; + if (hasBackground) { + background = ( + + ); + } + + return ( +