Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #2994 from matrix-org/travis/screenreader/topleftmenu
Browse files Browse the repository at this point in the history
Update TopLeftMenu for accessibility: Keyboard shortcut, reduced screen reader noise
  • Loading branch information
turt2live authored May 21, 2019
2 parents b4ca586 + bf28993 commit 907c7ed
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 25 deletions.
8 changes: 8 additions & 0 deletions res/css/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ textarea {
color: $primary-fg-color;
}

// This is used to hide the standard outline added by browsers for
// accessible (focusable) components. Not intended for buttons, but
// should be used on things like focusable containers where the outline
// is usually not helping anyone.
.mx_HiddenFocusable {
outline: none;
}

// .mx_textinput is a container for a text input
// + some other controls like buttons, ...
// it has the appearance of a text box so the controls
Expand Down
12 changes: 12 additions & 0 deletions src/components/structures/LoggedInView.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,18 @@ const LoggedInView = React.createClass({
handled = true;
}
break;
case KeyCode.KEY_I:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// will have to do.

if (ctrlCmdOnly) {
dis.dispatch({
action: 'toggle_top_left_menu',
});
handled = true;
}
break;
}

if (handled) {
Expand Down
39 changes: 34 additions & 5 deletions src/components/structures/TopLeftMenuButton.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
Copyright 2018 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.
Expand All @@ -23,6 +24,8 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar';
import { _t } from '../../languageHandler';
import dis from "../../dispatcher";
import {focusCapturedRef} from "../../utils/Accessibility";

const AVATAR_SIZE = 28;

Expand All @@ -37,6 +40,7 @@ export default class TopLeftMenuButton extends React.Component {
super();
this.state = {
menuDisplayed: false,
menuFunctions: null, // should be { close: fn }
profileInfo: null,
};

Expand All @@ -59,6 +63,8 @@ export default class TopLeftMenuButton extends React.Component {
}

async componentDidMount() {
this._dispatcherRef = dis.register(this.onAction);

try {
const profileInfo = await this._getProfileInfo();
this.setState({profileInfo});
Expand All @@ -68,6 +74,17 @@ export default class TopLeftMenuButton extends React.Component {
}
}

componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}

onAction = (payload) => {
// For accessibility
if (payload.action === "toggle_top_left_menu") {
if (this._buttonRef) this._buttonRef.click();
}
};

_getDisplayName() {
if (MatrixClientPeg.get().isGuest()) {
return _t("Guest");
Expand All @@ -88,7 +105,13 @@ export default class TopLeftMenuButton extends React.Component {
}

return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<AccessibleButton
className="mx_TopLeftMenuButton"
role="button"
onClick={this.onToggleMenu}
inputRef={(r) => this._buttonRef = r}
aria-label={_t("Your profile")}
>
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={name}
Expand All @@ -98,7 +121,7 @@ export default class TopLeftMenuButton extends React.Component {
resizeMethod="crop"
/>
{ nameElement }
<span className="mx_TopLeftMenuButton_chevron"></span>
<span className="mx_TopLeftMenuButton_chevron" />
</AccessibleButton>
);
}
Expand All @@ -107,20 +130,26 @@ export default class TopLeftMenuButton extends React.Component {
e.preventDefault();
e.stopPropagation();

if (this.state.menuDisplayed && this.state.menuFunctions) {
this.state.menuFunctions.close();
return;
}

const elementRect = e.currentTarget.getBoundingClientRect();
const x = elementRect.left;
const y = elementRect.top + elementRect.height;

ContextualMenu.createMenu(TopLeftMenu, {
const menuFunctions = ContextualMenu.createMenu(TopLeftMenu, {
chevronFace: "none",
left: x,
top: y,
userId: MatrixClientPeg.get().getUserId(),
displayName: this._getDisplayName(),
containerRef: focusCapturedRef, // Focus the TopLeftMenu on first render
onFinished: () => {
this.setState({ menuDisplayed: false });
this.setState({ menuDisplayed: false, menuFunctions: null });
},
});
this.setState({ menuDisplayed: true });
this.setState({ menuDisplayed: true, menuFunctions });
}
}
2 changes: 1 addition & 1 deletion src/components/views/avatars/BaseAvatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ module.exports = React.createClass({
const imgNode = (
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
width={width} height={height} aria-hidden="true" />
);
if (onClick != null) {
return (
Expand Down
47 changes: 28 additions & 19 deletions src/components/views/context_menus/TopLeftMenu.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
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.
Expand Down Expand Up @@ -29,6 +30,10 @@ export class TopLeftMenu extends React.Component {
displayName: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
onFinished: PropTypes.func,

// Optional function to collect a reference to the container
// of this component directly.
containerRef: PropTypes.func,
};

constructor() {
Expand Down Expand Up @@ -61,44 +66,48 @@ export class TopLeftMenu extends React.Component {
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener">{sub}</a>,
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener" tabIndex="0">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener">
<a href={hostingSignupLink} target="_blank" rel="noopener" aria-hidden={true}>
<img src={require("../../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
}

let homePageSection = null;
let homePageItem = null;
if (this.hasHomePage()) {
homePageSection = <ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage}>{_t("Home")}</li>
</ul>;
homePageItem = <li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage} tabIndex={0}>
{_t("Home")}
</li>;
}

let signInOutSection;
let signInOutItem;
if (isGuest) {
signInOutSection = <ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn}>{_t("Sign in")}</li>
</ul>;
signInOutItem = <li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn} tabIndex={0}>
{_t("Sign in")}
</li>;
} else {
signInOutSection = <ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut}>{_t("Sign out")}</li>
</ul>;
signInOutItem = <li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut} tabIndex={0}>
{_t("Sign out")}
</li>;
}

return <div className="mx_TopLeftMenu">
<div className="mx_TopLeftMenu_section_noIcon">
const settingsItem = <li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings} tabIndex={0}>
{_t("Settings")}
</li>;

return <div className="mx_TopLeftMenu mx_HiddenFocusable" tabIndex={0} ref={this.props.containerRef}>
<div className="mx_TopLeftMenu_section_noIcon" aria-readonly={true}>
<div>{this.props.displayName}</div>
<div className="mx_TopLeftMenu_greyedText">{this.props.userId}</div>
<div className="mx_TopLeftMenu_greyedText" aria-hidden={true}>{this.props.userId}</div>
{hostingSignup}
</div>
{homePageSection}
<ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings}>{_t("Settings")}</li>
{homePageItem}
{settingsItem}
{signInOutItem}
</ul>
{signInOutSection}
</div>;
}

Expand Down
5 changes: 5 additions & 0 deletions src/components/views/elements/AccessibleButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export default function AccessibleButton(props) {
};
}

// Pass through the ref - used for keyboard shortcut access to some buttons
restProps.ref = restProps.inputRef;
delete restProps.inputRef;

restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button";
restProps.className = (restProps.className ? restProps.className + " " : "") +
Expand All @@ -89,6 +93,7 @@ export default function AccessibleButton(props) {
*/
AccessibleButton.propTypes = {
children: PropTypes.node,
inputRef: PropTypes.func,
element: PropTypes.string,
onClick: PropTypes.func.isRequired,

Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,7 @@
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position",
"Guest": "Guest",
"Your profile": "Your profile",
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
Expand Down
28 changes: 28 additions & 0 deletions src/utils/Accessibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
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.
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.
*/

/**
* Automatically focuses the captured reference when receiving a non-null
* object. Useful in scenarios where componentDidMount does not have a
* useful reference to an element, but one needs to focus the element on
* first render. Example usage: ref={focusCapturedRef}
* @param {function} ref The React reference to focus on, if not null
*/
export function focusCapturedRef(ref) {
if (ref) {
ref.focus();
}
}

0 comments on commit 907c7ed

Please sign in to comment.