boundless-arrow-key-navigation
Version:
A higher-order component for arrow key navigation on a grouping of children.
219 lines (181 loc) • 7.32 kB
JavaScript
import PropTypes from 'prop-types';
import { cloneElement, createElement, Children, PureComponent } from 'react';
import { findDOMNode } from 'react-dom';
import omit from 'boundless-utils-omit-keys';
import uuid from 'boundless-utils-uuid';
const DATA_ATTRIBUTE_INDEX = 'data-focus-index';
const DATA_ATTRIBUTE_SKIP = 'data-focus-skip';
/**
* ArrowKeyNavigation is designed not to care about the component types it is wrapping. Due to this, you can pass
* whatever HTML tag you like into `props.component` or even a React component you've made elsewhere. Additional props
* passed to `<ArrowKeyNavigation ...>` will be forwarded on to the component or HTML tag name you've supplied.
*
* The children, similarly, can be any type of component.
*/
export default class ArrowKeyNavigation extends PureComponent {
static mode = {
HORIZONTAL: uuid(),
VERTICAL: uuid(),
BOTH: uuid(),
}
static propTypes = {
/**
* any [React-supported attribute]
* (https://facebook.github.io/react/docs/tags-and-attributes.html#html-attributes)
*/
'*': PropTypes.any,
/**
* Any valid HTML tag name or a React component factory, anything that can be passed as the first argument to
* `React.createElement`
*/
component: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
]),
/**
* Allows for a particular child to be initially reachable via tabbing; only applied during first render
*/
defaultActiveChildIndex: PropTypes.number,
/**
* controls which arrow key events are captured to move active focus within the list:
*
* Mode | Keys
* ---- | ----
* `ArrowKeyNavigation.mode.BOTH` | ⬅️ ➡️ ⬆️ ⬇️
* `ArrowKeyNavigation.mode.HORIZONTAL` | ⬅️ ➡️
* `ArrowKeyNavigation.mode.VERTICAL` | ⬆️ ⬇️
*
* _Note: focus loops when arrowing past one of the boundaries; tabbing moves the user away from the list._
*/
mode: PropTypes.oneOf([
ArrowKeyNavigation.mode.BOTH,
ArrowKeyNavigation.mode.HORIZONTAL,
ArrowKeyNavigation.mode.VERTICAL,
]),
}
static defaultProps = {
component: 'div',
defaultActiveChildIndex: 0,
mode: ArrowKeyNavigation.mode.BOTH,
onKeyDown: () => {},
}
static internalKeys = Object.keys(ArrowKeyNavigation.defaultProps)
state = {
activeChildIndex: this.props.defaultActiveChildIndex,
children: [],
}
getFilteredChildren(props = this.props) {
return Children.toArray(props.children).filter(Boolean);
}
setActiveChildIndex() {
if (this.state.activeChildIndex !== 0) {
const numChildren = Children.count(this.state.children);
if (numChildren === 0) {
this.setState({ activeChildIndex: 0 });
} else if (this.state.activeChildIndex >= numChildren) {
this.setState({ activeChildIndex: numChildren - 1 });
}
}
}
componentWillMount() { this.setState({ children: this.getFilteredChildren() }); }
componentWillReceiveProps(nextProps) {
if (nextProps.children !== this.props.children) {
return this.setState({ children: this.getFilteredChildren(nextProps) }, this.setActiveChildIndex);
}
this.setActiveChildIndex();
}
componentDidUpdate(prevProps, prevState) {
if (this.state.activeChildIndex !== prevState.activeChildIndex) {
this.setFocus(this.state.activeChildIndex);
}
}
setFocus(index) {
const childNode = this.$wrapper.children[index];
if (childNode && childNode.hasAttribute(DATA_ATTRIBUTE_SKIP)) {
this.moveFocus(
childNode.compareDocumentPosition(document.activeElement) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
);
} else if (childNode && document.activeElement !== childNode) {
childNode.focus();
}
}
moveFocus(delta) {
const numChildren = this.state.children ? Children.count(this.state.children) : 0;
let nextIndex = this.state.activeChildIndex + delta;
if (nextIndex >= numChildren) {
nextIndex = 0; // loop
} else if (nextIndex < 0) {
nextIndex = numChildren - 1; // reverse loop
}
this.setState({ activeChildIndex: nextIndex });
}
handleKeyDown = (event) => {
switch (event.key) {
case 'ArrowUp':
if (this.props.mode === ArrowKeyNavigation.mode.VERTICAL
|| this.props.mode === ArrowKeyNavigation.mode.BOTH) {
event.preventDefault();
this.moveFocus(-1);
}
break;
case 'ArrowLeft':
if (this.props.mode === ArrowKeyNavigation.mode.HORIZONTAL
|| this.props.mode === ArrowKeyNavigation.mode.BOTH) {
event.preventDefault();
this.moveFocus(-1);
}
break;
case 'ArrowDown':
if (this.props.mode === ArrowKeyNavigation.mode.VERTICAL
|| this.props.mode === ArrowKeyNavigation.mode.BOTH) {
event.preventDefault();
this.moveFocus(1);
}
break;
case 'ArrowRight':
if (this.props.mode === ArrowKeyNavigation.mode.HORIZONTAL
|| this.props.mode === ArrowKeyNavigation.mode.BOTH) {
event.preventDefault();
this.moveFocus(1);
}
break;
}
if (this.props.onKeyDown) {
this.props.onKeyDown(event);
}
}
handleFocus = (event) => {
if (event.target.hasAttribute(DATA_ATTRIBUTE_INDEX)) {
const index = parseInt(event.target.getAttribute(DATA_ATTRIBUTE_INDEX), 10);
const child = Children.toArray(this.state.children)[index];
this.setState({ activeChildIndex: index });
if (child.props.onFocus) {
child.props.onFocus(event);
}
}
}
renderChildren() {
return Children.map(this.state.children, (child, index) => {
return cloneElement(child, {
[DATA_ATTRIBUTE_INDEX]: index,
[DATA_ATTRIBUTE_SKIP]: parseInt(child.props.tabIndex, 10) === -1 || undefined,
key: child.key || index,
tabIndex: this.state.activeChildIndex === index ? 0 : -1,
});
});
}
persistWrapperElementReference = (unknownType) => {
this.$wrapper = unknownType instanceof HTMLElement ? unknownType : findDOMNode(unknownType);
}
render() {
return (
<this.props.component
{...omit(this.props, ArrowKeyNavigation.internalKeys)}
ref={this.persistWrapperElementReference}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}>
{this.renderChildren()}
</this.props.component>
);
}
}