All files / boundless/packages/boundless-arrow-key-navigation index.js

98.21% Statements 55/56
90.16% Branches 55/61
100% Functions 14/14
98.21% Lines 55/56
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216            1x 1x                                                                                                                                       51x       21x 1x   1x   1x 1x         34x     21x 17x     4x       51x 30x         33x   33x 2x     31x 21x         18x 18x   18x 3x 15x 2x     18x       20x   4x   3x 3x     4x     5x   4x 4x     5x     6x   5x 5x     6x     5x   4x 4x     5x     20x 20x         43x 43x 43x   43x   43x 1x           85x 164x                   70x       85x                      
import React, {Children, PropTypes} 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';
 
/**
__A higher-order component for arrow key navigation on a grouping of children.__
 
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 React.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);
 
            Iif (numChildren === 0) {
                this.setState({activeChildIndex: 0});
            } else Eif (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;
        }
 
        Eif (this.props.onKeyDown) {
            this.props.onKeyDown(event);
        }
    }
 
    handleFocus = (event) => {
        Eif (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 React.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>
        );
    }
}