Dropdowns accessibility (#7318)

* Mark currently selected privacy setting in privacy dropdown

* Prevent Enter keypresses from triggering dropdown display toggle twice

* Give focus to first/selected item of dropdown menus

* Implement keyboard navigation in privacy dropdown

* Implement keyboard navigation in generic dropdown menus
pull/7371/head
ThibG 2018-05-04 22:13:26 +02:00 committed by Eugen Rochko
parent 6793bec4c6
commit ef7d64c801
2 changed files with 99 additions and 17 deletions

View File

@ -43,6 +43,7 @@ class DropdownMenu extends React.PureComponent {
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus();
this.setState({ mounted: true }); this.setState({ mounted: true });
} }
@ -55,6 +56,46 @@ class DropdownMenu extends React.PureComponent {
this.node = c; this.node = c;
} }
setFocusRef = c => {
this.focusedItem = c;
}
handleKeyDown = e => {
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(e.currentTarget);
let element;
switch(e.key) {
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = items[index+1];
if (element) {
element.focus();
}
break;
case 'ArrowUp':
element = items[index-1];
if (element) {
element.focus();
}
break;
case 'Home':
element = items[0];
if (element) {
element.focus();
}
break;
case 'End':
element = items[items.length-1];
if (element) {
element.focus();
}
break;
}
}
handleClick = e => { handleClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index')); const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i]; const { action, to } = this.props.items[i];
@ -79,7 +120,7 @@ class DropdownMenu extends React.PureComponent {
return ( return (
<li className='dropdown-menu__item' key={`${text}-${i}`}> <li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}> <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleKeyDown} data-index={i}>
{text} {text}
</a> </a>
</li> </li>
@ -156,9 +197,6 @@ export default class Dropdown extends React.PureComponent {
handleKeyDown = e => { handleKeyDown = e => {
switch(e.key) { switch(e.key) {
case 'Enter':
this.handleClick(e);
break;
case 'Escape': case 'Escape':
this.handleClose(); this.handleClose();
break; break;

View File

@ -42,22 +42,65 @@ class PrivacyDropdownMenu extends React.PureComponent {
} }
} }
handleClick = e => { handleKeyDown = e => {
if (e.key === 'Escape') { const { items } = this.props;
this.props.onClose(); const value = e.currentTarget.getAttribute('data-index');
} else if (!e.key || e.key === 'Enter') { const index = items.findIndex(item => {
const value = e.currentTarget.getAttribute('data-index'); return (item.value === value);
});
e.preventDefault(); let element;
switch(e.key) {
case 'Escape':
this.props.onClose(); this.props.onClose();
this.props.onChange(value); break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
} }
} }
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus();
this.setState({ mounted: true }); this.setState({ mounted: true });
} }
@ -70,6 +113,10 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.node = c; this.node = c;
} }
setFocusRef = c => {
this.focusedItem = c;
}
render () { render () {
const { mounted } = this.state; const { mounted } = this.state;
const { style, items, value } = this.props; const { style, items, value } = this.props;
@ -80,9 +127,9 @@ class PrivacyDropdownMenu extends React.PureComponent {
// It should not be transformed when mounting because the resulting // It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by // size will be used to determine the coordinate of the menu by
// react-overlays // react-overlays
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => ( {items.map(item => (
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'> <div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} /> <i className={`fa fa-fw fa-${item.icon}`} />
</div> </div>
@ -147,9 +194,6 @@ export default class PrivacyDropdown extends React.PureComponent {
handleKeyDown = e => { handleKeyDown = e => {
switch(e.key) { switch(e.key) {
case 'Enter':
this.handleToggle(e);
break;
case 'Escape': case 'Escape':
this.handleClose(); this.handleClose();
break; break;