Support right click context menu interactions on Room List 2

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
pull/21833/head
Michael Telatynski 2020-07-01 23:06:26 +01:00
parent 89bd572371
commit 5c2b291510
3 changed files with 92 additions and 60 deletions

View File

@ -42,8 +42,10 @@ interface IProps {
isMinimized: boolean; isMinimized: boolean;
} }
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState { interface IState {
menuDisplayed: boolean; contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean; isDarkTheme: boolean;
} }
@ -56,7 +58,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
menuDisplayed: false, contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(), isDarkTheme: this.isUserOnDarkTheme(),
}; };
@ -106,13 +108,27 @@ export default class UserMenu extends React.Component<IProps, IState> {
private onOpenMenuClick = (ev: InputEvent) => { private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({menuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
};
private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
width: 20,
height: 0,
},
});
}; };
private onCloseMenu = (ev: InputEvent) => { private onCloseMenu = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({menuDisplayed: false}); this.setState({contextMenuPosition: null});
}; };
private onSwitchThemeClick = () => { private onSwitchThemeClick = () => {
@ -129,7 +145,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
defaultDispatcher.dispatch(payload); defaultDispatcher.dispatch(payload);
this.setState({menuDisplayed: false}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onShowArchived = (ev: ButtonEvent) => { private onShowArchived = (ev: ButtonEvent) => {
@ -145,7 +161,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
this.setState({menuDisplayed: false}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onSignOutClick = (ev: ButtonEvent) => { private onSignOutClick = (ev: ButtonEvent) => {
@ -153,7 +169,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
this.setState({menuDisplayed: false}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onHomeClick = (ev: ButtonEvent) => { private onHomeClick = (ev: ButtonEvent) => {
@ -164,7 +180,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
}; };
private renderContextMenu = (): React.ReactNode => { private renderContextMenu = (): React.ReactNode => {
if (!this.state.menuDisplayed) return null; if (!this.state.contextMenuPosition) return null;
let hostingLink; let hostingLink;
const signupLink = getHostingLink("user-context-menu"); const signupLink = getHostingLink("user-context-menu");
@ -198,13 +214,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
); );
} }
const elementRect = this.buttonRef.current.getBoundingClientRect();
return ( return (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace="none"
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected // -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={elementRect.width + elementRect.left - 20} left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={elementRect.top + elementRect.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
> >
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu"> <div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
@ -290,7 +305,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.buttonRef} inputRef={this.buttonRef}
label={_t("Account settings")} label={_t("Account settings")}
isExpanded={this.state.menuDisplayed} isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
> >
<div className="mx_UserMenu_row"> <div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer"> <span className="mx_UserMenu_userAvatarContainer">

View File

@ -65,22 +65,21 @@ interface IProps {
// TODO: Account for https://github.com/vector-im/riot-web/issues/14179 // TODO: Account for https://github.com/vector-im/riot-web/issues/14179
} }
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState { interface IState {
notificationState: ListNotificationState; notificationState: ListNotificationState;
menuDisplayed: boolean; contextMenuPosition: PartialDOMRect;
isResizing: boolean; isResizing: boolean;
} }
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef();
private menuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
menuDisplayed: false, contextMenuPosition: null,
isResizing: false, isResizing: false,
}; };
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
@ -132,11 +131,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private onOpenMenuClick = (ev: InputEvent) => { private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({menuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
};
private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
height: 0,
},
});
}; };
private onCloseMenu = () => { private onCloseMenu = () => {
this.setState({menuDisplayed: false}); this.setState({contextMenuPosition: null});
}; };
private onUnreadFirstChanged = async () => { private onUnreadFirstChanged = async () => {
@ -202,15 +214,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
let contextMenu = null; let contextMenu = null;
if (this.state.menuDisplayed) { if (this.state.contextMenuPosition) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace="none"
left={elementRect.left} left={this.state.contextMenuPosition.left}
top={elementRect.top + elementRect.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
> >
<div className="mx_RoomSublist2_contextMenu"> <div className="mx_RoomSublist2_contextMenu">
@ -261,9 +272,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<ContextMenuButton <ContextMenuButton
className="mx_RoomSublist2_menuButton" className="mx_RoomSublist2_menuButton"
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.menuButtonRef}
label={_t("List options")} label={_t("List options")}
isExpanded={this.state.menuDisplayed} isExpanded={!!this.state.contextMenuPosition}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -272,7 +282,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactElement { private renderHeader(): React.ReactElement {
return ( return (
<RovingTabIndexWrapper inputRef={this.headerButton}> <RovingTabIndexWrapper>
{({onFocus, isActive, ref}) => { {({onFocus, isActive, ref}) => {
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180 // TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
@ -317,12 +327,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<div className={classes}> <div className={classes}>
<div className='mx_RoomSublist2_stickable'> <div className='mx_RoomSublist2_stickable'>
<AccessibleButton <AccessibleButton
onFocus={onFocus}
inputRef={ref} inputRef={ref}
tabIndex={tabIndex} tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"} className={"mx_RoomSublist2_headerText"}
role="treeitem" role="treeitem"
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
> >
<span className={collapseClasses} /> <span className={collapseClasses} />
<span>{this.props.label}</span> <span>{this.props.label}</span>
@ -347,7 +359,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
'mx_RoomSublist2': true, 'mx_RoomSublist2': true,
'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed, 'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist2_minimized': this.props.isMinimized, 'mx_RoomSublist2_minimized': this.props.isMinimized,
}); });

View File

@ -60,15 +60,17 @@ interface IProps {
// TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177 // TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177
} }
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState { interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: INotificationState;
selected: boolean; selected: boolean;
notificationsMenuDisplayed: boolean; notificationsMenuPosition: PartialDOMRect;
generalMenuDisplayed: boolean; generalMenuPosition: PartialDOMRect;
} }
const contextMenuBelow = (elementRect) => { const contextMenuBelow = (elementRect: PartialDOMRect) => {
// align the context menu's icons with the icon which opened the context menu // align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9; const left = elementRect.left + window.pageXOffset - 9;
let top = elementRect.bottom + window.pageYOffset + 17; let top = elementRect.bottom + window.pageYOffset + 17;
@ -103,9 +105,6 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
}; };
export default class RoomTile2 extends React.Component<IProps, IState> { export default class RoomTile2 extends React.Component<IProps, IState> {
private notificationsMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
@ -115,8 +114,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
hover: false, hover: false,
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuDisplayed: false, notificationsMenuPosition: null,
generalMenuDisplayed: false, generalMenuPosition: null,
}; };
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
@ -137,6 +136,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
}; };
private onTileClick = (ev: React.KeyboardEvent) => { private onTileClick = (ev: React.KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
// TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233 // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
@ -153,25 +154,34 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
private onNotificationsMenuOpenClick = (ev: InputEvent) => { private onNotificationsMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({notificationsMenuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({notificationsMenuPosition: target.getBoundingClientRect()});
}; };
private onCloseNotificationsMenu = (ev: InputEvent) => { private onCloseNotificationsMenu = () => {
ev.preventDefault(); this.setState({notificationsMenuPosition: null});
ev.stopPropagation();
this.setState({notificationsMenuDisplayed: false});
}; };
private onGeneralMenuOpenClick = (ev: InputEvent) => { private onGeneralMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({generalMenuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({generalMenuPosition: target.getBoundingClientRect()});
}; };
private onCloseGeneralMenu = (ev: InputEvent) => { private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({generalMenuDisplayed: false}); this.setState({
generalMenuPosition: {
left: ev.clientX,
bottom: ev.clientY,
},
});
};
private onCloseGeneralMenu = () => {
this.setState({generalMenuPosition: null});
}; };
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => { private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
@ -190,7 +200,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
action: 'leave_room', action: 'leave_room',
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
this.setState({generalMenuDisplayed: false}); // hide the menu this.setState({generalMenuPosition: null}); // hide the menu
}; };
private onOpenRoomSettings = (ev: ButtonEvent) => { private onOpenRoomSettings = (ev: ButtonEvent) => {
@ -201,7 +211,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
action: 'open_room_settings', action: 'open_room_settings',
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
this.setState({generalMenuDisplayed: false}); // hide the menu this.setState({generalMenuPosition: null}); // hide the menu
}; };
private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) { private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) {
@ -218,10 +228,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
console.error(error); console.error(error);
} }
// Close the context menu this.setState({notificationsMenuPosition: null}); // Close the context menu
this.setState({
notificationsMenuDisplayed: false,
});
} }
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES); private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
@ -238,10 +245,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const state = getRoomNotifsState(this.props.room.roomId); const state = getRoomNotifsState(this.props.room.roomId);
let contextMenu = null; let contextMenu = null;
if (this.state.notificationsMenuDisplayed) { if (this.state.notificationsMenuPosition) {
const elementRect = this.notificationsMenuButtonRef.current.getBoundingClientRect();
contextMenu = ( contextMenu = (
<ContextMenu {...contextMenuBelow(elementRect)} onFinished={this.onCloseNotificationsMenu}> <ContextMenu {...contextMenuBelow(this.state.notificationsMenuPosition)} onFinished={this.onCloseNotificationsMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"> <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<NotifOption <NotifOption
@ -289,9 +295,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ContextMenuButton <ContextMenuButton
className={classes} className={classes}
onClick={this.onNotificationsMenuOpenClick} onClick={this.onNotificationsMenuOpenClick}
inputRef={this.notificationsMenuButtonRef}
label={_t("Notification options")} label={_t("Notification options")}
isExpanded={this.state.notificationsMenuDisplayed} isExpanded={!!this.state.notificationsMenuPosition}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -307,10 +312,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
} }
let contextMenu = null; let contextMenu = null;
if (this.state.generalMenuDisplayed) { if (this.state.generalMenuPosition) {
const elementRect = this.generalMenuButtonRef.current.getBoundingClientRect();
contextMenu = ( contextMenu = (
<ContextMenu {...contextMenuBelow(elementRect)} onFinished={this.onCloseGeneralMenu}> <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"> <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
@ -338,9 +342,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ContextMenuButton <ContextMenuButton
className="mx_RoomTile2_menuButton" className="mx_RoomTile2_menuButton"
onClick={this.onGeneralMenuOpenClick} onClick={this.onGeneralMenuOpenClick}
inputRef={this.generalMenuButtonRef}
label={_t("Room options")} label={_t("Room options")}
isExpanded={this.state.generalMenuDisplayed} isExpanded={!!this.state.generalMenuPosition}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -354,7 +357,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
'mx_RoomTile2': true, 'mx_RoomTile2': true,
'mx_RoomTile2_selected': this.state.selected, 'mx_RoomTile2_selected': this.state.selected,
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed || this.state.notificationsMenuDisplayed, 'mx_RoomTile2_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
'mx_RoomTile2_minimized': this.props.isMinimized, 'mx_RoomTile2_minimized': this.props.isMinimized,
}); });
@ -416,6 +419,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onMouseLeave={this.onTileMouseLeave} onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick} onClick={this.onTileClick}
role="treeitem" role="treeitem"
onContextMenu={this.onContextMenu}
> >
<div className="mx_RoomTile2_avatarContainer"> <div className="mx_RoomTile2_avatarContainer">
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} /> <RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />