Merge pull request #3611 from matrix-org/t3chguy/context_menus

ARIA compliant context menus
This commit is contained in:
Michael Telatynski 2019-12-04 17:17:47 +00:00 committed by GitHub
commit be6da03348
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1093 additions and 853 deletions

View file

@ -1,6 +1,7 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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.
@ -15,17 +16,93 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import Modal from '../../../Modal';
import { createMenu } from '../../structures/ContextualMenu';
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import {RoomContext} from "../../structures/RoomView";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
let contextMenu;
if (menuDisplayed) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
const onCryptoClick = () => {
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
import('../../../async-components/views/dialogs/EncryptedEventDialog'),
{event: mxEvent},
);
};
let e2eInfoCallback = null;
if (mxEvent.isEncrypted()) {
e2eInfoCallback = onCryptoClick;
}
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<MessageContextMenu
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyThread={replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined}
e2eInfoCallback={e2eInfoCallback}
onFinished={closeMenu}
/>
</ContextMenu>;
}
return <React.Fragment>
<ContextMenuButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
label={_t("Options")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={button}
/>
{ contextMenu }
</React.Fragment>;
};
const ReactButton = ({mxEvent, reactions}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker');
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</ContextMenu>;
}
return <React.Fragment>
<ContextMenuButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton"
label={_t("React")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={button}
/>
{ contextMenu }
</React.Fragment>;
};
export default class MessageActionBar extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
@ -62,14 +139,6 @@ export default class MessageActionBar extends React.PureComponent {
this.props.onFocusChange(focused);
};
onCryptoClick = () => {
const event = this.props.mxEvent;
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
import('../../../async-components/views/dialogs/EncryptedEventDialog'),
{event},
);
};
onReplyClick = (ev) => {
dis.dispatch({
action: 'reply_to_event',
@ -84,71 +153,6 @@ export default class MessageActionBar extends React.PureComponent {
});
};
getMenuOptions = (ev) => {
const menuOptions = {};
const buttonRect = ev.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const buttonRight = buttonRect.right + window.pageXOffset;
const buttonBottom = buttonRect.bottom + window.pageYOffset;
const buttonTop = buttonRect.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;
};
onReactClick = (ev) => {
const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker');
const menuOptions = {
...this.getMenuOptions(ev),
mxEvent: this.props.mxEvent,
reactions: this.props.reactions,
chevronFace: "none",
onFinished: () => this.onFocusChange(false),
};
createMenu(ReactionPicker, menuOptions);
this.onFocusChange(true);
};
onOptionsClick = (ev) => {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const { getTile, getReplyThread } = this.props;
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
let e2eInfoCallback = null;
if (this.props.mxEvent.isEncrypted()) {
e2eInfoCallback = () => this.onCryptoClick();
}
const menuOptions = {
...this.getMenuOptions(ev),
mxEvent: this.props.mxEvent,
chevronFace: "none",
permalinkCreator: this.props.permalinkCreator,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
e2eInfoCallback: e2eInfoCallback,
onFinished: () => {
this.onFocusChange(false);
},
};
createMenu(MessageContextMenu, menuOptions);
this.onFocusChange(true);
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
@ -158,11 +162,7 @@ export default class MessageActionBar extends React.PureComponent {
if (isContentActionable(this.props.mxEvent)) {
if (this.context.room.canReact) {
reactButton = <AccessibleButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton"
title={_t("React")}
onClick={this.onReactClick}
/>;
reactButton = <ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} />;
}
if (this.context.room.canReply) {
replyButton = <AccessibleButton
@ -185,11 +185,12 @@ export default class MessageActionBar extends React.PureComponent {
{reactButton}
{replyButton}
{editButton}
<AccessibleButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={this.onOptionsClick}
aria-haspopup={true}
<OptionsButton
mxEvent={this.props.mxEvent}
getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
/>
</div>;
}

View file

@ -27,12 +27,13 @@ import sdk from '../../../index';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import * as ContextualMenu from '../../structures/ContextualMenu';
import * as ContextMenu from '../../structures/ContextMenu';
import SettingsStore from "../../../settings/SettingsStore";
import ReplyThread from "../elements/ReplyThread";
import {pillifyLinks} from '../../../utils/pillify';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import {isPermalinkHost} from "../../../utils/permalinks/Permalinks";
import {toRightOf} from "../../structures/ContextMenu";
module.exports = createReactClass({
displayName: 'TextualBody',
@ -272,18 +273,12 @@ module.exports = createReactClass({
const copyCode = button.parentNode.getElementsByTagName("code")[0];
const successful = this.copyToClipboard(copyCode.textContent);
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const {close} = ContextualMenu.createMenu(GenericTextContextMenu, {
chevronOffset: 10,
left: x,
top: y,
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 11),
message: successful ? _t('Copied!') : _t('Failed to copy'),
}, false);
});
e.target.onmouseleave = close;
};