diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index bdaada0d15..a73658d916 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -86,11 +86,15 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton { // Cheaty way to return the occupied space to the filter input + flex-basis: 0; margin: 0; width: 0; - // Don't forget to hide the masked ::before icon - visibility: hidden; + // Don't forget to hide the masked ::before icon, + // using display:none or visibility:hidden would break accessibility + &::before { + content: none; + } } .mx_LeftPanel2_exploreButton { diff --git a/src/RoomNotifsTypes.ts b/src/RoomNotifsTypes.ts new file mode 100644 index 0000000000..0e7093e434 --- /dev/null +++ b/src/RoomNotifsTypes.ts @@ -0,0 +1,24 @@ +/* +Copyright 2020 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + ALL_MESSAGES, + ALL_MESSAGES_LOUD, + MENTIONS_ONLY, + MUTE, +} from "./RoomNotifs"; + +export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE; diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index e43b0d1431..bda194ddd0 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -23,6 +23,8 @@ import classNames from 'classnames'; import {Key} from "../../Keyboard"; import * as sdk from "../../index"; import AccessibleButton from "../views/elements/AccessibleButton"; +import StyledCheckbox from "../views/elements/StyledCheckbox"; +import StyledRadioButton from "../views/elements/StyledRadioButton"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -421,6 +423,54 @@ MenuItemCheckbox.propTypes = { onClick: PropTypes.func.isRequired, }; +// Semantic component for representing a styled role=menuitemcheckbox +export const StyledMenuItemCheckbox = ({children, label, onChange, onClose, checked, disabled=false, ...props}) => { + const onKeyDown = (e) => { + if (e.key === Key.ENTER || e.key === Key.SPACE) { + e.stopPropagation(); + e.preventDefault(); + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + if (e.key === Key.ENTER) { + onClose(); + } + } + }; + const onKeyUp = (e) => { + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + if (e.key === Key.SPACE || e.key === Key.ENTER) { + e.stopPropagation(); + e.preventDefault(); + } + }; + return ( + + { children } + + ); +}; +StyledMenuItemCheckbox.propTypes = { + ...StyledCheckbox.propTypes, + label: PropTypes.string, // optional + checked: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER +}; + // Semantic component for representing a role=menuitemradio export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -439,6 +489,54 @@ MenuItemRadio.propTypes = { onClick: PropTypes.func.isRequired, }; +// Semantic component for representing a styled role=menuitemradio +export const StyledMenuItemRadio = ({children, label, onChange, onClose, checked=false, disabled=false, ...props}) => { + const onKeyDown = (e) => { + if (e.key === Key.ENTER || e.key === Key.SPACE) { + e.stopPropagation(); + e.preventDefault(); + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + if (e.key === Key.ENTER) { + onClose(); + } + } + }; + const onKeyUp = (e) => { + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + if (e.key === Key.SPACE || e.key === Key.ENTER) { + e.stopPropagation(); + e.preventDefault(); + } + }; + return ( + + { children } + + ); +}; +StyledMenuItemRadio.propTypes = { + ...StyledMenuItemRadio.propTypes, + label: PropTypes.string, // optional + checked: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER +}; + // Placement method for to position context menu to right of elementRect with chevronOffset export const toRightOf = (elementRect, chevronOffset=12) => { const left = elementRect.right + window.pageXOffset + 3; diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 6c790f3318..4b954d7843 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -56,6 +56,15 @@ interface IState { showTagPanel: boolean; } +// List of CSS classes which should be included in keyboard navigation within the room list +const cssClasses = [ + "mx_RoomSearch_input", + "mx_RoomSearch_icon", // minimized + "mx_RoomSublist2_headerText", + "mx_RoomTile2", + "mx_RoomSublist2_showNButton", +]; + export default class LeftPanel2 extends React.Component { private listContainerRef: React.RefObject = createRef(); private tagPanelWatcherRef: string; @@ -212,10 +221,7 @@ export default class LeftPanel2 extends React.Component { if (element) { classes = element.classList; } - } while (element && !( - classes.contains("mx_RoomTile2") || - classes.contains("mx_RoomSublist2_headerText") || - classes.contains("mx_RoomSearch_input"))); + } while (element && !cssClasses.some(c => classes.contains(c))); if (element) { element.focus(); @@ -246,7 +252,12 @@ export default class LeftPanel2 extends React.Component { private renderSearchExplore(): React.ReactNode { return ( -
+
{ // TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180 className="mx_LeftPanel2_exploreButton" onClick={this.onExplore} - alt={_t("Explore rooms")} + title={_t("Explore rooms")} />
); diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 7ed2acf276..15f3bd5b54 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -149,7 +149,8 @@ export default class RoomSearch extends React.PureComponent { let clearButton = ( ); @@ -157,8 +158,8 @@ export default class RoomSearch extends React.PureComponent { if (this.props.isMinimized) { icon = ( ); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 5955a046a4..221ec07439 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -329,7 +329,7 @@ export default class UserMenu extends React.Component { className={classes} onClick={this.onOpenMenuClick} inputRef={this.buttonRef} - label={_t("Account settings")} + label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} > diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 508691e5fd..53e8d0072b 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -132,7 +132,7 @@ const BaseAvatar = (props) => { ); } else { return ( - + { textNode } { imgNode } diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index 91bef0fc3d..0d7c3b8ec1 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -274,9 +274,6 @@ export default class RoomList2 extends React.Component { className="mx_RoomList2" role="tree" aria-label={_t("Rooms")} - // Firefox sometimes makes this element focusable due to - // overflow:scroll;, so force it out of tab order. - tabIndex={-1} >{sublists}
)} diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 90ca8b2d4b..18f8f4e2f6 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -26,8 +26,12 @@ import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ListLayout } from "../../../stores/room-list/ListLayout"; -import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; -import StyledRadioButton from "../elements/StyledRadioButton"; +import { + ContextMenu, + ContextMenuButton, + StyledMenuItemCheckbox, + StyledMenuItemRadio, +} from "../../structures/ContextMenu"; import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; @@ -62,7 +66,7 @@ interface IProps { onAddRoom?: () => void; addRoomLabel: string; isInvite: boolean; - layout: ListLayout; + layout?: ListLayout; isMinimized: boolean; tagId: TagID; onResize: () => void; @@ -136,11 +140,13 @@ export default class RoomSublist2 extends React.Component { }; private onShowAllClick = () => { + // TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render }; private onShowLessClick = () => { + // TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.forceUpdate(); // because the layout doesn't trigger a re-render }; @@ -203,6 +209,7 @@ export default class RoomSublist2 extends React.Component { dis.dispatch({ action: 'view_room', room_id: room.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view }); } }; @@ -323,22 +330,24 @@ export default class RoomSublist2 extends React.Component {
{_t("Unread rooms")}
- {_t("Always show first")} - +

{_t("Show")}
- {_t("Message preview")} - +
); @@ -354,20 +363,22 @@ export default class RoomSublist2 extends React.Component {
{_t("Sort by")}
- this.onTagSortChanged(SortAlgorithm.Recent)} checked={!isAlphabetical} name={`mx_${this.props.tagId}_sortBy`} > {_t("Activity")} - - + this.onTagSortChanged(SortAlgorithm.Alphabetic)} checked={isAlphabetical} name={`mx_${this.props.tagId}_sortBy`} > {_t("A-Z")} - +
{otherSections}
@@ -390,16 +401,22 @@ export default class RoomSublist2 extends React.Component { private renderHeader(): React.ReactElement { return ( - + {({onFocus, isActive, ref}) => { const tabIndex = isActive ? 0 : -1; + let ariaLabel = _t("Jump to first unread room."); + if (this.props.tagId === DefaultTagID.Invite) { + ariaLabel = _t("Jump to first invite."); + } + const badge = ( ); @@ -440,7 +457,7 @@ export default class RoomSublist2 extends React.Component { // doesn't become sticky. // The same applies to the notification badge. return ( -
+
{ tabIndex={tabIndex} className="mx_RoomSublist2_headerText" role="treeitem" + aria-expanded={!this.props.layout || !this.props.layout.isCollapsed} aria-level={1} onClick={this.onHeaderClick} onContextMenu={this.onContextMenu} @@ -503,12 +521,12 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showMoreText = null; showNButton = ( -
+ {/* set by CSS masking */} {showMoreText} -
+
); } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { // we have all tiles visible - add a button to show less @@ -518,13 +536,14 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showLessText = null; + // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( -
+ {/* set by CSS masking */} {showLessText} -
+ ); } diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 4af9ef7966..c6cd401803 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -26,17 +26,30 @@ import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; import { _t } from "../../../languageHandler"; -import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu"; +import { + ContextMenu, + ContextMenuButton, + MenuItemRadio, + MenuItemCheckbox, + MenuItem, +} from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; +import { + getRoomNotifsState, + setRoomNotifsState, + ALL_MESSAGES, + ALL_MESSAGES_LOUD, + MENTIONS_ONLY, + MUTE, +} from "../../../RoomNotifs"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { setRoomNotifsState } from "../../../RoomNotifs"; import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; import { INotificationState } from "../../../stores/notifications/INotificationState"; import NotificationBadge from "./NotificationBadge"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { Volume } from "../../../RoomNotifsTypes"; // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 @@ -68,6 +81,8 @@ interface IState { generalMenuPosition: PartialDOMRect; } +const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`; + const contextMenuBelow = (elementRect: PartialDOMRect) => { // align the context menu's icons with the icon which opened the context menu const left = elementRect.left + window.pageXOffset - 9; @@ -123,6 +138,10 @@ export default class RoomTile2 extends React.Component { return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite; } + private get showMessagePreview(): boolean { + return !this.props.isMinimized && this.props.showMessagePreview; + } + public componentWillUnmount() { if (this.props.room) { ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); @@ -195,6 +214,11 @@ export default class RoomTile2 extends React.Component { // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 + + if ((ev as React.KeyboardEvent).key === Key.ENTER) { + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + this.setState({generalMenuPosition: null}); // hide the menu + } }; private onLeaveRoomClick = (ev: ButtonEvent) => { @@ -219,11 +243,13 @@ export default class RoomTile2 extends React.Component { 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: Volume) { ev.preventDefault(); ev.stopPropagation(); if (MatrixClientPeg.get().isGuest()) return; + // get key before we go async and React discards the nativeEvent + const key = (ev as React.KeyboardEvent).key; try { // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 await setRoomNotifsState(this.props.room.roomId, newState); @@ -233,7 +259,10 @@ export default class RoomTile2 extends React.Component { console.error(error); } - this.setState({notificationsMenuPosition: null}); // Close the context menu + if (key === Key.ENTER) { + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + this.setState({notificationsMenuPosition: null}); // hide the menu + } } private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES); @@ -322,20 +351,24 @@ export default class RoomTile2 extends React.Component {
- this.onTagRoom(e, DefaultTagID.Favourite)}> + this.onTagRoom(e, DefaultTagID.Favourite)} + active={false} // TODO: https://github.com/vector-im/riot-web/issues/14283 + label={_t("Favourite")} + > {_t("Favourite")} - - + + {_t("Settings")} - +
- + {_t("Leave Room")} - +
@@ -375,8 +408,9 @@ export default class RoomTile2 extends React.Component { let badge: React.ReactNode; if (!this.props.isMinimized) { + // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below badge = ( -
+