diff --git a/res/css/_common.scss b/res/css/_common.scss index d47bfdb7c0..3fd059acab 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -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 diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 4771c6f487..0ad2f72cfc 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -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) { diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js index b68d3a95a0..f745a7f7bc 100644 --- a/src/components/structures/TopLeftMenuButton.js +++ b/src/components/structures/TopLeftMenuButton.js @@ -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. @@ -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; @@ -37,6 +40,7 @@ export default class TopLeftMenuButton extends React.Component { super(); this.state = { menuDisplayed: false, + menuFunctions: null, // should be { close: fn } profileInfo: null, }; @@ -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}); @@ -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"); @@ -88,7 +105,13 @@ export default class TopLeftMenuButton extends React.Component { } return ( - + this._buttonRef = r} + aria-label={_t("Your profile")} + > { nameElement } - + ); } @@ -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 }); } } diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index db663e08a2..5b299c2570 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -156,7 +156,7 @@ module.exports = React.createClass({ const imgNode = ( + width={width} height={height} aria-hidden="true" /> ); if (onClick != null) { return ( diff --git a/src/components/views/context_menus/TopLeftMenu.js b/src/components/views/context_menus/TopLeftMenu.js index 278c879404..09e9142201 100644 --- a/src/components/views/context_menus/TopLeftMenu.js +++ b/src/components/views/context_menus/TopLeftMenu.js @@ -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. @@ -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() { @@ -61,44 +66,48 @@ export class TopLeftMenu extends React.Component { {_t( "Upgrade to your own domain", {}, { - a: sub => {sub}, + a: sub => {sub}, }, )} - + ; } - let homePageSection = null; + let homePageItem = null; if (this.hasHomePage()) { - homePageSection = ; + homePageItem =
  • + {_t("Home")} +
  • ; } - let signInOutSection; + let signInOutItem; if (isGuest) { - signInOutSection = ; + signInOutItem =
  • + {_t("Sign in")} +
  • ; } else { - signInOutSection = ; + signInOutItem =
  • + {_t("Sign out")} +
  • ; } - return
    -
    + const settingsItem =
  • + {_t("Settings")} +
  • ; + + return
    +
    {this.props.displayName}
    -
    {this.props.userId}
    +
    {this.props.userId}
    {hostingSignup}
    - {homePageSection}
      -
    • {_t("Settings")}
    • + {homePageItem} + {settingsItem} + {signInOutItem}
    - {signInOutSection}
    ; } diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js index 1c39ba4f49..06c440c54e 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.js @@ -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 + " " : "") + @@ -89,6 +93,7 @@ export default function AccessibleButton(props) { */ AccessibleButton.propTypes = { children: PropTypes.node, + inputRef: PropTypes.func, element: PropTypes.string, onClick: PropTypes.func.isRequired, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ad5cdd248d..2eebd16bcf 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/utils/Accessibility.js b/src/utils/Accessibility.js new file mode 100644 index 0000000000..f4909f971b --- /dev/null +++ b/src/utils/Accessibility.js @@ -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(); + } +}