1
0
mirror of https://github.com/matrix-org/matrix-react-sdk.git synced 2025-07-28 15:22:05 +03:00

Consolidate all except tooltips

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2019-11-28 18:16:59 +00:00
parent 5a540f5949
commit 6c5b777a77
9 changed files with 231 additions and 196 deletions

View File

@ -16,13 +16,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {useRef, useState} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import {focusCapturedRef} from "../../utils/Accessibility"; import {focusCapturedRef} from "../../utils/Accessibility";
import {Key, KeyCode} from "../../Keyboard"; import {Key, KeyCode} from "../../Keyboard";
import sdk from "../../index"; import sdk from "../../index";
import AccessibleButton from "../views/elements/AccessibleButton";
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -222,7 +223,7 @@ export default class ContextualMenu extends React.Component {
return <div className={className} style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}> return <div className={className} style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} tabIndex={0}> <div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} tabIndex={0}>
{ chevron } { chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} /> <ElementClass {...props} onFinished={props.closeMenu} onResize={props.onFinished} />
</div> </div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle} { props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> } onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
@ -231,8 +232,10 @@ export default class ContextualMenu extends React.Component {
} }
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
// Generic ContextMenu Portal wrapper
class ContextualMenu2 extends React.Component { // 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: { propTypes: {
top: PropTypes.number, top: PropTypes.number,
bottom: PropTypes.number, bottom: PropTypes.number,
@ -243,7 +246,7 @@ class ContextualMenu2 extends React.Component {
chevronOffset: PropTypes.number, chevronOffset: PropTypes.number,
chevronFace: PropTypes.string, // top, bottom, left, right or none chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close // Function to be called on menu close
onFinished: PropTypes.func, onFinished: PropTypes.func.isRequired,
menuPaddingTop: PropTypes.number, menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number, menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number, menuPaddingBottom: PropTypes.number,
@ -258,10 +261,14 @@ class ContextualMenu2 extends React.Component {
windowResize: PropTypes.func, windowResize: PropTypes.func,
}; };
static defaultProps = {
hasBackground: true,
};
constructor() { constructor() {
super(); super();
this.state = { this.state = {
contextMenuRect: null, contextMenuElem: null,
}; };
// persist what had focus when we got initialized so we can return it after // persist what had focus when we got initialized so we can return it after
@ -277,19 +284,22 @@ class ContextualMenu2 extends React.Component {
// We don't need to clean up when unmounting, so ignore // We don't need to clean up when unmounting, so ignore
if (!element) return; if (!element) return;
const first = element.querySelector('[role^="menuitem"]'); let first = element.querySelector('[role^="menuitem"]');
if (!first) {
first = element.querySelector('[tab-index]');
}
if (first) { if (first) {
first.focus(); first.focus();
} }
this.setState({ this.setState({
contextMenuRect: element.getBoundingClientRect(), contextMenuElem: element,
}); });
}; };
onContextMenu = (e) => { onContextMenu = (e) => {
if (this.props.closeMenu) { if (this.props.onFinished) {
this.props.closeMenu(); this.props.onFinished();
e.preventDefault(); e.preventDefault();
const x = e.clientX; const x = e.clientX;
@ -347,13 +357,25 @@ class ContextualMenu2 extends React.Component {
} }
}; };
_onKeyDown = (ev) => { _onMoveFocusHomeEnd = (element, up) => {
let handled = true; 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) => {
switch (ev.key) { switch (ev.key) {
case Key.TAB: case Key.TAB:
case Key.ESCAPE: case Key.ESCAPE:
this.props.closeMenu(); this.props.onFinished();
break; break;
case Key.ARROW_UP: case Key.ARROW_UP:
this._onMoveFocus(ev.target, true); this._onMoveFocus(ev.target, true);
@ -361,14 +383,17 @@ class ContextualMenu2 extends React.Component {
case Key.ARROW_DOWN: case Key.ARROW_DOWN:
this._onMoveFocus(ev.target, false); this._onMoveFocus(ev.target, false);
break; break;
default: case Key.HOME:
handled = false; this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break;
case Key.END:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break;
} }
if (handled) { // consume all other keys in context menu
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
}
}; };
render() { render() {
@ -390,7 +415,7 @@ class ContextualMenu2 extends React.Component {
chevronFace = 'right'; chevronFace = 'right';
} }
const contextMenuRect = this.state.contextMenuRect || null; const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const padding = 10; const padding = 10;
const chevronOffset = {}; const chevronOffset = {};
@ -465,37 +490,36 @@ class ContextualMenu2 extends React.Component {
let background; let background;
if (props.hasBackground) { if (props.hasBackground) {
background = ( background = (
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> <div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.onFinished} onContextMenu={this.onContextMenu} />
); );
} }
return ( const menu = (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}> <div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}> <div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role="menu">
{ chevron } { chevron }
{ props.children } { props.children }
</div> </div>
{ background } { background }
</div> </div>
); );
return ReactDOM.createPortal(menu, getOrCreateContainer());
} }
} }
// Generic ContextMenu Portal wrapper // Semantic component for representing the AccessibleButton which launches a <ContextMenu />
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => {
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
export const ContextMenu = ({children, onFinished, props, hasBackground=true}) => { <AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}>
const menu = <ContextualMenu2 { children }
{...props} </AccessibleButton>
hasBackground={hasBackground} );
closeMenu={onFinished} };
windowResize={onFinished} ContextMenuButton.propTypes = {
> ...AccessibleButton.propTypes,
{ children } label: PropTypes.string.isRequired,
</ContextualMenu2>; isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
return ReactDOM.createPortal(menu, getOrCreateContainer());
}; };
// Semantic component for representing a role=menuitem // Semantic component for representing a role=menuitem
@ -508,6 +532,7 @@ export const MenuItem = ({children, label, ...props}) => {
); );
}; };
MenuItem.propTypes = { MenuItem.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional label: PropTypes.string, // optional
className: PropTypes.string, // optional className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
@ -520,6 +545,7 @@ export const MenuGroup = ({children, label, ...props}) => {
</div>; </div>;
}; };
MenuGroup.propTypes = { MenuGroup.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
className: PropTypes.string, // optional className: PropTypes.string, // optional
}; };
@ -534,6 +560,7 @@ export const MenuItemCheckbox = ({children, label, active=false, disabled=false,
); );
}; };
MenuItemCheckbox.propTypes = { MenuItemCheckbox.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional label: PropTypes.string, // optional
active: PropTypes.bool.isRequired, active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional disabled: PropTypes.bool, // optional
@ -551,6 +578,7 @@ export const MenuItemRadio = ({children, label, active=false, disabled=false, ..
); );
}; };
MenuItemRadio.propTypes = { MenuItemRadio.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional label: PropTypes.string, // optional
active: PropTypes.bool.isRequired, active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional disabled: PropTypes.bool, // optional
@ -566,6 +594,38 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
return {left, top}; return {left, top};
}; };
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeft = (elementRect, chevronFace="none") => {
const menuOptions = { chevronFace };
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 = () => {
const _button = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
};
return [isOpen, _button, open, close, setIsOpen];
};
export function createMenu(ElementClass, props, hasBackground=true) { export function createMenu(ElementClass, props, hasBackground=true) {
const closeMenu = function(...args) { const closeMenu = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer()); ReactDOM.unmountComponentAtNode(getOrCreateContainer());

View File

@ -23,7 +23,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar'; import Avatar from '../../Avatar';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import dis from "../../dispatcher"; import dis from "../../dispatcher";
import {ContextMenu} from "./ContextualMenu"; import {ContextMenu, ContextMenuButton} from "./ContextualMenu";
import sdk from "../../index"; import sdk from "../../index";
const AVATAR_SIZE = 28; const AVATAR_SIZE = 28;
@ -119,29 +119,26 @@ export default class TopLeftMenuButton extends React.Component {
let contextMenu; let contextMenu;
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this._buttonRef.getBoundingClientRect(); const elementRect = this._buttonRef.getBoundingClientRect();
const x = elementRect.left;
const y = elementRect.top + elementRect.height;
const props = { contextMenu = (
chevronFace: "none", <ContextMenu
left: x, chevronFace="none"
top: y, left={elementRect.left}
}; top={elementRect.top + elementRect.height}
onFinished={this.closeMenu}
contextMenu = <ContextMenu props={props} onFinished={this.closeMenu}> >
<TopLeftMenu displayName={name} userId={cli} onFinished={this.closeMenu} /> <TopLeftMenu displayName={name} userId={cli} onFinished={this.closeMenu} />
</ContextMenu>; </ContextMenu>
);
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <React.Fragment> return <React.Fragment>
<AccessibleButton <ContextMenuButton
className="mx_TopLeftMenuButton" className="mx_TopLeftMenuButton"
onClick={this.openMenu} onClick={this.openMenu}
inputRef={(r) => this._buttonRef = r} inputRef={(r) => this._buttonRef = r}
aria-label={_t("Your profile")} label={_t("Your profile")}
aria-haspopup={true} isExpanded={this.state.menuDisplayed}
aria-expanded={this.state.menuDisplayed}
> >
<BaseAvatar <BaseAvatar
idName={MatrixClientPeg.get().getUserId()} idName={MatrixClientPeg.get().getUserId()}
@ -153,7 +150,7 @@ export default class TopLeftMenuButton extends React.Component {
/> />
{ nameElement } { nameElement }
{ chevronElement } { chevronElement }
</AccessibleButton> </ContextMenuButton>
{ contextMenu } { contextMenu }
</React.Fragment>; </React.Fragment>;

View File

@ -17,12 +17,12 @@ limitations under the License.
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import AccessibleButton from '../elements/AccessibleButton'; import {_t} from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames'; import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {ContextMenu} from "../../structures/ContextualMenu"; import {ContextMenu, ContextMenuButton} from "../../structures/ContextualMenu";
export default class MemberStatusMessageAvatar extends React.Component { export default class MemberStatusMessageAvatar extends React.Component {
static propTypes = { static propTypes = {
@ -118,29 +118,33 @@ export default class MemberStatusMessageAvatar extends React.Component {
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this._button.current.getBoundingClientRect(); const elementRect = this._button.current.getBoundingClientRect();
const x = (elementRect.left + window.pageXOffset);
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronOffset = (elementRect.width - chevronWidth) / 2;
const chevronMargin = 1; // Add some spacing away from target const chevronMargin = 1; // Add some spacing away from target
const y = elementRect.top + window.pageYOffset - chevronMargin;
const props = { contextMenu = (
chevronOffset: chevronOffset, <ContextMenu
chevronFace: 'bottom', chevronOffset={(elementRect.width - chevronWidth) / 2}
left: x, chevronFace="bottom"
top: y, left={elementRect.left + window.pageXOffset}
menuWidth: 226, top={elementRect.top + window.pageYOffset - chevronMargin}
}; menuWidth={226}
onFinished={this.closeMenu}
contextMenu = <ContextMenu props={props} onFinished={this.closeMenu}> >
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} /> <StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} />
</ContextMenu>; </ContextMenu>
);
} }
return <React.Fragment> return <React.Fragment>
<AccessibleButton className={classes} inputRef={this._button} onClick={this.openMenu}> <ContextMenuButton
className={classes}
inputRef={this._button}
onClick={this.openMenu}
isExpanded={this.state.menuDisplayed}
label={_t("User Status")}
>
{avatar} {avatar}
</AccessibleButton> </ContextMenuButton>
{ contextMenu } { contextMenu }
</React.Fragment>; </React.Fragment>;

View File

@ -16,8 +16,8 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import sdk from '../../../index';
import {_t} from '../../../languageHandler'; import {_t} from '../../../languageHandler';
import {MenuItem} from "../../structures/ContextualMenu";
export default class WidgetContextMenu extends React.Component { export default class WidgetContextMenu extends React.Component {
static propTypes = { static propTypes = {
@ -71,50 +71,45 @@ export default class WidgetContextMenu extends React.Component {
}; };
render() { render() {
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
const options = []; const options = [];
if (this.props.onEditClicked) { if (this.props.onEditClicked) {
options.push( options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'> <MenuItem className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
{_t("Edit")} {_t("Edit")}
</AccessibleButton>, </MenuItem>,
); );
} }
if (this.props.onReloadClicked) { if (this.props.onReloadClicked) {
options.push( options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} <MenuItem className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} key='reload'>
key='reload'>
{_t("Reload")} {_t("Reload")}
</AccessibleButton>, </MenuItem>,
); );
} }
if (this.props.onSnapshotClicked) { if (this.props.onSnapshotClicked) {
options.push( options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} <MenuItem className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} key='snap'>
key='snap'>
{_t("Take picture")} {_t("Take picture")}
</AccessibleButton>, </MenuItem>,
); );
} }
if (this.props.onDeleteClicked) { if (this.props.onDeleteClicked) {
options.push( options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} <MenuItem className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} key='delete'>
key='delete'>
{_t("Remove for everyone")} {_t("Remove for everyone")}
</AccessibleButton>, </MenuItem>,
); );
} }
// Push this last so it appears last. It's always present. // Push this last so it appears last. It's always present.
options.push( options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'> <MenuItem className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
{_t("Remove for me")} {_t("Remove for me")}
</AccessibleButton>, </MenuItem>,
); );
// Put separators between the options // Put separators between the options

