Show: Inherited Protected Private Deprecated

# File `src/ui/react/src/components/base/widget-focus-manager.js`

                                (function() {
                                    'use strict';

                                    var DIRECTION_NONE = 0;
                                    var DIRECTION_NEXT = 1;
                                    var DIRECTION_PREV = -1;

                                    var ACTION_NONE = 0;
                                    var ACTION_MOVE_FOCUS = 1;
                                    var ACTION_DISMISS_FOCUS = 2;

                                    /**
                                     * WidgetFocusManager is a mixin that provides keyboard navigation inside a widget. To do this,
                                     * it exposes the following props and methods:
                                     *
                                     * @class WidgetFocusManager
                                     */
                                    var WidgetFocusManager = {
                                        // Allows validating props being passed to the component.
                                        propTypes: {

                                            /**
                                             * Callback method to be invoked when the focus manager is to be dismissed. This happens
                                             * in the following scenarios if a dismiss callback has been specified:
                                             * - A dismiss key has been pressed
                                             * - In a non-circular focus manager, when:
                                             *     - The active descendant is the first one and a prev key has been pressed.
                                             *     - The active descendant is the last one and a next key has been pressed.
                                             *
                                             * @property {Function} onDismiss
                                             */
                                            onDismiss: React.PropTypes.func,

                                            /**
                                             * Indicates if focus should be set to the first/last descendant when the limits are reached.
                                             *
                                             * @property {boolean} circular
                                             */
                                            circular: React.PropTypes.bool.isRequired,

                                            /**
                                            * Indicate if should focus the first child of a container
                                            * @property {Boolean} focusFirstChild
                                            */
                                            focusFirstChild: React.PropTypes.bool,

                                            /**
                                             * String representing the CSS selector used to define the elements that should be handled.
                                             *
                                             * @property {String} descendants
                                             */
                                            descendants: React.PropTypes.string.isRequired,

                                            /**
                                             * Object representing the keys used to navigate between descendants. The format for the prop is:
                                             * `{dismiss: value, dismissNext: value, dismissPrev: value, next: value, prev: value}` where
                                             * value can be both a number or an array of numbers with the allowed keyCodes.
                                             *
                                             * @property {Object} keys
                                             */
                                            keys: React.PropTypes.object.isRequired
                                        },

                                        /**
                                         * Lifecycle. Invoked once, only on the client, immediately after the initial rendering occurs.
                                         *
                                         * @method componentDidMount
                                         */
                                        componentDidMount: function() {
                                            this._refresh();
                                        },

                                        /**
                                         * Lifecycle. Invoked immediately after the component's updates are flushed to the DOM.
                                         * Refreshes the descendants list.
                                         *
                                         * @method componentDidUpdate
                                         */
                                        componentDidUpdate: function() {
                                            this._refresh();
                                        },

                                        /**
                                         * Focuses the current active descendant.
                                         *
                                         * Several Widgets can be nested in a component hierarchy by attaching this focus method to
                                         * the widget DOM node, transferring the DOM focus control to the inner FocusManager.
                                         *
                                         * @method focus
                                         */
                                        focus: function(event) {
                                            if (!event || this._isValidTarget(event.target)) {
                                                if (this._descendants) {
                                                    var activeDescendantEl = this._descendants[this._activeDescendant];
                                                    // When user clicks with the mouse, the activeElement is already set and there
                                                    // is no need to focus it. Focusing of the active descendant (usually some button) is required
                                                    // in case of keyboard navigation, because the focused element might be not the first button,
                                                    // but the div element, which contains the button.
                                                    if (document.activeElement !== activeDescendantEl && !this.props.focusFirstChild) {
                                                        if (this._descendants.indexOf(document.activeElement) === -1) {
                                                            activeDescendantEl.focus();
                                                        }
                                                    }

                                                    if (event) {
                                                        event.stopPropagation();
                                                        event.preventDefault();
                                                    }
                                                }
                                            }
                                        },

                                        /**
                                         * Handles the key events on a DOM node to execute the appropriate navigation when needed.
                                         *
                                         * @param {Object} event The Keyboard event that was detected on the widget DOM node.
                                         * @method handleKey
                                         */
                                        handleKey: function(event) {
                                            if (this._isValidTarget(event.target) && this._descendants) {
                                                var action = this._getFocusAction(event);

                                                if (action.type) {
                                                    event.stopPropagation();
                                                    event.preventDefault();

                                                    if (action.type === ACTION_MOVE_FOCUS) {
                                                        this._moveFocus(action.direction);
                                                    }

                                                    if (action.type === ACTION_DISMISS_FOCUS) {
                                                        this.props.onDismiss(action.direction);
                                                    }
                                                }
                                            }
                                        },

                                        /**
                                         * Moves the focus among descendants in the especified direction.
                                         *
                                         * @method moveFocus
                                         * @param {number} direction The direction (1 or -1) of the focus movement among descendants.
                                         */
                                        moveFocus: function(direction) {
                                            direction = AlloyEditor.Lang.isNumber(direction) ? direction : 0;

                                            this._moveFocus(direction);
                                        },

                                        /**
                                         * Returns the action, if any, that a keyboard event in the current focus manager state
                                         * should produce.
                                         *
                                         * @protected
                                         * @method _getFocusAction
                                         * @param {object} event The Keyboard event.
                                         * @return {Object} An action object with type and direction properties.
                                         */
                                        _getFocusAction: function(event) {
                                            var action = {
                                                type: ACTION_NONE
                                            };

                                            if (this.props.keys) {
                                                var direction = this._getFocusMoveDirection(event);

                                                if (direction) {
                                                    action.direction = direction;
                                                    action.type = ACTION_MOVE_FOCUS;
                                                }

                                                var dismissAction = this._getFocusDismissAction(event, direction);

                                                if (dismissAction.dismiss) {
                                                    action.direction = dismissAction.direction;
                                                    action.type = ACTION_DISMISS_FOCUS;
                                                }
                                            }

                                            return action;
                                        },

                                        /**
                                         * Returns the dismiss action, if any, the focus manager should execute to yield the focus. This
                                         * will happen in any of these scenarios if a dismiss callback has been specified:
                                         * - A dismiss key has been pressed
                                         * - In a non-circular focus manager, when:
                                         *     - The active descendant is the first one and a prev key has been pressed.
                                         *     - The active descendant is the last one and a next key has been pressed.
                                         *
                                         * @protected
                                         * @method _getFocusDismissAction
                                         * @param {Object} event The Keyboard event.
                                         * @param {Number} focusMoveDirection The focus movement direction (if any).
                                         * @return {Object} A dismiss action with dismiss and direction properties.
                                         */
                                        _getFocusDismissAction: function(event, focusMoveDirection) {
                                            var dismissAction = {
                                                direction: focusMoveDirection,
                                                dismiss: false
                                            };

                                            if (this.props.onDismiss) {
                                                if (this._isValidKey(event.keyCode, this.props.keys.dismiss)) {
                                                    dismissAction.dismiss = true;
                                                }
                                                if (this._isValidKey(event.keyCode, this.props.keys.dismissNext)) {
                                                    dismissAction.dismiss = true;
                                                    dismissAction.direction = DIRECTION_NEXT;
                                                }
                                                if (this._isValidKey(event.keyCode, this.props.keys.dismissPrev)) {
                                                    dismissAction.dismiss = true;
                                                    dismissAction.direction = DIRECTION_PREV;
                                                }

                                                if (!dismissAction.dismiss && !this.props.circular && focusMoveDirection) {
                                                    dismissAction.dismiss = (
                                                        focusMoveDirection === DIRECTION_PREV && this._activeDescendant === 0 ||
                                                        focusMoveDirection === DIRECTION_NEXT && this._activeDescendant === this._descendants.length - 1
                                                    );
                                                }
                                            }

                                            return dismissAction;
                                        },

                                        /**
                                         * Returns the direction, if any, in which the focus should be moved. In presence of the
                                         * shift key modifier, the direction of the movement is inverted.
                                         *
                                         * @protected
                                         * @method _getFocusMoveDirection
                                         * @param {Object} event The Keyboard event.
                                         * @return {Number} The computed direction of the expected focus movement.
                                         */
                                        _getFocusMoveDirection: function(event) {
                                            var direction = DIRECTION_NONE;

                                            if (this._isValidKey(event.keyCode, this.props.keys.next)) {
                                                direction = DIRECTION_NEXT;
                                            }
                                            if (this._isValidKey(event.keyCode, this.props.keys.prev)) {
                                                direction = DIRECTION_PREV;
                                            }

                                            if (event.shifKey) {
                                                direction *= -1;
                                            }

                                            return direction;
                                        },

                                        /**
                                         * Indicates if a given keyCode is valid for the given set of keys.
                                         *
                                         * @param {Number} keyCode An event keyCode.
                                         * @param {Array|Number} keys A key set. Can be a number an array of numbers representing the allowed keyCodes.
                                         *
                                         * @protected
                                         * @method _isValidKey
                                         * @return {Boolean} A boolean value indicating if the key is valid.
                                         */
                                        _isValidKey: function(keyCode, keys) {
                                            return AlloyEditor.Lang.isArray(keys) ? (keys.indexOf(keyCode) !== -1) : (keyCode === keys);
                                        },

                                        /**
                                         * Indicates if a given element is valid for focus management. User input elements such as
                                         * input, select or textarea are excluded.
                                         *
                                         * @protected
                                         * @method _isValidKey
                                         * @param {DOMNode} element A DOM element.
                                         * @return {Boolean} A boolean value indicating if the element is valid.
                                         */
                                        _isValidTarget: function(element) {
                                            var tagName = element.tagName.toLowerCase();

                                            return (tagName !== 'input' && tagName !== 'select' && tagName !== 'textarea');
                                        },

                                        /**
                                         * Moves the focus among descendants in the especified direction.
                                         *
                                         * @protected
                                         * @method _moveFocus
                                         * @param {number} direction The direction (1 or -1) of the focus movement among descendants.
                                         */
                                        _moveFocus: function(direction) {
                                            var numDescendants = this._descendants.length;

                                            var descendant = this._descendants[this._activeDescendant];

                                            descendant.setAttribute('tabIndex', -1);

                                            this._activeDescendant += direction;

                                            if (this.props.circular) {
                                                // Calculate proper modulo result since remainder operator doesn't behave in the
                                                // same way for negative numbers
                                                this._activeDescendant = ((this._activeDescendant % numDescendants) + numDescendants) % numDescendants;
                                            } else {
                                                this._activeDescendant = Math.max(Math.min(this._activeDescendant, numDescendants - 1), 0);
                                            }

                                            descendant = this._descendants[this._activeDescendant];

                                            descendant.setAttribute('tabIndex', 0);
                                            descendant.focus();
                                        },

                                        /**
                                         * Refreshes the descendants list by executing the CSS selector again and resets the descendants tabIndex.
                                         *
                                         * @protected
                                         * @method _refresh
                                         */
                                        _refresh: function() {
                                            var domNode = ReactDOM.findDOMNode(this);

                                            if (domNode) {
                                                var descendants = domNode.querySelectorAll(this.props.descendants);

                                                var priorityDescendants = [];

                                                this._descendants = [];

                                                Array.prototype.slice.call(descendants).forEach(function(item) {
                                                    var dataTabIndex = item.getAttribute('data-tabindex');

                                                    if (dataTabIndex) {
                                                        priorityDescendants.push(item);
                                                    } else {
                                                        this._descendants.push(item);
                                                    }
                                                }.bind(this));

                                                priorityDescendants = priorityDescendants.sort(function(a, b) {
                                                    return (AlloyEditor.Lang.toInt(a.getAttribute('data-tabindex')) > AlloyEditor.Lang.toInt(b.getAttribute('data-tabindex')));
                                                });

                                                this._descendants = priorityDescendants.concat(this._descendants);

                                                this._activeDescendant = 0;

                                                this._descendants.some(function(item, index) {
                                                    if (item.getAttribute('tabindex') === '0') {
                                                        this._activeDescendant = index;
                                                        this.focus();

                                                        return true;
                                                    }
                                                }.bind(this));
                                            }
                                        }
                                    };

                                    AlloyEditor.WidgetFocusManager = WidgetFocusManager;
                                }());