Use navigation treeview aria pattern for roomlist sublists and tiles

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2019-10-17 15:53:39 +01:00
parent 2de88449aa
commit 3400808f6e
8 changed files with 81 additions and 25 deletions

View file

@ -143,6 +143,8 @@ limitations under the License.
// toggle menuButton and badge on hover/menu displayed // toggle menuButton and badge on hover/menu displayed
.mx_RoomTile_menuDisplayed, .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_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
.mx_RoomTile_menuButton { .mx_RoomTile_menuButton {
display: block; display: block;

View file

@ -69,6 +69,8 @@ export const Key = {
BACKSPACE: "Backspace", BACKSPACE: "Backspace",
ARROW_UP: "ArrowUp", ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown", ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
TAB: "Tab", TAB: "Tab",
ESCAPE: "Escape", ESCAPE: "Escape",
ENTER: "Enter", ENTER: "Enter",

View file

@ -186,6 +186,7 @@ const LeftPanel = createReactClass({
} }
} while (element && !( } while (element && !(
classes.contains("mx_RoomTile") || classes.contains("mx_RoomTile") ||
classes.contains("mx_RoomSubList_label") ||
classes.contains("mx_textinput_search"))); classes.contains("mx_textinput_search")));
if (element) { if (element) {

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import React from 'react'; import React, {createRef} from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import classNames from 'classnames'; import classNames from 'classnames';
import sdk from '../../index'; import sdk from '../../index';
@ -25,7 +26,7 @@ import Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs'; import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils'; import * as FormattingUtils from '../../utils/FormattingUtils';
import IndicatorScrollbar from './IndicatorScrollbar'; import IndicatorScrollbar from './IndicatorScrollbar';
import { KeyCode } from '../../Keyboard'; import {Key, KeyCode} from '../../Keyboard';
import { Group } from 'matrix-js-sdk'; import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import RoomTile from "../views/rooms/RoomTile"; import RoomTile from "../views/rooms/RoomTile";
@ -56,7 +57,6 @@ const RoomSubList = createReactClass({
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed? collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func, onHeaderClick: PropTypes.func,
incomingCall: PropTypes.object, incomingCall: PropTypes.object,
isFiltered: PropTypes.bool,
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
forceExpand: PropTypes.bool, forceExpand: PropTypes.bool,
}, },
@ -80,6 +80,7 @@ const RoomSubList = createReactClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this._headerButton = createRef();
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
}, },
@ -87,9 +88,9 @@ const RoomSubList = createReactClass({
dis.unregister(this.dispatcherRef); 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 // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() { isCollapsibleOnClick: function() {
const stuck = this.refs.header.dataset.stuck; const stuck = this.refs.header.dataset.stuck;
if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) { if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) {
return true; return true;
@ -114,8 +115,8 @@ const RoomSubList = createReactClass({
}, },
onClick: function(ev) { onClick: function(ev) {
if (this.isCollapsableOnClick()) { if (this.isCollapsibleOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic // The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden; const isHidden = !this.state.hidden;
this.setState({hidden: isHidden}, () => { this.setState({hidden: isHidden}, () => {
this.props.onHeaderClick(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 // 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.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) { 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) { _getHeaderJsx: function(isCollapsed) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton');
@ -209,12 +239,18 @@ const RoomSubList = createReactClass({
'mx_RoomSubList_badgeHighlight': subListNotifHighlight, 'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
}); });
if (subListNotifCount > 0) { if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}> badge = (
{ FormattingUtils.formatCount(subListNotifCount) } <AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
</div>; { FormattingUtils.formatCount(subListNotifCount) }
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) { } else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge // no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>{this.props.list.length}</div>; badge = (
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
{ this.props.list.length }
</AccessibleButton>
);
} }
} }
@ -237,7 +273,7 @@ const RoomSubList = createReactClass({
if (this.props.onAddRoom) { if (this.props.onAddRoom) {
addRoomButton = ( addRoomButton = (
<AccessibleTooltipButton <AccessibleTooltipButton
onClick={ this.props.onAddRoom } onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom" className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")} title={this.props.addRoomLabel || _t("Add room")}
/> />
@ -255,10 +291,17 @@ const RoomSubList = createReactClass({
chevron = (<div className={chevronClasses} />); chevron = (<div className={chevronClasses} />);
} }
const tabindex = this.props.isFiltered ? "0" : "-1";
return ( return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header"> <div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex} aria-expanded={!isCollapsed}> <AccessibleButton
onClick={this.onClick}
className="mx_RoomSubList_label"
tabIndex={0}
aria-expanded={!isCollapsed}
inputRef={this._headerButton}
// cancel out role so this button behaves as the toggle-header of this group
role="none"
>
{ chevron } { chevron }
<span>{this.props.label}</span> <span>{this.props.label}</span>
{ incomingCall } { incomingCall }
@ -344,6 +387,7 @@ const RoomSubList = createReactClass({
role="group" role="group"
aria-label={this.props.label} aria-label={this.props.label}
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
onKeyDown={this.onKeyDown}
> >
{ this._getHeaderJsx(isCollapsed) } { this._getHeaderJsx(isCollapsed) }
{ content } { content }

View file

@ -67,8 +67,6 @@ export default function AccessibleButton(props) {
restProps.ref = restProps.inputRef; restProps.ref = restProps.inputRef;
delete restProps.inputRef; delete restProps.inputRef;
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = restProps.role || "button";
restProps.className = (restProps.className ? restProps.className + " " : "") + "mx_AccessibleButton"; restProps.className = (restProps.className ? restProps.className + " " : "") + "mx_AccessibleButton";
if (kind) { if (kind) {
@ -93,19 +91,30 @@ export default function AccessibleButton(props) {
*/ */
AccessibleButton.propTypes = { AccessibleButton.propTypes = {
children: PropTypes.node, 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, element: PropTypes.string,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
// The kind of button, similar to how Bootstrap works. // The kind of button, similar to how Bootstrap works.
// See available classes for AccessibleButton for options. // See available classes for AccessibleButton for options.
kind: PropTypes.string, kind: PropTypes.string,
// The ARIA role
role: PropTypes.string,
// The tabIndex
tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
disabled: PropTypes.bool, disabled: PropTypes.bool,
}; };
AccessibleButton.defaultProps = { AccessibleButton.defaultProps = {
element: 'div', element: 'div',
role: 'button',
tabIndex: "0",
}; };
AccessibleButton.displayName = "AccessibleButton"; AccessibleButton.displayName = "AccessibleButton";

View file

@ -771,7 +771,7 @@ module.exports = createReactClass({
const subListComponents = this._mapSubListProps(subLists); const subListComponents = this._mapSubListProps(subLists);
return ( return (
<div ref={this._collectResizeContainer} className="mx_RoomList" role="listbox" aria-label={_t("Rooms")} <div ref={this._collectResizeContainer} className="mx_RoomList" role="tree" aria-label={_t("Rooms")}
onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave}> onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave}>
{ subListComponents } { subListComponents }
</div> </div>

View file

@ -398,7 +398,8 @@ module.exports = createReactClass({
onMouseLeave={this.onMouseLeave} onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
aria-label={ariaLabel} aria-label={ariaLabel}
role="option" aria-selected={this.state.selected}
role="treeitem"
> >
<div className={avatarClasses}> <div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container"> <div className="mx_RoomTile_avatar_container">

View file

@ -901,11 +901,6 @@
"Forget room": "Forget room", "Forget room": "Forget room",
"Search": "Search", "Search": "Search",
"Share room": "Share room", "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", "Community Invites": "Community Invites",
"Invites": "Invites", "Invites": "Invites",
"Favourites": "Favourites", "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.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"Active call": "Active call", "Active call": "Active call",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?", "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"Add room": "Add room", "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 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?", "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?",