Skip to content

Commit ac90f51

Browse files
committed
feat(Dropdown): keyboard control/navigation
Adds the ability to control/navigate the dropdown and it's menu using the keyboard (space, arrow, esc keys) This mimic bootstrap 4's functionality Closes #580
1 parent e4479aa commit ac90f51

3 files changed

Lines changed: 583 additions & 7 deletions

File tree

src/Dropdown.js

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
66
import ReactDOM from 'react-dom';
77
import { Manager } from 'react-popper';
88
import classNames from 'classnames';
9-
import { mapToCssModules, omit } from './utils';
9+
import { mapToCssModules, omit, keyCodes } from './utils';
1010

1111
const propTypes = {
1212
disabled: PropTypes.bool,
@@ -39,6 +39,7 @@ class Dropdown extends React.Component {
3939

4040
this.addEvents = this.addEvents.bind(this);
4141
this.handleDocumentClick = this.handleDocumentClick.bind(this);
42+
this.handleKeyDown = this.handleKeyDown.bind(this);
4243
this.removeEvents = this.removeEvents.bind(this);
4344
this.toggle = this.toggle.bind(this);
4445
}
@@ -65,26 +66,84 @@ class Dropdown extends React.Component {
6566
this.removeEvents();
6667
}
6768

69+
getContainer() {
70+
return ReactDOM.findDOMNode(this);
71+
}
72+
6873
addEvents() {
69-
['click', 'touchstart'].forEach(event =>
74+
['click', 'touchstart', 'keyup'].forEach(event =>
7075
document.addEventListener(event, this.handleDocumentClick, true)
7176
);
7277
}
7378

7479
removeEvents() {
75-
['click', 'touchstart'].forEach(event =>
80+
['click', 'touchstart', 'keyup'].forEach(event =>
7681
document.removeEventListener(event, this.handleDocumentClick, true)
7782
);
7883
}
7984

8085
handleDocumentClick(e) {
81-
const container = ReactDOM.findDOMNode(this);
86+
if (e && (e.which === 3 || (e.type === 'keyup' && e.which !== keyCodes.tab))) return;
87+
const container = this.getContainer();
88+
89+
if (container.contains(e.target) && container !== e.target && (e.type !== 'keyup' || e.which === keyCodes.tab)) {
90+
return;
91+
}
92+
93+
this.toggle(e);
94+
}
95+
96+
handleKeyDown(e) {
97+
if ([keyCodes.esc, keyCodes.up, keyCodes.down, keyCodes.space].indexOf(e.which) === -1 ||
98+
(/button/i.test(e.target.tagName) && e.which === keyCodes.space) ||
99+
/input|textarea/i.test(e.target.tagName)) {
100+
return;
101+
}
102+
103+
e.preventDefault();
104+
if (this.props.disabled) return;
105+
106+
const container = this.getContainer();
107+
108+
if (e.which === keyCodes.space && this.props.isOpen && container !== e.target) {
109+
e.target.click();
110+
}
82111

83-
if (container.contains(e.target) && container !== e.target) {
112+
if (e.which === keyCodes.esc || !this.props.isOpen) {
113+
this.toggle(e);
114+
container.querySelector('[aria-expanded]').focus();
84115
return;
85116
}
86117

87-
this.toggle();
118+
const menuClass = mapToCssModules('dropdown-menu', this.props.cssModule);
119+
const itemClass = mapToCssModules('dropdown-item', this.props.cssModule);
120+
const disabledClass = mapToCssModules('disabled', this.props.cssModule);
121+
122+
const items = container.querySelectorAll(`.${menuClass} .${itemClass}:not(.${disabledClass})`);
123+
124+
if (!items.length) return;
125+
126+
let index = -1;
127+
for (let i = 0; i < items.length; i += 1) {
128+
if (items[i] === e.target) {
129+
index = i;
130+
break;
131+
}
132+
}
133+
134+
if (e.which === keyCodes.up && index > 0) {
135+
index -= 1;
136+
}
137+
138+
if (e.which === keyCodes.down && index < items.length - 1) {
139+
index += 1;
140+
}
141+
142+
if (index < 0) {
143+
index = 0;
144+
}
145+
146+
items[index].focus();
88147
}
89148

90149
handleProps() {
@@ -124,7 +183,7 @@ class Dropdown extends React.Component {
124183
dropup: dropup
125184
}
126185
), cssModule);
127-
return <Manager {...attrs} className={classes} />;
186+
return <Manager {...attrs} className={classes} onKeyDown={this.handleKeyDown} />;
128187
}
129188
}
130189

0 commit comments

Comments
 (0)