Merge pull request #3556 from matrix-org/t3chguy/a11y4

Use Navigation Treeview pattern for RoomList Accessibility
This commit is contained in:
Michael Telatynski 2019-10-22 13:42:08 +01:00 committed by GitHub
commit e7f292794c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 145 additions and 93 deletions

View file

@ -20,7 +20,7 @@ limitations under the License.
so they ideally wouldn't affect each other.
lowest category: .mx_RoomSubList
flex-shrink: 10000000
distribute size of items within the same categery by their size
distribute size of items within the same category by their size
middle category: .mx_RoomSubList.resized-sized
flex-shrink: 1000
applied when using the resizer, will have a max-height set to it,
@ -46,10 +46,15 @@ limitations under the License.
flex-direction: row;
align-items: center;
flex: 0 0 auto;
margin: 0 16px;
margin: 0 8px;
padding: 0 8px;
height: 36px;
}
.mx_RoomSubList_labelContainer:focus-within {
background-color: $roomtile-focused-bg-color;
}
.mx_RoomSubList_label {
flex: 1;
cursor: pointer;
@ -67,7 +72,7 @@ limitations under the License.
margin-left: 8px;
}
.mx_RoomSubList_badge {
.mx_RoomSubList_badge > div {
flex: 0 0 auto;
border-radius: 8px;
font-weight: 600;
@ -103,7 +108,7 @@ limitations under the License.
}
}
.mx_RoomSubList_badgeHighlight {
.mx_RoomSubList_badgeHighlight > div {
color: $accent-fg-color;
background-color: $warning-color;
}

View file

@ -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;

View file

@ -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",

View file

@ -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) {

View file

@ -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,9 +57,8 @@ const RoomSubList = createReactClass({
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func,
incomingCall: PropTypes.object,
isFiltered: PropTypes.bool,
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
forceExpand: PropTypes.bool,
},
getInitialState: function() {
@ -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);
@ -126,6 +127,49 @@ const RoomSubList = createReactClass({
}
},
onHeaderKeyDown: 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:
// On ARROW_LEFT collapse the room sublist
if (!this.state.hidden && !this.props.forceExpand) {
this.onClick();
}
ev.stopPropagation();
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
if (this.state.hidden && !this.props.forceExpand) {
// sublist is collapsed, expand it
this.onClick();
} else if (!this.props.forceExpand) {
// sublist is expanded, go to first room
const element = this.refs.subList && this.refs.subList.querySelector(".mx_RoomTile");
if (element) {
element.focus();
}
}
break;
}
}
},
onKeyDown: function(ev) {
switch (ev.key) {
// On ARROW_LEFT go to the sublist header
case Key.ARROW_LEFT:
ev.stopPropagation();
this._headerButton.current.focus();
break;
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
case Key.ARROW_RIGHT:
ev.stopPropagation();
}
},
onRoomTileClick(roomId, ev) {
dis.dispatch({
action: 'view_room',
@ -193,6 +237,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');
@ -208,13 +257,24 @@ const RoomSubList = createReactClass({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// 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.")}>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
@ -237,7 +297,7 @@ const RoomSubList = createReactClass({
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
onClick={ this.props.onAddRoom }
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
@ -255,10 +315,16 @@ const RoomSubList = createReactClass({
chevron = (<div className={chevronClasses} />);
}
const tabindex = this.props.isFiltered ? "0" : "-1";
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex} aria-expanded={!isCollapsed}>
<div className="mx_RoomSubList_labelContainer" title={title} ref="header" onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onClick={this.onClick}
className="mx_RoomSubList_label"
tabIndex={0}
aria-expanded={!isCollapsed}
inputRef={this._headerButton}
role="treeitem"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
@ -299,21 +365,20 @@ const RoomSubList = createReactClass({
render: function() {
const len = this.props.list.length + this.props.extraTiles.length;
const isCollapsed = this.state.hidden && !this.props.forceExpand;
if (len) {
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": len && isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
let content;
if (len) {
if (isCollapsed) {
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}>
{this._getHeaderJsx(isCollapsed)}
</div>;
// no body
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
content = (
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
<LazyRenderList
scrollTop={this.state.scrollTop }
height={ this.state.scrollerHeight }
@ -321,31 +386,35 @@ const RoomSubList = createReactClass({
itemHeight={34}
items={ this.props.list } />
</IndicatorScrollbar>
</div>;
);
} else {
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles);
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
content = (
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
{ tiles }
</IndicatorScrollbar>
</div>;
);
}
} else {
const Loader = sdk.getComponent("elements.Spinner");
let content;
if (this.props.showSpinner && !isCollapsed) {
const Loader = sdk.getComponent("elements.Spinner");
content = <Loader />;
}
return (
<div ref="subList" className="mx_RoomSubList" role="group" aria-label={this.props.label}>
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
}
return (
<div
ref="subList"
className={subListClasses}
role="group"
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
},
});

View file

@ -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";

View file

@ -49,21 +49,6 @@ function labelForTagName(tagName) {
return tagName;
}
function phraseForSection(section) {
switch (section) {
case 'm.favourite':
return _t('Drop here to favourite');
case 'im.vector.fake.direct':
return _t('Drop here to tag direct chat');
case 'im.vector.fake.recent':
return _t('Drop here to restore');
case 'm.lowpriority':
return _t('Drop here to demote');
default:
return _t('Drop here to tag %(section)s', {section: section});
}
}
module.exports = createReactClass({
displayName: 'RoomList',
@ -203,7 +188,7 @@ module.exports = createReactClass({
this.resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse"
reverse: "mx_ResizeHandle_reverse",
});
this._layout.update(
this._layoutSections,
@ -584,23 +569,6 @@ module.exports = createReactClass({
}
},
_getHeaderItems: function(section) {
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
switch (section) {
case 'im.vector.fake.direct':
return <span className="mx_RoomList_headerButtons">
<StartChatButton size="16" />
</span>;
case 'im.vector.fake.recent':
return <span className="mx_RoomList_headerButtons">
<RoomDirectoryButton size="16" />
<CreateRoomButton size="16" />
</span>;
}
},
_makeGroupInviteTiles(filter) {
const ret = [];
const lcFilter = filter && filter.toLowerCase();
@ -676,7 +644,7 @@ module.exports = createReactClass({
props = Object.assign({}, defaultProps, props);
const isLast = i === subListsProps.length - 1;
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
const {key, label, onHeaderClick, ... otherProps} = props;
const {key, label, onHeaderClick, ...otherProps} = props;
const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => {
this._handleCollapsedState(chosenKey, collapsed);
@ -746,16 +714,14 @@ module.exports = createReactClass({
list: this.state.lists['im.vector.fake.direct'],
label: _t('People'),
tagName: "im.vector.fake.direct",
headerItems: this._getHeaderItems('im.vector.fake.direct'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'})},
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
addRoomLabel: _t("Start chat"),
},
{
list: this.state.lists['im.vector.fake.recent'],
label: _t('Rooms'),
headerItems: this._getHeaderItems('im.vector.fake.recent'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
@ -805,7 +771,7 @@ module.exports = createReactClass({
const subListComponents = this._mapSubListProps(subLists);
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}>
{ subListComponents }
</div>

View file

@ -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"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">

View file

@ -907,11 +907,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",
@ -1664,6 +1659,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 <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",
"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?",