Merge pull request #2994 from matrix-org/travis/screenreader/topleftmenu
Update TopLeftMenu for accessibility: Keyboard shortcut, reduced screen reader noise
This commit is contained in:
commit
907c7ed119
8 changed files with 117 additions and 25 deletions
|
@ -114,6 +114,14 @@ textarea {
|
||||||
color: $primary-fg-color;
|
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
|
// .mx_textinput is a container for a text input
|
||||||
// + some other controls like buttons, ...
|
// + some other controls like buttons, ...
|
||||||
// it has the appearance of a text box so the controls
|
// it has the appearance of a text box so the controls
|
||||||
|
|
|
@ -322,6 +322,18 @@ const LoggedInView = React.createClass({
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
break;
|
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) {
|
if (handled) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2018 New Vector Ltd
|
Copyright 2018 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.
|
||||||
|
@ -23,6 +24,8 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||||
import Avatar from '../../Avatar';
|
import Avatar from '../../Avatar';
|
||||||
import { _t } from '../../languageHandler';
|
import { _t } from '../../languageHandler';
|
||||||
|
import dis from "../../dispatcher";
|
||||||
|
import {focusCapturedRef} from "../../utils/Accessibility";
|
||||||
|
|
||||||
const AVATAR_SIZE = 28;
|
const AVATAR_SIZE = 28;
|
||||||
|
|
||||||
|
@ -37,6 +40,7 @@ export default class TopLeftMenuButton extends React.Component {
|
||||||
super();
|
super();
|
||||||
this.state = {
|
this.state = {
|
||||||
menuDisplayed: false,
|
menuDisplayed: false,
|
||||||
|
menuFunctions: null, // should be { close: fn }
|
||||||
profileInfo: null,
|
profileInfo: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -59,6 +63,8 @@ export default class TopLeftMenuButton extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
|
this._dispatcherRef = dis.register(this.onAction);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profileInfo = await this._getProfileInfo();
|
const profileInfo = await this._getProfileInfo();
|
||||||
this.setState({profileInfo});
|
this.setState({profileInfo});
|
||||||
|
@ -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() {
|
_getDisplayName() {
|
||||||
if (MatrixClientPeg.get().isGuest()) {
|
if (MatrixClientPeg.get().isGuest()) {
|
||||||
return _t("Guest");
|
return _t("Guest");
|
||||||
|
@ -88,7 +105,13 @@ export default class TopLeftMenuButton extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<BaseAvatar
|
||||||
idName={MatrixClientPeg.get().getUserId()}
|
idName={MatrixClientPeg.get().getUserId()}
|
||||||
name={name}
|
name={name}
|
||||||
|
@ -98,7 +121,7 @@ export default class TopLeftMenuButton extends React.Component {
|
||||||
resizeMethod="crop"
|
resizeMethod="crop"
|
||||||
/>
|
/>
|
||||||
{ nameElement }
|
{ nameElement }
|
||||||
<span className="mx_TopLeftMenuButton_chevron"></span>
|
<span className="mx_TopLeftMenuButton_chevron" />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -107,20 +130,26 @@ export default class TopLeftMenuButton extends React.Component {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (this.state.menuDisplayed && this.state.menuFunctions) {
|
||||||
|
this.state.menuFunctions.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const elementRect = e.currentTarget.getBoundingClientRect();
|
const elementRect = e.currentTarget.getBoundingClientRect();
|
||||||
const x = elementRect.left;
|
const x = elementRect.left;
|
||||||
const y = elementRect.top + elementRect.height;
|
const y = elementRect.top + elementRect.height;
|
||||||
|
|
||||||
ContextualMenu.createMenu(TopLeftMenu, {
|
const menuFunctions = ContextualMenu.createMenu(TopLeftMenu, {
|
||||||
chevronFace: "none",
|
chevronFace: "none",
|
||||||
left: x,
|
left: x,
|
||||||
top: y,
|
top: y,
|
||||||
userId: MatrixClientPeg.get().getUserId(),
|
userId: MatrixClientPeg.get().getUserId(),
|
||||||
displayName: this._getDisplayName(),
|
displayName: this._getDisplayName(),
|
||||||
|
containerRef: focusCapturedRef, // Focus the TopLeftMenu on first render
|
||||||
onFinished: () => {
|
onFinished: () => {
|
||||||
this.setState({ menuDisplayed: false });
|
this.setState({ menuDisplayed: false, menuFunctions: null });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.setState({ menuDisplayed: true });
|
this.setState({ menuDisplayed: true, menuFunctions });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -156,7 +156,7 @@ module.exports = React.createClass({
|
||||||
const imgNode = (
|
const imgNode = (
|
||||||
<img className="mx_BaseAvatar_image" src={imageUrl}
|
<img className="mx_BaseAvatar_image" src={imageUrl}
|
||||||
alt="" title={title} onError={this.onError}
|
alt="" title={title} onError={this.onError}
|
||||||
width={width} height={height} />
|
width={width} height={height} aria-hidden="true" />
|
||||||
);
|
);
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
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.
|
||||||
|
@ -29,6 +30,10 @@ export class TopLeftMenu extends React.Component {
|
||||||
displayName: PropTypes.string.isRequired,
|
displayName: PropTypes.string.isRequired,
|
||||||
userId: PropTypes.string.isRequired,
|
userId: PropTypes.string.isRequired,
|
||||||
onFinished: PropTypes.func,
|
onFinished: PropTypes.func,
|
||||||
|
|
||||||
|
// Optional function to collect a reference to the container
|
||||||
|
// of this component directly.
|
||||||
|
containerRef: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -61,44 +66,48 @@ export class TopLeftMenu extends React.Component {
|
||||||
{_t(
|
{_t(
|
||||||
"<a>Upgrade</a> to your own domain", {},
|
"<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='' />
|
<img src={require("../../../../res/img/external-link.svg")} width="11" height="10" alt='' />
|
||||||
</a>
|
</a>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let homePageSection = null;
|
let homePageItem = null;
|
||||||
if (this.hasHomePage()) {
|
if (this.hasHomePage()) {
|
||||||
homePageSection = <ul className="mx_TopLeftMenu_section_withIcon">
|
homePageItem = <li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage} tabIndex={0}>
|
||||||
<li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage}>{_t("Home")}</li>
|
{_t("Home")}
|
||||||
</ul>;
|
</li>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let signInOutSection;
|
let signInOutItem;
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
signInOutSection = <ul className="mx_TopLeftMenu_section_withIcon">
|
signInOutItem = <li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn} tabIndex={0}>
|
||||||
<li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn}>{_t("Sign in")}</li>
|
{_t("Sign in")}
|
||||||
</ul>;
|
</li>;
|
||||||
} else {
|
} else {
|
||||||
signInOutSection = <ul className="mx_TopLeftMenu_section_withIcon">
|
signInOutItem = <li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut} tabIndex={0}>
|
||||||
<li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut}>{_t("Sign out")}</li>
|
{_t("Sign out")}
|
||||||
</ul>;
|
</li>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="mx_TopLeftMenu">
|
const settingsItem = <li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings} tabIndex={0}>
|
||||||
<div className="mx_TopLeftMenu_section_noIcon">
|
{_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>{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}
|
{hostingSignup}
|
||||||
</div>
|
</div>
|
||||||
{homePageSection}
|
|
||||||
<ul className="mx_TopLeftMenu_section_withIcon">
|
<ul className="mx_TopLeftMenu_section_withIcon">
|
||||||
<li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings}>{_t("Settings")}</li>
|
{homePageItem}
|
||||||
|
{settingsItem}
|
||||||
|
{signInOutItem}
|
||||||
</ul>
|
</ul>
|
||||||
{signInOutSection}
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.tabIndex = restProps.tabIndex || "0";
|
||||||
restProps.role = "button";
|
restProps.role = "button";
|
||||||
restProps.className = (restProps.className ? restProps.className + " " : "") +
|
restProps.className = (restProps.className ? restProps.className + " " : "") +
|
||||||
|
@ -89,6 +93,7 @@ export default function AccessibleButton(props) {
|
||||||
*/
|
*/
|
||||||
AccessibleButton.propTypes = {
|
AccessibleButton.propTypes = {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
|
inputRef: PropTypes.func,
|
||||||
element: PropTypes.string,
|
element: PropTypes.string,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
|
|
||||||
|
|
|
@ -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.",
|
"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",
|
"Failed to load timeline position": "Failed to load timeline position",
|
||||||
"Guest": "Guest",
|
"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|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|zero": "Uploading %(filename)s",
|
||||||
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
|
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
|
||||||
|
|
28
src/utils/Accessibility.js
Normal file
28
src/utils/Accessibility.js
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue