Skip to content

Commit 2569b78

Browse files
authored
Merge pull request matrix-org#3729 from matrix-org/t3chguy/aria_dropdown
Make combobox dropdown keyboard and screen reader accessible
2 parents be914c7 + 9c4eb1d commit 2569b78

File tree

4 files changed

+117
-46
lines changed

4 files changed

+117
-46
lines changed

src/components/views/auth/CountryDropdown.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import sdk from '../../../index';
2121

2222
import { COUNTRIES } from '../../../phonenumber';
2323
import SdkConfig from "../../../SdkConfig";
24+
import { _t } from "../../../languageHandler";
2425

2526
const COUNTRIES_BY_ISO2 = {};
2627
for (const c of COUNTRIES) {
@@ -130,10 +131,17 @@ export default class CountryDropdown extends React.Component {
130131
// values between mounting and the initial value propgating
131132
const value = this.props.value || this.state.defaultCountry.iso2;
132133

133-
return <Dropdown className={this.props.className + " mx_CountryDropdown"}
134-
onOptionChange={this._onOptionChange} onSearchChange={this._onSearchChange}
135-
menuWidth={298} getShortOption={this._getShortOption}
136-
value={value} searchEnabled={true} disabled={this.props.disabled}
134+
return <Dropdown
135+
id="mx_CountryDropdown"
136+
className={this.props.className + " mx_CountryDropdown"}
137+
onOptionChange={this._onOptionChange}
138+
onSearchChange={this._onSearchChange}
139+
menuWidth={298}
140+
getShortOption={this._getShortOption}
141+
value={value}
142+
searchEnabled={true}
143+
disabled={this.props.disabled}
144+
label={_t("Country Dropdown")}
137145
>
138146
{ options }
139147
</Dropdown>;

src/components/views/elements/Dropdown.js

Lines changed: 94 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
Copyright 2017 Vector Creations Ltd
33
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
4+
Copyright 2019 The Matrix.org Foundation C.I.C.
45
56
Licensed under the Apache License, Version 2.0 (the "License");
67
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
1516
limitations under the License.
1617
*/
1718

18-
import React from 'react';
19+
import React, {createRef} from 'react';
1920
import PropTypes from 'prop-types';
2021
import classnames from 'classnames';
2122
import AccessibleButton from './AccessibleButton';
2223
import { _t } from '../../../languageHandler';
24+
import {Key} from "../../../Keyboard";
2325

2426
class MenuOption extends React.Component {
2527
constructor(props) {
@@ -48,9 +50,14 @@ class MenuOption extends React.Component {
4850
mx_Dropdown_option_highlight: this.props.highlighted,
4951
});
5052

51-
return <div className={optClasses}
53+
return <div
54+
id={this.props.id}
55+
className={optClasses}
5256
onClick={this._onClick}
5357
onMouseEnter={this._onMouseEnter}
58+
role="option"
59+
aria-selected={this.props.highlighted}
60+
ref={this.props.inputRef}
5461
>
5562
{ this.props.children }
5663
</div>;
@@ -66,6 +73,7 @@ MenuOption.propTypes = {
6673
dropdownKey: PropTypes.string,
6774
onClick: PropTypes.func.isRequired,
6875
onMouseEnter: PropTypes.func.isRequired,
76+
inputRef: PropTypes.any,
6977
};
7078

7179
/*
@@ -111,6 +119,7 @@ export default class Dropdown extends React.Component {
111119
}
112120

113121
componentWillMount() {
122+
this._button = createRef();
114123
// Listen for all clicks on the document so we can close the
115124
// menu when the user clicks somewhere else
116125
document.addEventListener('click', this._onDocumentClick, false);
@@ -169,40 +178,47 @@ export default class Dropdown extends React.Component {
169178
}
170179
}
171180

172-
_onMenuOptionClick(dropdownKey) {
181+
_close() {
173182
this.setState({
174183
expanded: false,
175184
});
185+
// their focus was on the input, its getting unmounted, move it to the button
186+
if (this._button.current) {
187+
this._button.current.focus();
188+
}
189+
}
190+
191+
_onMenuOptionClick(dropdownKey) {
192+
this._close();
176193
this.props.onOptionChange(dropdownKey);
177194
}
178195

179196
_onInputKeyPress(e) {
180-
// This needs to be on the keypress event because otherwise
181-
// it can't cancel the form submission
182-
if (e.key == 'Enter') {
183-
this.setState({
184-
expanded: false,
185-
});
186-
this.props.onOptionChange(this.state.highlightedOption);
197+
// This needs to be on the keypress event because otherwise it can't cancel the form submission
198+
if (e.key === Key.ENTER) {
187199
e.preventDefault();
188200
}
189201
}
190202

191203
_onInputKeyUp(e) {
192-
// These keys don't generate keypress events and so needs to
193-
// be on keyup
194-
if (e.key == 'Escape') {
195-
this.setState({
196-
expanded: false,
197-
});
198-
} else if (e.key == 'ArrowDown') {
199-
this.setState({
200-
highlightedOption: this._nextOption(this.state.highlightedOption),
201-
});
202-
} else if (e.key == 'ArrowUp') {
203-
this.setState({
204-
highlightedOption: this._prevOption(this.state.highlightedOption),
205-
});
204+
// These keys don't generate keypress events and so needs to be on keyup
205+
switch (e.key) {
206+
case Key.ENTER:
207+
this.props.onOptionChange(this.state.highlightedOption);
208+
// fallthrough
209+
case Key.ESCAPE:
210+
this._close();
211+
break;
212+
case Key.ARROW_DOWN:
213+
this.setState({
214+
highlightedOption: this._nextOption(this.state.highlightedOption),
215+
});
216+
break;
217+
case Key.ARROW_UP:
218+
this.setState({
219+
highlightedOption: this._prevOption(this.state.highlightedOption),
220+
});
221+
break;
206222
}
207223
}
208224

@@ -250,20 +266,34 @@ export default class Dropdown extends React.Component {
250266
return keys[(index - 1) % keys.length];
251267
}
252268

269+
_scrollIntoView(node) {
270+
if (node) {
271+
node.scrollIntoView({
272+
block: "nearest",
273+
behavior: "auto",
274+
});
275+
}
276+
}
277+
253278
_getMenuOptions() {
254279
const options = React.Children.map(this.props.children, (child) => {
280+
const highlighted = this.state.highlightedOption === child.key;
255281
return (
256-
<MenuOption key={child.key} dropdownKey={child.key}
257-
highlighted={this.state.highlightedOption == child.key}
282+
<MenuOption
283+
id={`${this.props.id}__${child.key}`}
284+
key={child.key}
285+
dropdownKey={child.key}
286+
highlighted={highlighted}
258287
onMouseEnter={this._setHighlightedOption}
259288
onClick={this._onMenuOptionClick}
289+
inputRef={highlighted ? this._scrollIntoView : undefined}
260290
>
261291
{ child }
262292
</MenuOption>
263293
);
264294
});
265295
if (options.length === 0) {
266-
return [<div key="0" className="mx_Dropdown_option">
296+
return [<div key="0" className="mx_Dropdown_option" role="option">
267297
{ _t("No results") }
268298
</div>];
269299
}
@@ -279,23 +309,36 @@ export default class Dropdown extends React.Component {
279309
let menu;
280310
if (this.state.expanded) {
281311
if (this.props.searchEnabled) {
282-
currentValue = <input type="text" className="mx_Dropdown_option"
283-
ref={this._collectInputTextBox} onKeyPress={this._onInputKeyPress}
284-
onKeyUp={this._onInputKeyUp}
285-
onChange={this._onInputChange}
286-
value={this.state.searchQuery}
287-
/>;
312+
currentValue = (
313+
<input
314+
type="text"
315+
className="mx_Dropdown_option"
316+
ref={this._collectInputTextBox}
317+
onKeyPress={this._onInputKeyPress}
318+
onKeyUp={this._onInputKeyUp}
319+
onChange={this._onInputChange}
320+
value={this.state.searchQuery}
321+
role="combobox"
322+
aria-autocomplete="list"
323+
aria-activedescendant={`${this.props.id}__${this.state.highlightedOption}`}
324+
aria-owns={`${this.props.id}_listbox`}
325+
aria-disabled={this.props.disabled}
326+
aria-label={this.props.label}
327+
/>
328+
);
288329
}
289-
menu = <div className="mx_Dropdown_menu" style={menuStyle}>
290-
{ this._getMenuOptions() }
291-
</div>;
330+
menu = (
331+
<div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
332+
{ this._getMenuOptions() }
333+
</div>
334+
);
292335
}
293336

294337
if (!currentValue) {
295338
const selectedChild = this.props.getShortOption ?
296339
this.props.getShortOption(this.props.value) :
297340
this.childrenByKey[this.props.value];
298-
currentValue = <div className="mx_Dropdown_option">
341+
currentValue = <div className="mx_Dropdown_option" id={`${this.props.id}_value`}>
299342
{ selectedChild }
300343
</div>;
301344
}
@@ -311,16 +354,26 @@ export default class Dropdown extends React.Component {
311354
// Note the menu sits inside the AccessibleButton div so it's anchored
312355
// to the input, but overflows below it. The root contains both.
313356
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
314-
<AccessibleButton className="mx_Dropdown_input mx_no_textinput" onClick={this._onInputClick}>
357+
<AccessibleButton
358+
className="mx_Dropdown_input mx_no_textinput"
359+
onClick={this._onInputClick}
360+
aria-haspopup="listbox"
361+
aria-expanded={this.state.expanded}
362+
disabled={this.props.disabled}
363+
inputRef={this._button}
364+
aria-label={this.props.label}
365+
aria-describedby={`${this.props.id}_value`}
366+
>
315367
{ currentValue }
316-
<span className="mx_Dropdown_arrow"></span>
368+
<span className="mx_Dropdown_arrow" />
317369
{ menu }
318370
</AccessibleButton>
319371
</div>;
320372
}
321373
}
322374

323375
Dropdown.propTypes = {
376+
id: PropTypes.string.isRequired,
324377
// The width that the dropdown should be. If specified,
325378
// the dropped-down part of the menu will be set to this
326379
// width.
@@ -340,4 +393,6 @@ Dropdown.propTypes = {
340393
value: PropTypes.string,
341394
// negative for consistency with HTML
342395
disabled: PropTypes.bool,
396+
// ARIA label
397+
label: PropTypes.string.isRequired,
343398
};

src/components/views/elements/LanguageDropdown.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import PropTypes from 'prop-types';
2121
import sdk from '../../../index';
2222
import * as languageHandler from '../../../languageHandler';
2323
import SettingsStore from "../../../settings/SettingsStore";
24+
import { _t } from "../../../languageHandler";
2425

2526
function languageMatchesSearchQuery(query, language) {
2627
if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
@@ -105,9 +106,14 @@ export default class LanguageDropdown extends React.Component {
105106
value = this.props.value || language;
106107
}
107108

108-
return <Dropdown className={this.props.className}
109-
onOptionChange={this.props.onOptionChange} onSearchChange={this._onSearchChange}
110-
searchEnabled={true} value={value}
109+
return <Dropdown
110+
id="mx_LanguageDropdown"
111+
className={this.props.className}
112+
onOptionChange={this.props.onOptionChange}
113+
onSearchChange={this._onSearchChange}
114+
searchEnabled={true}
115+
value={value}
116+
label={_t("Language Dropdown")}
111117
>
112118
{ options }
113119
</Dropdown>;

src/i18n/strings/en_EN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,7 @@
12681268
"Rotate Right": "Rotate Right",
12691269
"Rotate clockwise": "Rotate clockwise",
12701270
"Download this file": "Download this file",
1271+
"Language Dropdown": "Language Dropdown",
12711272
"Manage Integrations": "Manage Integrations",
12721273
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
12731274
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",
@@ -1633,6 +1634,7 @@
16331634
"User Status": "User Status",
16341635
"powered by Matrix": "powered by Matrix",
16351636
"This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.",
1637+
"Country Dropdown": "Country Dropdown",
16361638
"Custom Server Options": "Custom Server Options",
16371639
"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.",
16381640
"To continue, please enter your password.": "To continue, please enter your password.",

0 commit comments

Comments
 (0)