diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 2acddc233c..1814919b61 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -143,6 +143,8 @@ limitations under the License. // toggle menuButton and badge on hover/menu displayed .mx_RoomTile_menuDisplayed, +// or on keyboard focus of room tile +.mx_RoomTile.focus-visible:focus-within, .mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover { .mx_RoomTile_menuButton { display: block; diff --git a/src/Keyboard.js b/src/Keyboard.js index 738da478e4..f63956777f 100644 --- a/src/Keyboard.js +++ b/src/Keyboard.js @@ -69,6 +69,8 @@ export const Key = { BACKSPACE: "Backspace", ARROW_UP: "ArrowUp", ARROW_DOWN: "ArrowDown", + ARROW_LEFT: "ArrowLeft", + ARROW_RIGHT: "ArrowRight", TAB: "Tab", ESCAPE: "Escape", ENTER: "Enter", diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 36dd3a7a61..d1d3bb1b63 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -186,6 +186,7 @@ const LeftPanel = createReactClass({ } } while (element && !( classes.contains("mx_RoomTile") || + classes.contains("mx_RoomSubList_label") || classes.contains("mx_textinput_search"))); if (element) { diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 60be1d7b34..92b9d91e0e 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018, 2019 New Vector Ltd +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. @@ -16,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import classNames from 'classnames'; import sdk from '../../index'; @@ -25,7 +26,7 @@ import Unread from '../../Unread'; import * as RoomNotifs from '../../RoomNotifs'; import * as FormattingUtils from '../../utils/FormattingUtils'; import IndicatorScrollbar from './IndicatorScrollbar'; -import { KeyCode } from '../../Keyboard'; +import {Key, KeyCode} from '../../Keyboard'; import { Group } from 'matrix-js-sdk'; import PropTypes from 'prop-types'; import RoomTile from "../views/rooms/RoomTile"; @@ -56,7 +57,6 @@ const RoomSubList = createReactClass({ collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed? onHeaderClick: PropTypes.func, incomingCall: PropTypes.object, - isFiltered: PropTypes.bool, extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles forceExpand: PropTypes.bool, }, @@ -80,6 +80,7 @@ const RoomSubList = createReactClass({ }, componentWillMount: function() { + this._headerButton = createRef(); this.dispatcherRef = dis.register(this.onAction); }, @@ -87,9 +88,9 @@ const RoomSubList = createReactClass({ dis.unregister(this.dispatcherRef); }, - // The header is collapsable if it is hidden or not stuck + // The header is collapsible if it is hidden or not stuck // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method - isCollapsableOnClick: function() { + isCollapsibleOnClick: function() { const stuck = this.refs.header.dataset.stuck; if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) { return true; @@ -114,8 +115,8 @@ const RoomSubList = createReactClass({ }, onClick: function(ev) { - if (this.isCollapsableOnClick()) { - // The header isCollapsable, so the click is to be interpreted as collapse and truncation logic + if (this.isCollapsibleOnClick()) { + // The header isCollapsible, so the click is to be interpreted as collapse and truncation logic const isHidden = !this.state.hidden; this.setState({hidden: isHidden}, () => { this.props.onHeaderClick(isHidden); @@ -124,6 +125,30 @@ const RoomSubList = createReactClass({ // The header is stuck, so the click is to be interpreted as a scroll to the header this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition); } + this._headerButton.current.focus(); + }, + + onKeyDown: function(ev) { + switch (ev.key) { + case Key.TAB: + // Prevent LeftPanel handling Tab if focus is on the sublist header itself + ev.stopPropagation(); + break; + case Key.ARROW_LEFT: + ev.stopPropagation(); + if (!this.state.hidden && !this.props.forceExpand) { + this.onClick(); + } + break; + case Key.ARROW_RIGHT: + ev.stopPropagation(); + if (this.state.hidden && !this.props.forceExpand) { + this.onClick(); + } else { + // TODO go to first element in subtree + } + break; + } }, onRoomTileClick(roomId, ev) { @@ -193,6 +218,11 @@ const RoomSubList = createReactClass({ } }, + onAddRoom: function(e) { + e.stopPropagation(); + if (this.props.onAddRoom) this.props.onAddRoom(); + }, + _getHeaderJsx: function(isCollapsed) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); @@ -209,12 +239,18 @@ const RoomSubList = createReactClass({ 'mx_RoomSubList_badgeHighlight': subListNotifHighlight, }); if (subListNotifCount > 0) { - badge =
- { FormattingUtils.formatCount(subListNotifCount) } -
; + badge = ( + + { FormattingUtils.formatCount(subListNotifCount) } + + ); } else if (this.props.isInvite && this.props.list.length) { // no notifications but highlight anyway because this is an invite badge - badge =
{this.props.list.length}
; + badge = ( + + { this.props.list.length } + + ); } } @@ -237,7 +273,7 @@ const RoomSubList = createReactClass({ if (this.props.onAddRoom) { addRoomButton = ( @@ -255,10 +291,17 @@ const RoomSubList = createReactClass({ chevron = (
); } - const tabindex = this.props.isFiltered ? "0" : "-1"; return ( -
- +
+ { chevron } {this.props.label} { incomingCall } @@ -344,6 +387,7 @@ const RoomSubList = createReactClass({ role="group" aria-label={this.props.label} aria-expanded={!isCollapsed} + onKeyDown={this.onKeyDown} > { this._getHeaderJsx(isCollapsed) } { content } diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js index bfc3e45246..1ccb7d0796 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.js @@ -67,8 +67,6 @@ export default function AccessibleButton(props) { restProps.ref = restProps.inputRef; delete restProps.inputRef; - restProps.tabIndex = restProps.tabIndex || "0"; - restProps.role = restProps.role || "button"; restProps.className = (restProps.className ? restProps.className + " " : "") + "mx_AccessibleButton"; if (kind) { @@ -93,19 +91,30 @@ export default function AccessibleButton(props) { */ AccessibleButton.propTypes = { children: PropTypes.node, - inputRef: PropTypes.func, + inputRef: PropTypes.oneOfType([ + // Either a function + PropTypes.func, + // Or the instance of a DOM native element + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), element: PropTypes.string, onClick: PropTypes.func.isRequired, // The kind of button, similar to how Bootstrap works. // See available classes for AccessibleButton for options. kind: PropTypes.string, + // The ARIA role + role: PropTypes.string, + // The tabIndex + tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), disabled: PropTypes.bool, }; AccessibleButton.defaultProps = { element: 'div', + role: 'button', + tabIndex: "0", }; AccessibleButton.displayName = "AccessibleButton"; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index d8092eae22..036f50d899 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -771,7 +771,7 @@ module.exports = createReactClass({ const subListComponents = this._mapSubListProps(subLists); return ( -
{ subListComponents }
diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index b727abd261..1398e03b10 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -398,7 +398,8 @@ module.exports = createReactClass({ onMouseLeave={this.onMouseLeave} onContextMenu={this.onContextMenu} aria-label={ariaLabel} - role="option" + aria-selected={this.state.selected} + role="treeitem" >
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f524a22d4b..13945a9ce8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -901,11 +901,6 @@ "Forget room": "Forget room", "Search": "Search", "Share room": "Share room", - "Drop here to favourite": "Drop here to favourite", - "Drop here to tag direct chat": "Drop here to tag direct chat", - "Drop here to restore": "Drop here to restore", - "Drop here to demote": "Drop here to demote", - "Drop here to tag %(section)s": "Drop here to tag %(section)s", "Community Invites": "Community Invites", "Invites": "Invites", "Favourites": "Favourites", @@ -1653,6 +1648,8 @@ "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Active call": "Active call", "There's no one else here! Would you like to invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?", + "Jump to first unread room.": "Jump to first unread room.", + "Jump to first invite.": "Jump to first invite.", "Add room": "Add room", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",