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

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,9 +57,8 @@ 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,
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
forceExpand: PropTypes.bool,
}, },
getInitialState: function() { getInitialState: function() {
@ -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);
@ -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) { onRoomTileClick(roomId, ev) {
dis.dispatch({ dis.dispatch({
action: 'view_room', 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) { _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');
@ -208,13 +257,24 @@ const RoomSubList = createReactClass({
'mx_RoomSubList_badge': true, 'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight, '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) { if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}> badge = (
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
<div>
{ FormattingUtils.formatCount(subListNotifCount) } { FormattingUtils.formatCount(subListNotifCount) }
</div>; </div>
</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.")}>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
} }
} }
@ -237,7 +297,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 +315,16 @@ 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" onKeyDown={this.onHeaderKeyDown}>
<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}
role="treeitem"
>
{ chevron } { chevron }
<span>{this.props.label}</span> <span>{this.props.label}</span>
{ incomingCall } { incomingCall }
@ -299,20 +365,19 @@ const RoomSubList = createReactClass({
render: function() { render: function() {
const len = this.props.list.length + this.props.extraTiles.length; const len = this.props.list.length + this.props.extraTiles.length;
const isCollapsed = this.state.hidden && !this.props.forceExpand; const isCollapsed = this.state.hidden && !this.props.forceExpand;
if (len) {
const subListClasses = classNames({ const subListClasses = classNames({
"mx_RoomSubList": true, "mx_RoomSubList": true,
"mx_RoomSubList_hidden": isCollapsed, "mx_RoomSubList_hidden": len && isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed, "mx_RoomSubList_nonEmpty": len && !isCollapsed,
}); });
let content;
if (len) {
if (isCollapsed) { if (isCollapsed) {
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}> // no body
{this._getHeaderJsx(isCollapsed)}
</div>;
} else if (this._canUseLazyListRendering()) { } else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}> content = (
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}> <IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
<LazyRenderList <LazyRenderList
scrollTop={this.state.scrollTop } scrollTop={this.state.scrollTop }
@ -321,31 +386,35 @@ const RoomSubList = createReactClass({
itemHeight={34} itemHeight={34}
items={ this.props.list } /> items={ this.props.list } />
</IndicatorScrollbar> </IndicatorScrollbar>
</div>; );
} else { } else {
const roomTiles = this.props.list.map(r => this.makeRoomTile(r)); const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles); const tiles = roomTiles.concat(this.props.extraTiles);
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}> content = (
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}> <IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
{ tiles } { tiles }
</IndicatorScrollbar> </IndicatorScrollbar>
</div>; );
} }
} else { } else {
const Loader = sdk.getComponent("elements.Spinner");
let content;
if (this.props.showSpinner && !isCollapsed) { if (this.props.showSpinner && !isCollapsed) {
const Loader = sdk.getComponent("elements.Spinner");
content = <Loader />; content = <Loader />;
} }
}
return ( return (
<div ref="subList" className="mx_RoomSubList" role="group" aria-label={this.props.label}> <div
ref="subList"
className={subListClasses}
role="group"
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{ this._getHeaderJsx(isCollapsed) } { this._getHeaderJsx(isCollapsed) }
{ content } { content }
</div> </div>
); );
}
}, },
}); });

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

@ -49,21 +49,6 @@ function labelForTagName(tagName) {
return 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({ module.exports = createReactClass({
displayName: 'RoomList', displayName: 'RoomList',
@ -203,7 +188,7 @@ module.exports = createReactClass({
this.resizer.setClassNames({ this.resizer.setClassNames({
handle: "mx_ResizeHandle", handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical", vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse" reverse: "mx_ResizeHandle_reverse",
}); });
this._layout.update( this._layout.update(
this._layoutSections, 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) { _makeGroupInviteTiles(filter) {
const ret = []; const ret = [];
const lcFilter = filter && filter.toLowerCase(); const lcFilter = filter && filter.toLowerCase();
@ -746,16 +714,14 @@ module.exports = createReactClass({
list: this.state.lists['im.vector.fake.direct'], list: this.state.lists['im.vector.fake.direct'],
label: _t('People'), label: _t('People'),
tagName: "im.vector.fake.direct", tagName: "im.vector.fake.direct",
headerItems: this._getHeaderItems('im.vector.fake.direct'),
order: "recent", order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'), incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'})}, onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
addRoomLabel: _t("Start chat"), addRoomLabel: _t("Start chat"),
}, },
{ {
list: this.state.lists['im.vector.fake.recent'], list: this.state.lists['im.vector.fake.recent'],
label: _t('Rooms'), label: _t('Rooms'),
headerItems: this._getHeaderItems('im.vector.fake.recent'),
order: "recent", order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'), incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});}, onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
@ -805,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

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