Merge pull request #3729 from matrix-org/t3chguy/aria_dropdown
Make combobox dropdown keyboard and screen reader accessiblepull/21833/head
commit
2569b78db3
|
@ -21,6 +21,7 @@ import sdk from '../../../index';
|
||||||
|
|
||||||
import { COUNTRIES } from '../../../phonenumber';
|
import { COUNTRIES } from '../../../phonenumber';
|
||||||
import SdkConfig from "../../../SdkConfig";
|
import SdkConfig from "../../../SdkConfig";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
|
||||||
const COUNTRIES_BY_ISO2 = {};
|
const COUNTRIES_BY_ISO2 = {};
|
||||||
for (const c of COUNTRIES) {
|
for (const c of COUNTRIES) {
|
||||||
|
@ -130,10 +131,17 @@ export default class CountryDropdown extends React.Component {
|
||||||
// values between mounting and the initial value propgating
|
// values between mounting and the initial value propgating
|
||||||
const value = this.props.value || this.state.defaultCountry.iso2;
|
const value = this.props.value || this.state.defaultCountry.iso2;
|
||||||
|
|
||||||
return <Dropdown className={this.props.className + " mx_CountryDropdown"}
|
return <Dropdown
|
||||||
onOptionChange={this._onOptionChange} onSearchChange={this._onSearchChange}
|
id="mx_CountryDropdown"
|
||||||
menuWidth={298} getShortOption={this._getShortOption}
|
className={this.props.className + " mx_CountryDropdown"}
|
||||||
value={value} searchEnabled={true} disabled={this.props.disabled}
|
onOptionChange={this._onOptionChange}
|
||||||
|
onSearchChange={this._onSearchChange}
|
||||||
|
menuWidth={298}
|
||||||
|
getShortOption={this._getShortOption}
|
||||||
|
value={value}
|
||||||
|
searchEnabled={true}
|
||||||
|
disabled={this.props.disabled}
|
||||||
|
label={_t("Country Dropdown")}
|
||||||
>
|
>
|
||||||
{ options }
|
{ options }
|
||||||
</Dropdown>;
|
</Dropdown>;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2017 Vector Creations Ltd
|
Copyright 2017 Vector Creations Ltd
|
||||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
|
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.
|
||||||
|
@ -15,11 +16,12 @@ 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 PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import AccessibleButton from './AccessibleButton';
|
import AccessibleButton from './AccessibleButton';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
import {Key} from "../../../Keyboard";
|
||||||
|
|
||||||
class MenuOption extends React.Component {
|
class MenuOption extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
|
@ -48,9 +50,14 @@ class MenuOption extends React.Component {
|
||||||
mx_Dropdown_option_highlight: this.props.highlighted,
|
mx_Dropdown_option_highlight: this.props.highlighted,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div className={optClasses}
|
return <div
|
||||||
|
id={this.props.id}
|
||||||
|
className={optClasses}
|
||||||
onClick={this._onClick}
|
onClick={this._onClick}
|
||||||
onMouseEnter={this._onMouseEnter}
|
onMouseEnter={this._onMouseEnter}
|
||||||
|
role="option"
|
||||||
|
aria-selected={this.props.highlighted}
|
||||||
|
ref={this.props.inputRef}
|
||||||
>
|
>
|
||||||
{ this.props.children }
|
{ this.props.children }
|
||||||
</div>;
|
</div>;
|
||||||
|
@ -66,6 +73,7 @@ MenuOption.propTypes = {
|
||||||
dropdownKey: PropTypes.string,
|
dropdownKey: PropTypes.string,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
onMouseEnter: PropTypes.func.isRequired,
|
onMouseEnter: PropTypes.func.isRequired,
|
||||||
|
inputRef: PropTypes.any,
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -111,6 +119,7 @@ export default class Dropdown extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
|
this._button = createRef();
|
||||||
// Listen for all clicks on the document so we can close the
|
// Listen for all clicks on the document so we can close the
|
||||||
// menu when the user clicks somewhere else
|
// menu when the user clicks somewhere else
|
||||||
document.addEventListener('click', this._onDocumentClick, false);
|
document.addEventListener('click', this._onDocumentClick, false);
|
||||||
|
@ -169,40 +178,47 @@ export default class Dropdown extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onMenuOptionClick(dropdownKey) {
|
_close() {
|
||||||
this.setState({
|
this.setState({
|
||||||
expanded: false,
|
expanded: false,
|
||||||
});
|
});
|
||||||
|
// their focus was on the input, its getting unmounted, move it to the button
|
||||||
|
if (this._button.current) {
|
||||||
|
this._button.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMenuOptionClick(dropdownKey) {
|
||||||
|
this._close();
|
||||||
this.props.onOptionChange(dropdownKey);
|
this.props.onOptionChange(dropdownKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onInputKeyPress(e) {
|
_onInputKeyPress(e) {
|
||||||
// This needs to be on the keypress event because otherwise
|
// This needs to be on the keypress event because otherwise it can't cancel the form submission
|
||||||
// it can't cancel the form submission
|
if (e.key === Key.ENTER) {
|
||||||
if (e.key == 'Enter') {
|
|
||||||
this.setState({
|
|
||||||
expanded: false,
|
|
||||||
});
|
|
||||||
this.props.onOptionChange(this.state.highlightedOption);
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onInputKeyUp(e) {
|
_onInputKeyUp(e) {
|
||||||
// These keys don't generate keypress events and so needs to
|
// These keys don't generate keypress events and so needs to be on keyup
|
||||||
// be on keyup
|
switch (e.key) {
|
||||||
if (e.key == 'Escape') {
|
case Key.ENTER:
|
||||||
this.setState({
|
this.props.onOptionChange(this.state.highlightedOption);
|
||||||
expanded: false,
|
// fallthrough
|
||||||
});
|
case Key.ESCAPE:
|
||||||
} else if (e.key == 'ArrowDown') {
|
this._close();
|
||||||
|
break;
|
||||||
|
case Key.ARROW_DOWN:
|
||||||
this.setState({
|
this.setState({
|
||||||
highlightedOption: this._nextOption(this.state.highlightedOption),
|
highlightedOption: this._nextOption(this.state.highlightedOption),
|
||||||
});
|
});
|
||||||
} else if (e.key == 'ArrowUp') {
|
break;
|
||||||
|
case Key.ARROW_UP:
|
||||||
this.setState({
|
this.setState({
|
||||||
highlightedOption: this._prevOption(this.state.highlightedOption),
|
highlightedOption: this._prevOption(this.state.highlightedOption),
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -250,20 +266,34 @@ export default class Dropdown extends React.Component {
|
||||||
return keys[(index - 1) % keys.length];
|
return keys[(index - 1) % keys.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_scrollIntoView(node) {
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_getMenuOptions() {
|
_getMenuOptions() {
|
||||||
const options = React.Children.map(this.props.children, (child) => {
|
const options = React.Children.map(this.props.children, (child) => {
|
||||||
|
const highlighted = this.state.highlightedOption === child.key;
|
||||||
return (
|
return (
|
||||||
<MenuOption key={child.key} dropdownKey={child.key}
|
<MenuOption
|
||||||
highlighted={this.state.highlightedOption == child.key}
|
id={`${this.props.id}__${child.key}`}
|
||||||
|
key={child.key}
|
||||||
|
dropdownKey={child.key}
|
||||||
|
highlighted={highlighted}
|
||||||
onMouseEnter={this._setHighlightedOption}
|
onMouseEnter={this._setHighlightedOption}
|
||||||
onClick={this._onMenuOptionClick}
|
onClick={this._onMenuOptionClick}
|
||||||
|
inputRef={highlighted ? this._scrollIntoView : undefined}
|
||||||
>
|
>
|
||||||
{ child }
|
{ child }
|
||||||
</MenuOption>
|
</MenuOption>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
return [<div key="0" className="mx_Dropdown_option">
|
return [<div key="0" className="mx_Dropdown_option" role="option">
|
||||||
{ _t("No results") }
|
{ _t("No results") }
|
||||||
</div>];
|
</div>];
|
||||||
}
|
}
|
||||||
|
@ -279,23 +309,36 @@ export default class Dropdown extends React.Component {
|
||||||
let menu;
|
let menu;
|
||||||
if (this.state.expanded) {
|
if (this.state.expanded) {
|
||||||
if (this.props.searchEnabled) {
|
if (this.props.searchEnabled) {
|
||||||
currentValue = <input type="text" className="mx_Dropdown_option"
|
currentValue = (
|
||||||
ref={this._collectInputTextBox} onKeyPress={this._onInputKeyPress}
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx_Dropdown_option"
|
||||||
|
ref={this._collectInputTextBox}
|
||||||
|
onKeyPress={this._onInputKeyPress}
|
||||||
onKeyUp={this._onInputKeyUp}
|
onKeyUp={this._onInputKeyUp}
|
||||||
onChange={this._onInputChange}
|
onChange={this._onInputChange}
|
||||||
value={this.state.searchQuery}
|
value={this.state.searchQuery}
|
||||||
/>;
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={`${this.props.id}__${this.state.highlightedOption}`}
|
||||||
|
aria-owns={`${this.props.id}_listbox`}
|
||||||
|
aria-disabled={this.props.disabled}
|
||||||
|
aria-label={this.props.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
menu = <div className="mx_Dropdown_menu" style={menuStyle}>
|
menu = (
|
||||||
|
<div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
|
||||||
{ this._getMenuOptions() }
|
{ this._getMenuOptions() }
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentValue) {
|
if (!currentValue) {
|
||||||
const selectedChild = this.props.getShortOption ?
|
const selectedChild = this.props.getShortOption ?
|
||||||
this.props.getShortOption(this.props.value) :
|
this.props.getShortOption(this.props.value) :
|
||||||
this.childrenByKey[this.props.value];
|
this.childrenByKey[this.props.value];
|
||||||
currentValue = <div className="mx_Dropdown_option">
|
currentValue = <div className="mx_Dropdown_option" id={`${this.props.id}_value`}>
|
||||||
{ selectedChild }
|
{ selectedChild }
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
@ -311,9 +354,18 @@ export default class Dropdown extends React.Component {
|
||||||
// Note the menu sits inside the AccessibleButton div so it's anchored
|
// Note the menu sits inside the AccessibleButton div so it's anchored
|
||||||
// to the input, but overflows below it. The root contains both.
|
// to the input, but overflows below it. The root contains both.
|
||||||
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
|
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
|
||||||
<AccessibleButton className="mx_Dropdown_input mx_no_textinput" onClick={this._onInputClick}>
|
<AccessibleButton
|
||||||
|
className="mx_Dropdown_input mx_no_textinput"
|
||||||
|
onClick={this._onInputClick}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={this.state.expanded}
|
||||||
|
disabled={this.props.disabled}
|
||||||
|
inputRef={this._button}
|
||||||
|
aria-label={this.props.label}
|
||||||
|
aria-describedby={`${this.props.id}_value`}
|
||||||
|
>
|
||||||
{ currentValue }
|
{ currentValue }
|
||||||
<span className="mx_Dropdown_arrow"></span>
|
<span className="mx_Dropdown_arrow" />
|
||||||
{ menu }
|
{ menu }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</div>;
|
</div>;
|
||||||
|
@ -321,6 +373,7 @@ export default class Dropdown extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
Dropdown.propTypes = {
|
Dropdown.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
// The width that the dropdown should be. If specified,
|
// The width that the dropdown should be. If specified,
|
||||||
// the dropped-down part of the menu will be set to this
|
// the dropped-down part of the menu will be set to this
|
||||||
// width.
|
// width.
|
||||||
|
@ -340,4 +393,6 @@ Dropdown.propTypes = {
|
||||||
value: PropTypes.string,
|
value: PropTypes.string,
|
||||||
// negative for consistency with HTML
|
// negative for consistency with HTML
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
|
// ARIA label
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,6 +21,7 @@ import PropTypes from 'prop-types';
|
||||||
import sdk from '../../../index';
|
import sdk from '../../../index';
|
||||||
import * as languageHandler from '../../../languageHandler';
|
import * as languageHandler from '../../../languageHandler';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
|
||||||
function languageMatchesSearchQuery(query, language) {
|
function languageMatchesSearchQuery(query, language) {
|
||||||
if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
|
if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
|
||||||
|
@ -105,9 +106,14 @@ export default class LanguageDropdown extends React.Component {
|
||||||
value = this.props.value || language;
|
value = this.props.value || language;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Dropdown className={this.props.className}
|
return <Dropdown
|
||||||
onOptionChange={this.props.onOptionChange} onSearchChange={this._onSearchChange}
|
id="mx_LanguageDropdown"
|
||||||
searchEnabled={true} value={value}
|
className={this.props.className}
|
||||||
|
onOptionChange={this.props.onOptionChange}
|
||||||
|
onSearchChange={this._onSearchChange}
|
||||||
|
searchEnabled={true}
|
||||||
|
value={value}
|
||||||
|
label={_t("Language Dropdown")}
|
||||||
>
|
>
|
||||||
{ options }
|
{ options }
|
||||||
</Dropdown>;
|
</Dropdown>;
|
||||||
|
|
|
@ -1268,6 +1268,7 @@
|
||||||
"Rotate Right": "Rotate Right",
|
"Rotate Right": "Rotate Right",
|
||||||
"Rotate clockwise": "Rotate clockwise",
|
"Rotate clockwise": "Rotate clockwise",
|
||||||
"Download this file": "Download this file",
|
"Download this file": "Download this file",
|
||||||
|
"Language Dropdown": "Language Dropdown",
|
||||||
"Manage Integrations": "Manage Integrations",
|
"Manage Integrations": "Manage Integrations",
|
||||||
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
|
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
|
||||||
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",
|
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",
|
||||||
|
@ -1633,6 +1634,7 @@
|
||||||
"User Status": "User Status",
|
"User Status": "User Status",
|
||||||
"powered by Matrix": "powered by Matrix",
|
"powered by Matrix": "powered by Matrix",
|
||||||
"This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.",
|
"This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.",
|
||||||
|
"Country Dropdown": "Country Dropdown",
|
||||||
"Custom Server Options": "Custom Server Options",
|
"Custom Server Options": "Custom Server Options",
|
||||||
"You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.",
|
"You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.",
|
||||||
"To continue, please enter your password.": "To continue, please enter your password.",
|
"To continue, please enter your password.": "To continue, please enter your password.",
|
||||||
|
|
Loading…
Reference in New Issue