Add a context menu to rooms in the new room list

The tag options are not implemented out of concern for diff size. 

This splits the context menu classes out to a new "iconized" style which is common across a number of context menus, including the UserMenu.

Some of the badge/sublist styles had to change to better accommodate the menu icon lining up.

This also contains the framework required for https://github.com/vector-im/riot-web/issues/13961
This commit is contained in:
Travis Ralston 2020-06-09 23:09:15 -06:00
parent 7549d7d98a
commit 0755b4c148
10 changed files with 362 additions and 95 deletions

View file

@ -581,3 +581,118 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
// So it fits in the space provided by the page // So it fits in the space provided by the page
max-width: 120px; max-width: 120px;
} }
// A context menu that largely fits the | [icon] [label] | format.
.mx_IconizedContextMenu {
// Put 20px of padding around the whole menu. We do this instead of a
// simple `padding: 20px` rule so the horizontal rules added by the
// optionLists is rendered correctly (full width).
> * {
padding-left: 20px;
padding-right: 20px;
&:first-child {
padding-top: 20px;
}
&:last-child {
padding-bottom: 20px;
}
}
.mx_IconizedContextMenu_optionList {
// the notFirst class is for cases where the optionList might be under a header of sorts.
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
margin-top: 20px;
// This is a bit of a hack when we could just use a simple border-top property,
// however we have a (kinda) good reason for doing it this way: we need opacity.
// To get the right color, we need an opacity modifier which means we have to work
// around the problem. PostCSS doesn't support the opacity() function, and if we
// use something like postcss-functions we quickly run into an issue where the
// function we would define gets passed a CSS variable for custom themes, which
// can't be converted easily even when considering https://stackoverflow.com/a/41265350/7037379
//
// Therefore, we just hack in a line and border the thing ourselves
&::before {
border-top: 1px solid $primary-fg-color;
opacity: 0.1;
content: '';
// Counteract the padding problems (width: 100% ignores the 40px padding,
// unless we position it absolutely then it does the right thing).
width: 100%;
position: absolute;
left: 0;
}
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0;
padding: 20px 0 0;
.mx_AccessibleButton {
text-decoration: none;
color: $primary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
// Create a flexbox to more easily define the list items
display: flex;
align-items: center;
img, .mx_IconizedContextMenu_icon { // icons
width: 16px;
min-width: 16px;
max-width: 16px;
}
span:last-child { // labels
padding-left: 14px;
width: 100%;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
}
}
&.mx_IconizedContextMenu_compact {
> * {
padding-left: 11px;
padding-right: 16px;
&:first-child {
padding-top: 13px;
}
&:last-child {
padding-bottom: 13px;
}
}
.mx_IconizedContextMenu_optionList {
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
margin-top: 10px;
li:first-child {
padding-top: 10px;
}
}
li:first-child {
padding-top: 0;
}
}
}
}

View file

@ -21,22 +21,6 @@ limitations under the License.
.mx_UserMenuButton_contextMenu { .mx_UserMenuButton_contextMenu {
width: 231px; width: 231px;
// Put 20px of padding around the whole menu. We do this instead of a
// simple `padding: 20px` rule so the horizontal rules added by the
// optionLists is rendered correctly (full width).
> * {
padding-left: 20px;
padding-right: 20px;
&:first-child {
padding-top: 20px;
}
&:last-child {
padding-bottom: 20px;
}
}
.mx_UserMenuButton_contextMenu_header { .mx_UserMenuButton_contextMenu_header {
// Create a flexbox to organize the header a bit easier // Create a flexbox to organize the header a bit easier
display: flex; display: flex;
@ -95,68 +79,4 @@ limitations under the License.
justify-content: center; justify-content: center;
} }
} }
.mx_UserMenuButton_contextMenu_optionList {
margin-top: 20px;
// This is a bit of a hack when we could just use a simple border-top property,
// however we have a (kinda) good reason for doing it this way: we need opacity.
// To get the right color, we need an opacity modifier which means we have to work
// around the problem. PostCSS doesn't support the opacity() function, and if we
// use something like postcss-functions we quickly run into an issue where the
// function we would define gets passed a CSS variable for custom themes, which
// can't be converted easily even when considering https://stackoverflow.com/a/41265350/7037379
//
// Therefore, we just hack in a line and border the thing ourselves
&::before {
border-top: 1px solid $primary-fg-color;
opacity: 0.1;
content: '';
// Counteract the padding problems (width: 100% ignores the 40px padding,
// unless we position it absolutely then it does the right thing).
width: 100%;
position: absolute;
left: 0;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0;
padding: 20px 0 0;
.mx_AccessibleButton {
text-decoration: none;
color: $primary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
// Create a flexbox to more easily define the list items
display: flex;
align-items: center;
img { // icons
width: 16px;
min-width: 16px;
max-width: 16px;
}
span { // labels
padding-left: 14px;
width: 100%;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
}
}
} }

View file

