diff --git a/package-lock.json b/package-lock.json
index c2ed10753..0ff5c14c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20846,6 +20846,11 @@
"has-symbols": "^1.0.0"
}
},
+ "tabbable": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-4.0.0.tgz",
+ "integrity": "sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ=="
+ },
"table": {
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz",
diff --git a/package.json b/package.json
index 8dc3010b1..b4cd68d00 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,8 @@
"react-focus-lock": "^2.1.1",
"react-overlays": "^1.1.2",
"react-popper": "^1.3.3",
- "shortid": "^2.2.14"
+ "shortid": "^2.2.14",
+ "tabbable": "^4.0.0"
},
"devDependencies": {
"@babel/cli": "^7.1.5",
diff --git a/src/ComboboxInput/ComboboxInput.js b/src/ComboboxInput/ComboboxInput.js
index c4b47427c..980b81154 100644
--- a/src/ComboboxInput/ComboboxInput.js
+++ b/src/ComboboxInput/ComboboxInput.js
@@ -39,7 +39,8 @@ const ComboboxInput = React.forwardRef(({ placeholder, menu, compact, className,
}
disableKeyPressHandler
disableStyles={disableStyles}
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
);
});
diff --git a/src/Dropdown/Dropdown.Component.js b/src/Dropdown/Dropdown.Component.js
index 9e36e15ca..7458c017d 100644
--- a/src/Dropdown/Dropdown.Component.js
+++ b/src/Dropdown/Dropdown.Component.js
@@ -28,7 +28,8 @@ export const DropdownComponent = () => {
}
control={}
id='jhqD0555'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
@@ -49,7 +50,8 @@ export const DropdownComponent = () => {
}
id='jhqD0556'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
@@ -75,7 +77,8 @@ export const DropdownComponent = () => {
}
id='jhqD0557'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
@@ -97,7 +100,8 @@ export const DropdownComponent = () => {
}
id='jhqD0558'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
@@ -122,7 +126,8 @@ export const DropdownComponent = () => {
}
id='jhqD0559'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
@@ -143,7 +148,8 @@ export const DropdownComponent = () => {
}
id='jhqD0560'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
@@ -170,7 +176,8 @@ export const DropdownComponent = () => {
}
disabled
id='jhqD0561'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
diff --git a/src/Dropdown/__stories__/Dropdown.stories.js b/src/Dropdown/__stories__/Dropdown.stories.js
index eda091941..52ffa4aa5 100644
--- a/src/Dropdown/__stories__/Dropdown.stories.js
+++ b/src/Dropdown/__stories__/Dropdown.stories.js
@@ -25,7 +25,8 @@ storiesOf('Components|Dropdown', module)
}
control={}
id='jhqD0555'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
))
.add('disable styles', () => (
@@ -45,7 +46,8 @@ storiesOf('Components|Dropdown', module)
control={}
disableStyles
id='jhqD0555'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
))
.add('custom styles', () => (
@@ -65,6 +67,7 @@ storiesOf('Components|Dropdown', module)
control={}
disableStyles
id='jhqD0555'
- noArrow />
+ noArrow
+ useArrowKeyNavigation />
));
diff --git a/src/Popover/Popover.js b/src/Popover/Popover.js
index a477a8492..af80848dc 100644
--- a/src/Popover/Popover.js
+++ b/src/Popover/Popover.js
@@ -1,9 +1,12 @@
import chain from 'chain-function';
import classnames from 'classnames';
+import { findDOMNode } from 'react-dom';
+import FocusManager from '../utils/focusManager/focusManager';
import keycode from 'keycode';
import Popper from '../utils/_Popper';
import PropTypes from 'prop-types';
import shortId from '../utils/shortId';
+import tabbable from 'tabbable';
import withStyles from '../utils/WithStyles/WithStyles';
import { POPOVER_TYPES, POPPER_PLACEMENTS } from '../utils/constants';
import React, { Component } from 'react';
@@ -41,6 +44,12 @@ class Popover extends Component {
}
};
+ handleFocusManager = () => {
+ if (this.state.isExpanded && this.popover) {
+ this.focusManager = new FocusManager(this.popover, this.controlRef, this.props.useArrowKeyNavigation);
+ }
+ }
+
handleOutsideClick = () => {
if (this.state.isExpanded) {
this.setState({
@@ -49,6 +58,19 @@ class Popover extends Component {
}
};
+ handleEscapeKey = () => {
+ this.handleOutsideClick();
+
+ if (this.controlRef) {
+ if (tabbable.isTabbable(this.controlRef)) {
+ this.controlRef.focus();
+ } else {
+ const firstTabbableNode = tabbable(this.controlRef)[0];
+ firstTabbableNode && firstTabbableNode.focus();
+ }
+ }
+ }
+
handleKeyPress = (event, node, onClickFunctions) => {
if (!this.isButton(node)) {
switch (keycode(event)) {
@@ -76,6 +98,7 @@ class Popover extends Component {
className,
placement,
popperProps,
+ useArrowKeyNavigation,
type,
...rest
} = this.props;
@@ -88,7 +111,15 @@ class Popover extends Component {
const id = popperProps.id || this.popoverId;
let controlProps = {
- onClick: onClickFunctions
+ onClick: onClickFunctions,
+ ref: (c) => {
+ this.controlRef = findDOMNode(c);
+ }
+ };
+
+ const innerRef = (c) => {
+ this.popover = findDOMNode(c);
+ this.handleFocusManager();
};
if (!disableKeyPressHandler) {
@@ -112,9 +143,10 @@ class Popover extends Component {
(
}
- type='menu' />
+ body={someMenu}
+ control={}
+ type='menu'
+ useArrowKeyNavigation />
))
.add('Placement', () => (
<>
@@ -176,14 +178,17 @@ storiesOf('Components|Popover', module)
glyph='navigation-up-arrow'
option='light' />}
disableStyles
- type='menu' />
+ type='menu'
+ useArrowKeyNavigation />
))
.add('custom styles', () => (
}
customStyles={require('../../utils/WithStyles/customStylesTest.css')}
- type='menu' />
+ type='menu'
+ useArrowKeyNavigation />
));
diff --git a/src/utils/_Popper.js b/src/utils/_Popper.js
index aa35423ac..9f8854231 100644
--- a/src/utils/_Popper.js
+++ b/src/utils/_Popper.js
@@ -63,6 +63,7 @@ class Popper extends React.Component {
children,
cssBlock,
disableEdgeDetection,
+ innerRef,
noArrow,
onClickOutside,
popperClassName,
@@ -88,6 +89,7 @@ class Popper extends React.Component {
let popper = (
{({ ref, style, placement, outOfBoundaries, arrowProps }) => {
@@ -147,6 +149,7 @@ Popper.displayName = 'Popper';
Popper.propTypes = {
children: PropTypes.node.isRequired,
cssBlock: PropTypes.string.isRequired,
+ innerRef: PropTypes.func.isRequired,
referenceComponent: PropTypes.element.isRequired,
disableEdgeDetection: PropTypes.bool,
noArrow: PropTypes.bool,
diff --git a/src/utils/focusManager/focusManager.js b/src/utils/focusManager/focusManager.js
new file mode 100644
index 000000000..24a08b65d
--- /dev/null
+++ b/src/utils/focusManager/focusManager.js
@@ -0,0 +1,73 @@
+import keycode from 'keycode';
+import tabbable from 'tabbable';
+
+export default class FocusManager {
+ constructor(trapNode, controlNode, useArrowKeys = false) {
+ this.container = trapNode;
+ this.firstOuterTabbableNode = tabbable.isTabbable(controlNode) ? controlNode : tabbable(controlNode)[0];
+ this.tabbableNodes = tabbable(this.container);
+ this.useArrowKeys = useArrowKeys;
+
+ document.addEventListener('keydown', this.keyHandler, true);
+ }
+
+ isFocusContained = (e) => {
+ return (e.target === window && this.container && !this.container.contains(document.activeElement));
+ }
+
+ keyHandler = (e) => {
+ if (!document.body.contains(this.container)) {
+ document.removeEventListener('keydown', this.keyHandler, true);
+ return;
+ }
+
+ const isPreviousKey = (this.useArrowKeys && e.keyCode === keycode.codes.up) ||
+ (!this.useArrowKeys && e.keyCode === keycode.codes.tab && e.shiftKey);
+
+ const isNextKey = (this.useArrowKeys && e.keyCode === keycode.codes.down) ||
+ (!this.useArrowKeys && e.keyCode === keycode.codes.tab);
+
+ if (isPreviousKey || isNextKey) {
+ e.preventDefault();
+
+ if (!this.isFocusContained(e)) {
+ this.tryFocus(this.tabbableNodes[0]);
+ }
+
+ this.tabbableNodes = tabbable(this.container);
+ const currentIndex = this.tabbableNodes.indexOf(e.target);
+ const lastNode = this.tabbableNodes[this.tabbableNodes.length - 1];
+ const firstNode = this.tabbableNodes[0];
+
+ if (isPreviousKey) {
+ if (this.tabbableNodes[currentIndex] === firstNode) {
+ this.tryFocus(lastNode);
+ } else {
+ this.tryFocus(this.tabbableNodes[currentIndex - 1]);
+ }
+ } else if (isNextKey) {
+ if (this.tabbableNodes[currentIndex] === lastNode) {
+ this.tryFocus(firstNode);
+ } else {
+ this.tryFocus(this.tabbableNodes[currentIndex + 1]);
+ }
+ }
+ } else if (this.useArrowKeys && e.keyCode === keycode.codes.tab) {
+ // navigate out of component with tab when arrow-key navigation enabled
+ e.preventDefault();
+
+ const documentTabbableElements = tabbable(document);
+ const nextElementIndex = documentTabbableElements.indexOf(this.firstOuterTabbableNode) + (e.shiftKey ? -1 : 1);
+ this.tryFocus(documentTabbableElements[nextElementIndex]);
+ }
+ };
+
+ tryFocus = (node) => {
+ if (node) {
+ const posX = window.pageXOffset;
+ const posY = window.pageYOffset;
+ node.focus();
+ window.scrollTo(posX, posY);
+ }
+ }
+}
diff --git a/src/utils/focusManager/focusManager.test.js b/src/utils/focusManager/focusManager.test.js
new file mode 100644
index 000000000..d68460548
--- /dev/null
+++ b/src/utils/focusManager/focusManager.test.js
@@ -0,0 +1,139 @@
+import FocusManager from './focusManager';
+
+const ce = global.document.createElement;
+
+// limitation with jest and 'offsetParent': https://github.com/jsdom/jsdom/issues/1261#issuecomment-362928131
+Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
+ get() {
+ return this.parentNode;
+ }
+});
+
+describe('focus manager', () => {
+ let trapNode;
+ let controlNode;
+
+ beforeAll(() => {
+ global.document.createElement = function() {
+ const [type] = arguments;
+ const element = ce.apply(this, arguments);
+ if (type === 'a') {
+ element.setAttribute('tabIndex', 0);
+ }
+ return element;
+ };
+ });
+
+ afterAll(() => {
+ global.document.createElement = ce;
+ });
+
+ beforeEach(() => {
+ trapNode = document.createElement('div');
+ controlNode = document.createElement('button');
+ const a1 = document.createElement('a');
+ const a2 = document.createElement('a');
+ const a3 = document.createElement('a');
+ const a4 = document.createElement('a');
+ const span = document.createElement('span');
+
+ trapNode.appendChild(a1);
+ trapNode.appendChild(a2);
+ trapNode.appendChild(span);
+ trapNode.appendChild(a3);
+
+ span.appendChild(a4);
+ controlNode.appendChild(trapNode);
+ });
+
+ describe('Default Behavior', () => {
+ it('should have tabbable elements', () => {
+ const manager = new FocusManager(trapNode, controlNode);
+
+ expect(manager.tabbableNodes.length).toEqual(4);
+ });
+
+ it('should focus on nodes via tryFocus', () => {
+ const manager = new FocusManager(trapNode, controlNode);
+ const link = trapNode.querySelector('a');
+
+ manager.tryFocus(link);
+
+ expect(document.activeElement).toEqual(link);
+ });
+ });
+
+ describe('Event Behavior', () => {
+ it('should handle tabbing back 1 element', () => {
+ const manager = new FocusManager(trapNode, controlNode);
+
+ const anchors = trapNode.querySelectorAll('a');
+ const a1 = anchors[0];
+ const a2 = anchors[1];
+
+ const event = {
+ preventDefault: () => {},
+ shiftKey: true,
+ target: a2
+ };
+
+ manager.keyHandler(event);
+
+ expect(document.activeElement).toEqual(a1);
+ });
+
+ it('should handle tabbing forward 1 element', () => {
+ const manager = new FocusManager(trapNode, controlNode);
+
+ const anchors = trapNode.querySelectorAll('a');
+ const a2 = anchors[1];
+ const a3 = anchors[2];
+
+ const event = {
+ preventDefault: () => {},
+ shiftKey: false,
+ target: a2
+ };
+
+ manager.keyHandler(event);
+
+ expect(document.activeElement).toEqual(a3);
+ });
+
+ it('should redirect to the last element when on first element', () => {
+ const manager = new FocusManager(trapNode, controlNode);
+
+ const anchors = trapNode.querySelectorAll('a');
+ const a1 = anchors[0];
+ const a4 = anchors[3];
+
+ const event = {
+ preventDefault: () => {},
+ shiftKey: true,
+ target: a1
+ };
+
+ manager.keyHandler(event);
+
+ expect(document.activeElement).toEqual(a4);
+ });
+
+ it('should redirect to the first element when on last element', () => {
+ const manager = new FocusManager(trapNode, controlNode);
+
+ const anchors = trapNode.querySelectorAll('a');
+ const a1 = anchors[0];
+ const a4 = anchors[3];
+
+ const event = {
+ preventDefault: () => {},
+ shiftKey: false,
+ target: a4
+ };
+
+ manager.keyHandler(event);
+
+ expect(document.activeElement).toEqual(a1);
+ });
+ });
+});