Skip to content

Commit

Permalink
fix: keyboard interaction for popover (#793)
Browse files Browse the repository at this point in the history
* fix: add focus manager, focus reference element on close

* fix: adjust focus trapping

* fix: simplify logic

* fix: unit tests

* fix: remove unnecessary handling

* chore: nits

* fix: separate innerRef from popper props

* fix: arrow key navigation for certain components
  • Loading branch information
jacobdevera committed Nov 21, 2019
1 parent 673aecd commit 8cf5555
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 19 deletions.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/ComboboxInput/ComboboxInput.js
Expand Up @@ -39,7 +39,8 @@ const ComboboxInput = React.forwardRef(({ placeholder, menu, compact, className,
}
disableKeyPressHandler
disableStyles={disableStyles}
noArrow />
noArrow
useArrowKeyNavigation />
</div>
);
});
Expand Down
21 changes: 14 additions & 7 deletions src/Dropdown/Dropdown.Component.js
Expand Up @@ -28,7 +28,8 @@ export const DropdownComponent = () => {
}
control={<Button className='fd-dropdown__control'>Select</Button>}
id='jhqD0555'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>

<Dropdown>
Expand All @@ -49,7 +50,8 @@ export const DropdownComponent = () => {
</Button>
}
id='jhqD0556'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
</Example>

Expand All @@ -75,7 +77,8 @@ export const DropdownComponent = () => {
</Button>
}
id='jhqD0557'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>

<Dropdown>
Expand All @@ -97,7 +100,8 @@ export const DropdownComponent = () => {
</Button>
}
id='jhqD0558'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
</Example>

Expand All @@ -122,7 +126,8 @@ export const DropdownComponent = () => {
</Button>
}
id='jhqD0559'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>

<Dropdown standard>
Expand All @@ -143,7 +148,8 @@ export const DropdownComponent = () => {
</Button>
}
id='jhqD0560'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
</Example>

Expand All @@ -170,7 +176,8 @@ export const DropdownComponent = () => {
}
disabled
id='jhqD0561'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
</Example>

Expand Down
9 changes: 6 additions & 3 deletions src/Dropdown/__stories__/Dropdown.stories.js
Expand Up @@ -25,7 +25,8 @@ storiesOf('Components|Dropdown', module)
}
control={<Button className='fd-dropdown__control'>Select</Button>}
id='jhqD0555'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
))
.add('disable styles', () => (
Expand All @@ -45,7 +46,8 @@ storiesOf('Components|Dropdown', module)
control={<Button className='fd-dropdown__control' disableStyles>Select</Button>}
disableStyles
id='jhqD0555'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
))
.add('custom styles', () => (
Expand All @@ -65,6 +67,7 @@ storiesOf('Components|Dropdown', module)
control={<Button className='fd-dropdown__control' disableStyles>Select</Button>}
disableStyles
id='jhqD0555'
noArrow />
noArrow
useArrowKeyNavigation />
</Dropdown>
));
37 changes: 35 additions & 2 deletions 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';
Expand Down Expand Up @@ -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({
Expand All @@ -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)) {
Expand Down Expand Up @@ -76,6 +98,7 @@ class Popover extends Component {
className,
placement,
popperProps,
useArrowKeyNavigation,
type,
...rest
} = this.props;
Expand All @@ -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) {
Expand All @@ -112,9 +143,10 @@ class Popover extends Component {
<Popper
cssBlock='fd-popover'
disableEdgeDetection={disableEdgeDetection}
innerRef={innerRef}
noArrow={noArrow}
onClickOutside={chain(this.handleOutsideClick, onClickOutside)}
onEscapeKey={chain(this.handleOutsideClick, onEscapeKey)}
onEscapeKey={chain(this.handleEscapeKey, onEscapeKey)}
popperPlacement={placement}
popperProps={{ ...popperProps, id }}
referenceClassName='fd-popover__control'
Expand Down Expand Up @@ -143,6 +175,7 @@ Popover.propTypes = {
placement: PropTypes.oneOf(POPPER_PLACEMENTS),
popperProps: PropTypes.object,
type: PropTypes.oneOf(POPOVER_TYPES),
useArrowKeyNavigation: PropTypes.bool,
onClickOutside: PropTypes.func,
onEscapeKey: PropTypes.func
};
Expand Down
15 changes: 10 additions & 5 deletions src/Popover/__stories__/Popover.stories.js
Expand Up @@ -22,8 +22,10 @@ storiesOf('Components|Popover', module)
.addDecorator(withKnobs)
.add('Default', () => (
<Popover
body={someMenu} control={<Button glyph='navigation-up-arrow' option='light' />}
type='menu' />
body={someMenu}
control={<Button glyph='navigation-up-arrow' option='light' />}
type='menu'
useArrowKeyNavigation />
))
.add('Placement', () => (
<>
Expand Down Expand Up @@ -176,14 +178,17 @@ storiesOf('Components|Popover', module)
glyph='navigation-up-arrow'
option='light' />}
disableStyles
type='menu' />
type='menu'
useArrowKeyNavigation />
))
.add('custom styles', () => (
<Popover
body={someMenu} control={<Button
body={someMenu}
control={<Button
disableStyles
glyph='navigation-up-arrow'
option='light' />}
customStyles={require('../../utils/WithStyles/customStylesTest.css')}
type='menu' />
type='menu'
useArrowKeyNavigation />
));
3 changes: 3 additions & 0 deletions src/utils/_Popper.js
Expand Up @@ -63,6 +63,7 @@ class Popper extends React.Component {
children,
cssBlock,
disableEdgeDetection,
innerRef,
noArrow,
onClickOutside,
popperClassName,
Expand All @@ -88,6 +89,7 @@ class Popper extends React.Component {

let popper = (
<ReactPopper
innerRef={innerRef}
modifiers={modifiers}
placement={popperPlacement}>
{({ ref, style, placement, outOfBoundaries, arrowProps }) => {
Expand Down Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions 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);
}
}
}

0 comments on commit 8cf5555

Please sign in to comment.