Improve dropdown menu keyboard navigation (#11491)

* Allow selecting menu items with the space bar in status dropdown menus

* Fix modals opened by keyboard navigation being immediately closed

* Fix menu items triggering modal actions

* Add Tab trapping inside dropdown menu

* Give focus back to last focused element when status dropdown menu closes
This commit is contained in:
ThibG 2019-08-06 11:59:46 +02:00 committed by Eugen Rochko
parent 5c73746b69
commit a12f1a0baf
4 changed files with 30 additions and 21 deletions

View file

@ -9,8 +9,9 @@ export function openModal(type, props) {
}; };
}; };
export function closeModal() { export function closeModal(type) {
return { return {
type: MODAL_CLOSE, type: MODAL_CLOSE,
modalType: type,
}; };
}; };

View file

@ -45,7 +45,10 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); this.activeElement = document.activeElement;
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
}
this.setState({ mounted: true }); this.setState({ mounted: true });
} }
@ -53,6 +56,9 @@ class DropdownMenu extends React.PureComponent {
document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.activeElement) {
this.activeElement.focus();
}
} }
setRef = c => { setRef = c => {
@ -81,6 +87,18 @@ class DropdownMenu extends React.PureComponent {
element.focus(); element.focus();
} }
break; break;
case 'Tab':
if (e.shiftKey) {
element = items[index-1] || items[items.length-1];
} else {
element = items[index+1] || items[0];
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home': case 'Home':
element = items[0]; element = items[0];
if (element) { if (element) {
@ -93,11 +111,14 @@ class DropdownMenu extends React.PureComponent {
element.focus(); element.focus();
} }
break; break;
case 'Escape':
this.props.onClose();
break;
} }
} }
handleItemKeyDown = e => { handleItemKeyUp = e => {
if (e.key === 'Enter') { if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e); this.handleClick(e);
} }
} }
@ -126,7 +147,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={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}> <a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyUp={this.handleItemKeyUp} data-index={i}>
{text} {text}
</a> </a>
</li> </li>
@ -202,19 +223,6 @@ export default class Dropdown extends React.PureComponent {
this.props.onClose(this.state.id); this.props.onClose(this.state.id);
} }
handleKeyDown = e => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleItemClick = e => { handleItemClick = 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];
@ -249,7 +257,7 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId; const open = this.state.id === openDropdownId;
return ( return (
<div onKeyDown={this.handleKeyDown}> <div>
<IconButton <IconButton
icon={icon} icon={icon}
title={title} title={title}

View file

@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
}) : openDropdownMenu(id, dropdownPlacement, keyboard)); }) : openDropdownMenu(id, dropdownPlacement, keyboard));
}, },
onClose(id) { onClose(id) {
dispatch(closeModal()); dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id)); dispatch(closeDropdownMenu(id));
}, },
}); });

View file

@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
case MODAL_OPEN: case MODAL_OPEN:
return { modalType: action.modalType, modalProps: action.modalProps }; return { modalType: action.modalType, modalProps: action.modalProps };
case MODAL_CLOSE: case MODAL_CLOSE:
return initialState; return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
default: default:
return state; return state;
} }