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
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 {
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 {
// Create a flexbox to organize the header a bit easier
display: flex;
@ -95,68 +79,4 @@ limitations under the License.
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;
height: 6px;
border-radius: 6px;
margin-right: 18px;
margin-right: 8px;
}
&.mx_NotificationBadge_2char {

View file

@ -25,9 +25,10 @@ limitations under the License.
display: flex;
flex-direction: column;
margin-left: 8px;
padding-left: 8px;
margin-top: 12px;
margin-bottom: 12px;
width: 100%;
.mx_RoomSublist2_headerContainer {
// 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
.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-right: 3px;
padding: 4px;
@ -27,7 +27,7 @@ limitations under the License.
display: flex;
flex-wrap: wrap;
&.mx_RoomTile2_selected {
&.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen {
background-color: $roomtile2-selected-bg-color;
border-radius: 32px;
}
@ -37,6 +37,9 @@ limitations under the License.
}
.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
display: flex;
flex-direction: column;
@ -45,9 +48,13 @@ limitations under the License.
.mx_RoomTile2_name,
.mx_RoomTile2_messagePreview {
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 {
font-size: $font-14px;
@ -66,7 +73,8 @@ limitations under the License.
}
.mx_RoomTile2_badgeContainer {
flex-grow: 1;
width: 18px;
height: 32px;
// Create another flexbox row because it's super easy to position the badge at
// the end this way.
@ -74,4 +82,97 @@ limitations under the License.
align-items: center;
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}
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_name">
<span className="mx_UserMenuButton_contextMenu_displayName">
@ -203,7 +203,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
</div>
</div>
{hostingLink}
<div className="mx_UserMenuButton_contextMenu_optionList">
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
@ -237,7 +237,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
</li>
</ul>
</div>
<div className="mx_UserMenuButton_contextMenu_optionList">
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={this.onSignOutClick}>

View file

@ -27,6 +27,9 @@ import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
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 *
@ -49,10 +52,12 @@ interface IState {
hover: boolean;
notificationState: INotificationState;
selected: boolean;
generalMenuDisplayed: boolean;
}
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: Lock icon
@ -72,6 +77,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
hover: false,
notificationState: new RoomNotificationState(this.props.room),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
generalMenuDisplayed: false,
};
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});
};
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 {
// TODO: Collapsed state
// TODO: Invites
@ -114,6 +238,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const classes = classNames({
'mx_RoomTile2': true,
'mx_RoomTile2_selected': this.state.selected,
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed,
});
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;
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTile}>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
@ -165,6 +290,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<div className="mx_RoomTile2_badgeContainer">
{badge}
</div>
{this.renderGeneralMenu()}
</AccessibleButton>
}
</RovingTabIndexWrapper>

View file

@ -1142,6 +1142,11 @@
"%(count)s unread messages.|one": "1 unread message.",
"Unread mentions.": "Unread mentions.",
"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",
"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.",
@ -1817,9 +1822,6 @@
"Mentions only": "Mentions only",
"Leave": "Leave",
"Forget": "Forget",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Direct Chat": "Direct Chat",
"Clear status": "Clear status",
"Update status": "Update status",
"Set status": "Set status",