View File

@ -18,7 +18,7 @@ limitations under the License.
import url from 'url'; import url from 'url';
import qs from 'querystring'; import qs from 'querystring';
import React from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import WidgetMessaging from '../../../WidgetMessaging'; import WidgetMessaging from '../../../WidgetMessaging';
@ -35,7 +35,7 @@ import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames'; import classNames from 'classnames';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {createMenu} from "../../structures/ContextualMenu"; import {aboveLeft, ContextMenu, ContextMenuButton} from "../../structures/ContextualMenu";
import PersistedElement from "./PersistedElement"; import PersistedElement from "./PersistedElement";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
@ -62,6 +62,8 @@ export default class AppTile extends React.Component {
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this); this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this); this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef();
} }
/** /**
@ -89,6 +91,7 @@ export default class AppTile extends React.Component {
error: null, error: null,
deleting: false, deleting: false,
widgetPageTitle: newProps.widgetPageTitle, widgetPageTitle: newProps.widgetPageTitle,
menuDisplayed: false,
}; };
} }
@ -555,45 +558,12 @@ export default class AppTile extends React.Component {
this.refs.appFrame.src = this.refs.appFrame.src; this.refs.appFrame.src = this.refs.appFrame.src;
} }
_getMenuOptions(ev) { _onContextMenuClick = () => {
// TODO: This block of code gets copy/pasted a lot. We should make that happen less. this.setState({ menuDisplayed: true });
const menuOptions = {}; };
const buttonRect = ev.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const buttonLeft = buttonRect.left + window.pageXOffset;
const buttonTop = buttonRect.top + window.pageYOffset;
// Align the right edge of the menu to the left edge of the button
menuOptions.right = window.innerWidth - buttonLeft;
// Align the menu vertically on whichever side of the button has more
// space available.
if (buttonTop < window.innerHeight / 2) {
menuOptions.top = buttonTop;
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
}
return menuOptions;
}
_onContextMenuClick = (ev) => { _closeContextMenu = () => {
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); this.setState({ menuDisplayed: false });
const menuOptions = {
...this._getMenuOptions(ev),
// A revoke handler is always required
onRevokeClicked: this._onRevokeClicked,
};
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
if (showEditButton) menuOptions.onEditClicked = this._onEditClick;
if (showDeleteButton) menuOptions.onDeleteClicked = this._onDeleteClick;
if (showPictureSnapshotButton) menuOptions.onSnapshotClicked = this._onSnapshotClick;
if (this.props.showReload) menuOptions.onReloadClicked = this._onReloadWidgetClick;
createMenu(WidgetContextMenu, menuOptions);
}; };
render() { render() {
@ -601,7 +571,7 @@ export default class AppTile extends React.Component {
// Don't render widget if it is in the process of being deleted // Don't render widget if it is in the process of being deleted
if (this.state.deleting) { if (this.state.deleting) {
return <div></div>; return <div />;
} }
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
@ -697,7 +667,31 @@ export default class AppTile extends React.Component {
mx_AppTileMenuBar_expanded: this.props.show, mx_AppTileMenuBar_expanded: this.props.show,
}); });
return ( let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = (
<ContextMenu {...aboveLeft(elementRect, null)} onFinished={this._closeContextMenu}>
<WidgetContextMenu
onRevokeClicked={this._onRevokeClicked}
onEditClicked={showEditButton && this._onEditClick}
onDeleteClicked={showDeleteButton && this._onDeleteClick}
onSnapshotClicked={showPictureSnapshotButton && this._onSnapshotClick}
onReloadClicked={this.props.showReload && this._onReloadWidgetClick}
onFinished={this._closeContextMenu}
/>
</ContextMenu>
);
}
return <React.Fragment>
<div className={appTileClass} id={this.props.id}> <div className={appTileClass} id={this.props.id}>
{ this.props.showMenubar && { this.props.showMenubar &&
<div ref="menu_bar" className={menuBarClasses} onClick={this.onClickMenuBar}> <div ref="menu_bar" className={menuBarClasses} onClick={this.onClickMenuBar}>
@ -725,20 +719,24 @@ export default class AppTile extends React.Component {
onClick={this._onPopoutWidgetClick} onClick={this._onPopoutWidgetClick}
/> } /> }
{ /* Context menu */ } { /* Context menu */ }
{ <AccessibleButton { <ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu" className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
title={_t('More options')} label={_t('More options')}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick} onClick={this._onContextMenuClick}
/> } /> }
</span> </span>
</div> } </div> }
{ appTileBody } { appTileBody }
</div> </div>
);
{ contextMenu }
</React.Fragment>;
} }
} }
AppTile.displayName ='AppTile'; AppTile.displayName = 'AppTile';
AppTile.propTypes = { AppTile.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,

View File

@ -23,13 +23,14 @@ import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import {_t} from '../../../languageHandler';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard'; import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
import * as FormattingUtils from '../../../utils/FormattingUtils'; import * as FormattingUtils from '../../../utils/FormattingUtils';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import TagOrderStore from '../../../stores/TagOrderStore'; import TagOrderStore from '../../../stores/TagOrderStore';
import {ContextMenu, toRightOf} from "../../structures/ContextualMenu"; import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextualMenu";
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents // A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to: // a thing to click on for the user to filter the visible rooms in the RoomList to:
@ -139,7 +140,6 @@ export default createReactClass({
render: function() { render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Tooltip = sdk.getComponent('elements.Tooltip'); const Tooltip = sdk.getComponent('elements.Tooltip');
const profile = this.state.profile || {}; const profile = this.state.profile || {};
const name = profile.name || this.props.tag; const name = profile.name || this.props.tag;
@ -178,14 +178,20 @@ export default createReactClass({
const elementRect = this._contextMenuButton.current.getBoundingClientRect(); const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu'); const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
contextMenu = ( contextMenu = (
<ContextMenu props={toRightOf(elementRect)} onFinished={this.closeMenu}> <ContextMenu {...toRightOf(elementRect)} onFinished={this.closeMenu}>
<TagTileContextMenu tag={this.props.tag} onFinished={this.closeMenu} /> <TagTileContextMenu tag={this.props.tag} onFinished={this.closeMenu} />
</ContextMenu> </ContextMenu>
); );
} }
return <React.Fragment> return <React.Fragment>
<AccessibleButton className={className} onClick={this.onClick} onContextMenu={this.openMenu}> <ContextMenuButton
className={className}
onClick={this.onClick}
onContextMenu={this.openMenu}
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}> <div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar <BaseAvatar
name={name} name={name}
@ -198,7 +204,7 @@ export default createReactClass({
{ contextButton } { contextButton }
{ badgeElement } { badgeElement }
</div> </div>
</AccessibleButton> </ContextMenuButton>
{ contextMenu } { contextMenu }
</React.Fragment>; </React.Fragment>;

View File

@ -22,9 +22,10 @@ import createReactClass from 'create-react-class';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import {_t} from '../../../languageHandler';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientPeg from "../../../MatrixClientPeg"; import MatrixClientPeg from "../../../MatrixClientPeg";
import {ContextMenu, toRightOf} from "../../structures/ContextualMenu"; import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextualMenu";
export default createReactClass({ export default createReactClass({
displayName: 'GroupInviteTile', displayName: 'GroupInviteTile',
@ -124,9 +125,15 @@ export default createReactClass({
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
const badge = ( const badge = (
<AccessibleButton className={badgeClasses} inputRef={this._contextMenuButton} onClick={this.openMenu}> <ContextMenuButton
className={badgeClasses}
inputRef={this._contextMenuButton}
onClick={this.openMenu}
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
>
{ badgeContent } { badgeContent }
</AccessibleButton> </ContextMenuButton>
); );
let tooltip; let tooltip;
@ -146,7 +153,7 @@ export default createReactClass({
const elementRect = this._contextMenuButton.current.getBoundingClientRect(); const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const GroupInviteTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu'); const GroupInviteTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu');
contextMenu = ( contextMenu = (
<ContextMenu props={toRightOf(elementRect)} onFinished={this.closeMenu}> <ContextMenu {...toRightOf(elementRect)} onFinished={this.closeMenu}>
<GroupInviteTileContextMenu group={this.props.group} onFinished={this.closeMenu} /> <GroupInviteTileContextMenu group={this.props.group} onFinished={this.closeMenu} />
</ContextMenu> </ContextMenu>
); );

View File

@ -16,50 +16,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useState, useEffect, useRef} from 'react'; import React, {useEffect} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import {ContextMenu} from '../../structures/ContextualMenu'; import {aboveLeft, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextualMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import {RoomContext} from "../../structures/RoomView"; import {RoomContext} from "../../structures/RoomView";
const contextMenuProps = (elementRect) => {
const menuOptions = {
chevronFace: "none",
};
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;
};
const useContextMenu = () => {
const _button = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
};
return [isOpen, _button, open, close, setIsOpen];
};
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, _button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, _button, openMenu, closeMenu] = useContextMenu();
useEffect(() => { useEffect(() => {
@ -86,7 +53,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
} }
const buttonRect = _button.current.getBoundingClientRect(); const buttonRect = _button.current.getBoundingClientRect();
contextMenu = <ContextMenu props={contextMenuProps(buttonRect)} onFinished={closeMenu}> contextMenu = <ContextMenu {...aboveLeft(buttonRect)} onFinished={closeMenu}>
<MessageContextMenu <MessageContextMenu
mxEvent={mxEvent} mxEvent={mxEvent}
permalinkCreator={permalinkCreator} permalinkCreator={permalinkCreator}
@ -98,14 +65,12 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
</ContextMenu>; </ContextMenu>;
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <React.Fragment> return <React.Fragment>
<AccessibleButton <ContextMenuButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")} label={_t("Options")}
onClick={openMenu} onClick={openMenu}
aria-haspopup={true} isExpanded={menuDisplayed}
aria-expanded={menuDisplayed}
inputRef={_button} inputRef={_button}
/> />
@ -120,19 +85,17 @@ const ReactButton = ({mxEvent, reactions}) => {
if (menuDisplayed) { if (menuDisplayed) {
const buttonRect = _button.current.getBoundingClientRect(); const buttonRect = _button.current.getBoundingClientRect();
const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker'); const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker');
contextMenu = <ContextMenu props={contextMenuProps(buttonRect)} onFinished={closeMenu}> contextMenu = <ContextMenu {...aboveLeft(buttonRect)} onFinished={closeMenu}>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} /> <ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</ContextMenu>; </ContextMenu>;
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <React.Fragment> return <React.Fragment>
<AccessibleButton <ContextMenuButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton"
title={_t("React")} label={_t("React")}
onClick={openMenu} onClick={openMenu}
aria-haspopup={true} isExpanded={menuDisplayed}
aria-expanded={menuDisplayed}
inputRef={_button} inputRef={_button}
/> />
@ -227,7 +190,7 @@ export default class MessageActionBar extends React.PureComponent {
getReplyThread={this.props.getReplyThread} getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile} getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.props.onFocusChange} onFocusChange={this.onFocusChange}
/> />
</div>; </div>;
} }

View File

@ -25,7 +25,7 @@ import dis from '../../../dispatcher';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
import sdk from '../../../index'; import sdk from '../../../index';
import {ContextMenu, toRightOf} from '../../structures/ContextualMenu'; import {ContextMenu, ContextMenuButton, toRightOf} from '../../structures/ContextualMenu';
import * as RoomNotifs from '../../../RoomNotifs'; import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from '../../../utils/FormattingUtils'; import * as FormattingUtils from '../../../utils/FormattingUtils';
import ActiveRoomObserver from '../../../ActiveRoomObserver'; import ActiveRoomObserver from '../../../ActiveRoomObserver';
@ -344,7 +344,12 @@ module.exports = createReactClass({
let contextMenuButton; let contextMenuButton;
if (!MatrixClientPeg.get().isGuest()) { if (!MatrixClientPeg.get().isGuest()) {
contextMenuButton = ( contextMenuButton = (
<AccessibleButton className="mx_RoomTile_menuButton" inputRef={this._contextMenuButton} onClick={this.openMenu} /> <ContextMenuButton
className="mx_RoomTile_menuButton"
inputRef={this._contextMenuButton}
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
onClick={this.openMenu} />
); );
} }
@ -381,7 +386,7 @@ module.exports = createReactClass({
const elementRect = this._contextMenuButton.current.getBoundingClientRect(); const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu'); const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
contextMenu = ( contextMenu = (
<ContextMenu props={toRightOf(elementRect)} onFinished={this.closeMenu}> <ContextMenu {...toRightOf(elementRect)} onFinished={this.closeMenu}>
<RoomTileContextMenu room={this.props.room} onFinished={this.closeMenu} /> <RoomTileContextMenu room={this.props.room} onFinished={this.closeMenu} />
</ContextMenu> </ContextMenu>
); );