@ -46,7 +46,7 @@ limitations under the License.
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 6px; border-radius: 6px;
margin-right: 18px; margin-right: 8px;
} }
&.mx_NotificationBadge_2char { &.mx_NotificationBadge_2char {

View file

@ -25,9 +25,10 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-left: 8px; padding-left: 8px;
margin-top: 12px; margin-top: 12px;
margin-bottom: 12px; margin-bottom: 12px;
width: 100%;
.mx_RoomSublist2_headerContainer { .mx_RoomSublist2_headerContainer {
// Create a flexbox to make ordering easy // Create a flexbox to make ordering easy

View file

@ -18,7 +18,7 @@ limitations under the License.
// Note: the room tile expects to be in a flexbox column container // Note: the room tile expects to be in a flexbox column container
.mx_RoomTile2 { .mx_RoomTile2 {
width: calc(100% - 11px); // 8px for padding (4px on either side), 3px for margin width: calc(100% - 21px); // 8px for padding (4px on either side), 3px for margin
margin-bottom: 4px; margin-bottom: 4px;
margin-right: 3px; margin-right: 3px;
padding: 4px; padding: 4px;
@ -27,7 +27,7 @@ limitations under the License.
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
&.mx_RoomTile2_selected { &.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen {
background-color: $roomtile2-selected-bg-color; background-color: $roomtile2-selected-bg-color;
border-radius: 32px; border-radius: 32px;
} }
@ -37,6 +37,9 @@ limitations under the License.
} }
.mx_RoomTile2_nameContainer { .mx_RoomTile2_nameContainer {
flex-grow: 1;
max-width: calc(100% - 58px); // 32px avatar, 18px badge area, 8px margin on avatar
// Create a new column layout flexbox for the name parts // Create a new column layout flexbox for the name parts
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -45,9 +48,13 @@ limitations under the License.
.mx_RoomTile2_name, .mx_RoomTile2_name,
.mx_RoomTile2_messagePreview { .mx_RoomTile2_messagePreview {
margin: 0 2px; margin: 0 2px;
} width: 100%;
// TODO: Ellipsis on the name and preview // Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_RoomTile2_name { .mx_RoomTile2_name {
font-size: $font-14px; font-size: $font-14px;
@ -66,7 +73,8 @@ limitations under the License.
} }
.mx_RoomTile2_badgeContainer { .mx_RoomTile2_badgeContainer {
flex-grow: 1; width: 18px;
height: 32px;
// Create another flexbox row because it's super easy to position the badge at // Create another flexbox row because it's super easy to position the badge at
// the end this way. // the end this way.
@ -74,4 +82,97 @@ limitations under the License.
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
} }
// The menu button is hidden by default
// TODO: [Notifications] Add your bell icon class here, similar to the following approach:
// https://github.com/matrix-org/matrix-react-sdk/blob/2180a56074f3698fc0241c309a72ba6cad802d1c/res/css/views/rooms/_RoomSublist2.scss#L48-L76
// You'll need to do the same down below on the &:hover selector for the tile.
// ... also remove this 4 line TODO comment.
.mx_RoomTile2_menuButton {
width: 0;
height: 0;
visibility: hidden;
position: relative;
&::before {
content: '';
width: 16px;
height: 16px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
}
}
.mx_RoomTile2_menuButton::before {
top: 8px;
left: -1px; // this is off-center to align it with the badges
mask-image: url('$(res)/img/feather-customised/more-horizontal.svg');
}
&:hover, &.mx_RoomTile2_hasMenuOpen {
// Hide the badge container on hover because it'll be a menu button
.mx_RoomTile2_badgeContainer {
width: 0;
height: 0;
visibility: hidden;
}
.mx_RoomTile2_menuButton {
width: 18px;
height: 32px;
visibility: visible;
}
}
}
.mx_RoomTile2_contextMenu {
.mx_RoomTile2_contextMenu_redRow {
.mx_AccessibleButton {
color: $warning-color !important; // !important to override styles from context menu
}
.mx_IconizedContextMenu_icon::before {
background-color: $warning-color;
}
}
.mx_IconizedContextMenu_icon {
position: relative;
width: 16px;
height: 16px;
&::before {
content: '';
width: 16px;
height: 16px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
}
}
.mx_RoomTile2_iconStar::before {
mask-image: url('$(res)/img/feather-customised/star.svg');
}
.mx_RoomTile2_iconArrowDown::before {
mask-image: url('$(res)/img/feather-customised/arrow-down.svg');
}
.mx_RoomTile2_iconUser::before {
mask-image: url('$(res)/img/feather-customised/user.svg');
}
.mx_RoomTile2_iconSettings::before {
mask-image: url('$(res)/img/feather-customised/settings.svg');
}
.mx_RoomTile2_iconSignOut::before {
mask-image: url('$(res)/img/feather-customised/sign-out.svg');
}
} }

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-down"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>

After

Width:  |  Height:  |  Size: 339 B

View file

@ -180,7 +180,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
top={elementRect.top + elementRect.height} top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
> >
<div className="mx_UserMenuButton_contextMenu"> <div className="mx_IconizedContextMenu mx_UserMenuButton_contextMenu">
<div className="mx_UserMenuButton_contextMenu_header"> <div className="mx_UserMenuButton_contextMenu_header">
<div className="mx_UserMenuButton_contextMenu_name"> <div className="mx_UserMenuButton_contextMenu_name">
<span className="mx_UserMenuButton_contextMenu_displayName"> <span className="mx_UserMenuButton_contextMenu_displayName">
@ -203,7 +203,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
</div> </div>
</div> </div>
{hostingLink} {hostingLink}
<div className="mx_UserMenuButton_contextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul> <ul>
<li> <li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
@ -237,7 +237,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
</li> </li>
</ul> </ul>
</div> </div>
<div className="mx_UserMenuButton_contextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<ul> <ul>
<li> <li>
<AccessibleButton onClick={this.onSignOutClick}> <AccessibleButton onClick={this.onSignOutClick}>

View file

@ -27,6 +27,9 @@ import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge"; import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -49,10 +52,12 @@ interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: INotificationState;
selected: boolean; selected: boolean;
generalMenuDisplayed: boolean;
} }
export default class RoomTile2 extends React.Component<IProps, IState> { export default class RoomTile2 extends React.Component<IProps, IState> {
private roomTile = createRef(); private roomTileRef: React.RefObject<HTMLDivElement> = createRef();
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
// TODO: Custom status // TODO: Custom status
// TODO: Lock icon // TODO: Lock icon
@ -72,6 +77,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
hover: false, hover: false,
notificationState: new RoomNotificationState(this.props.room), notificationState: new RoomNotificationState(this.props.room),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
generalMenuDisplayed: false,
}; };
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
@ -105,6 +111,124 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({selected: isActive}); this.setState({selected: isActive});
}; };
private onGeneralMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({generalMenuDisplayed: true});
};
private onCloseGeneralMenu = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({generalMenuDisplayed: false});
};
private onTagRoom = (ev: React.MouseEvent, tagId: TagID) => {
ev.preventDefault();
ev.stopPropagation();
if (tagId === DefaultTagID.DM) {
// TODO: DM Flagging
} else {
// TODO: XOR favourites and low priority
}
};
private onLeaveRoomClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'leave_room',
room_id: this.props.room.roomId,
});
this.setState({generalMenuDisplayed: false}); // hide the menu
};
private onOpenRoomSettings = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'open_room_settings',
room_id: this.props.room.roomId,
});
this.setState({generalMenuDisplayed: false}); // hide the menu
};
private renderGeneralMenu(): React.ReactElement {
let contextMenu = null;
if (this.state.generalMenuDisplayed) {
// The context menu appears within the list, so use the room tile as a reference point
const elementRect = this.roomTileRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
chevronFace="none"
left={elementRect.left}
top={elementRect.top + elementRect.height + 8}
onFinished={this.onCloseGeneralMenu}
>
<div
className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"
style={{width: elementRect.width}}
>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span>{_t("Favourite")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" />
<span>{_t("Low Priority")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.DM)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconUser" />
<span>{_t("Direct Chat")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onOpenRoomSettings}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span>{_t("Settings")}</span>
</AccessibleButton>
</li>
</ul>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span>{_t("Leave Room")}</span>
</AccessibleButton>
</li>
</ul>
</div>
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuButton
className="mx_RoomTile2_menuButton"
onClick={this.onGeneralMenuOpenClick}
inputRef={this.generalMenuButtonRef}
label={_t("Room options")}
isExpanded={this.state.generalMenuDisplayed}
/>
{contextMenu}
</React.Fragment>
)
}
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Collapsed state // TODO: Collapsed state
// TODO: Invites // TODO: Invites
@ -114,6 +238,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,
}); });
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />; const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
@ -141,7 +266,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const avatarSize = 32; const avatarSize = 32;
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTile}> <RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) => {({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}
@ -165,6 +290,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<div className="mx_RoomTile2_badgeContainer"> <div className="mx_RoomTile2_badgeContainer">
{badge} {badge}
</div> </div>
{this.renderGeneralMenu()}
</AccessibleButton> </AccessibleButton>
} }
</RovingTabIndexWrapper> </RovingTabIndexWrapper>

View file

@ -1142,6 +1142,11 @@
"%(count)s unread messages.|one": "1 unread message.", "%(count)s unread messages.|one": "1 unread message.",
"Unread mentions.": "Unread mentions.", "Unread mentions.": "Unread mentions.",
"Unread messages.": "Unread messages.", "Unread messages.": "Unread messages.",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Direct Chat": "Direct Chat",
"Leave Room": "Leave Room",
"Room options": "Room options",
"Add a topic": "Add a topic", "Add a topic": "Add a topic",
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.",
"This room has already been upgraded.": "This room has already been upgraded.", "This room has already been upgraded.": "This room has already been upgraded.",
@ -1817,9 +1822,6 @@
"Mentions only": "Mentions only", "Mentions only": "Mentions only",
"Leave": "Leave", "Leave": "Leave",
"Forget": "Forget", "Forget": "Forget",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Direct Chat": "Direct Chat",
"Clear status": "Clear status", "Clear status": "Clear status",
"Update status": "Update status", "Update status": "Update status",
"Set status": "Set status", "Set status": "Set status",