1
1
/*
2
2
Copyright 2017 Vector Creations Ltd
3
3
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
4
+ Copyright 2019 The Matrix.org Foundation C.I.C.
4
5
5
6
Licensed under the Apache License, Version 2.0 (the "License");
6
7
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
15
16
limitations under the License.
16
17
*/
17
18
18
- import React from 'react' ;
19
+ import React , { createRef } from 'react' ;
19
20
import PropTypes from 'prop-types' ;
20
21
import classnames from 'classnames' ;
21
22
import AccessibleButton from './AccessibleButton' ;
22
23
import { _t } from '../../../languageHandler' ;
24
+ import { Key } from "../../../Keyboard" ;
23
25
24
26
class MenuOption extends React . Component {
25
27
constructor ( props ) {
@@ -48,9 +50,14 @@ class MenuOption extends React.Component {
48
50
mx_Dropdown_option_highlight : this . props . highlighted ,
49
51
} ) ;
50
52
51
- return < div className = { optClasses }
53
+ return < div
54
+ id = { this . props . id }
55
+ className = { optClasses }
52
56
onClick = { this . _onClick }
53
57
onMouseEnter = { this . _onMouseEnter }
58
+ role = "option"
59
+ aria-selected = { this . props . highlighted }
60
+ ref = { this . props . inputRef }
54
61
>
55
62
{ this . props . children }
56
63
</ div > ;
@@ -66,6 +73,7 @@ MenuOption.propTypes = {
66
73
dropdownKey : PropTypes . string ,
67
74
onClick : PropTypes . func . isRequired ,
68
75
onMouseEnter : PropTypes . func . isRequired ,
76
+ inputRef : PropTypes . any ,
69
77
} ;
70
78
71
79
/*
@@ -111,6 +119,7 @@ export default class Dropdown extends React.Component {
111
119
}
112
120
113
121
componentWillMount ( ) {
122
+ this . _button = createRef ( ) ;
114
123
// Listen for all clicks on the document so we can close the
115
124
// menu when the user clicks somewhere else
116
125
document . addEventListener ( 'click' , this . _onDocumentClick , false ) ;
@@ -169,40 +178,47 @@ export default class Dropdown extends React.Component {
169
178
}
170
179
}
171
180
172
- _onMenuOptionClick ( dropdownKey ) {
181
+ _close ( ) {
173
182
this . setState ( {
174
183
expanded : false ,
175
184
} ) ;
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 ( ) ;
176
193
this . props . onOptionChange ( dropdownKey ) ;
177
194
}
178
195
179
196
_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 ) {
187
199
e . preventDefault ( ) ;
188
200
}
189
201
}
190
202
191
203
_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 ;
206
222
}
207
223
}
208
224
@@ -250,20 +266,34 @@ export default class Dropdown extends React.Component {
250
266
return keys [ ( index - 1 ) % keys . length ] ;
251
267
}
252
268
269
+ _scrollIntoView ( node ) {
270
+ if ( node ) {
271
+ node . scrollIntoView ( {
272
+ block : "nearest" ,
273
+ behavior : "auto" ,
274
+ } ) ;
275
+ }
276
+ }
277
+
253
278
_getMenuOptions ( ) {
254
279
const options = React . Children . map ( this . props . children , ( child ) => {
280
+ const highlighted = this . state . highlightedOption === child . key ;
255
281
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 }
258
287
onMouseEnter = { this . _setHighlightedOption }
259
288
onClick = { this . _onMenuOptionClick }
289
+ inputRef = { highlighted ? this . _scrollIntoView : undefined }
260
290
>
261
291
{ child }
262
292
</ MenuOption >
263
293
) ;
264
294
} ) ;
265
295
if ( options . length === 0 ) {
266
- return [ < div key = "0" className = "mx_Dropdown_option" >
296
+ return [ < div key = "0" className = "mx_Dropdown_option" role = "option" >
267
297
{ _t ( "No results" ) }
268
298
</ div > ] ;
269
299
}
@@ -279,23 +309,36 @@ export default class Dropdown extends React.Component {
279
309
let menu ;
280
310
if ( this . state . expanded ) {
281
311
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
+ ) ;
288
329
}
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
+ ) ;
292
335
}
293
336
294
337
if ( ! currentValue ) {
295
338
const selectedChild = this . props . getShortOption ?
296
339
this . props . getShortOption ( this . props . value ) :
297
340
this . childrenByKey [ this . props . value ] ;
298
- currentValue = < div className = "mx_Dropdown_option" >
341
+ currentValue = < div className = "mx_Dropdown_option" id = { ` ${ this . props . id } _value` } >
299
342
{ selectedChild }
300
343
</ div > ;
301
344
}
@@ -311,16 +354,26 @@ export default class Dropdown extends React.Component {
311
354
// Note the menu sits inside the AccessibleButton div so it's anchored
312
355
// to the input, but overflows below it. The root contains both.
313
356
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
+ >
315
367
{ currentValue }
316
- < span className = "mx_Dropdown_arrow" > </ span >
368
+ < span className = "mx_Dropdown_arrow" / >
317
369
{ menu }
318
370
</ AccessibleButton >
319
371
</ div > ;
320
372
}
321
373
}
322
374
323
375
Dropdown . propTypes = {
376
+ id : PropTypes . string . isRequired ,
324
377
// The width that the dropdown should be. If specified,
325
378
// the dropped-down part of the menu will be set to this
326
379
// width.
@@ -340,4 +393,6 @@ Dropdown.propTypes = {
340
393
value : PropTypes . string ,
341
394
// negative for consistency with HTML
342
395
disabled : PropTypes . bool ,
396
+ // ARIA label
397
+ label : PropTypes . string . isRequired ,
343
398
} ;
0 commit comments