UNPKG

@noriginmedia/react-spatial-navigation

Version:

HOC-based Spatial Navigation (key navigation) solution for React

1,171 lines (917 loc) 39.2 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.getChildClosestToOrigin = exports.ROOT_FOCUS_KEY = undefined; var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _DEFAULT_KEY_MAP; var _filter = require('lodash/filter'); var _filter2 = _interopRequireDefault(_filter); var _first = require('lodash/first'); var _first2 = _interopRequireDefault(_first); var _sortBy = require('lodash/sortBy'); var _sortBy2 = _interopRequireDefault(_sortBy); var _findKey = require('lodash/findKey'); var _findKey2 = _interopRequireDefault(_findKey); var _forEach = require('lodash/forEach'); var _forEach2 = _interopRequireDefault(_forEach); var _forOwn = require('lodash/forOwn'); var _forOwn2 = _interopRequireDefault(_forOwn); var _throttle = require('lodash/throttle'); var _throttle2 = _interopRequireDefault(_throttle); var _difference = require('lodash/difference'); var _difference2 = _interopRequireDefault(_difference); var _measureLayout = require('./measureLayout'); var _measureLayout2 = _interopRequireDefault(_measureLayout); var _visualDebugger = require('./visualDebugger'); var _visualDebugger2 = _interopRequireDefault(_visualDebugger); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } var ROOT_FOCUS_KEY = exports.ROOT_FOCUS_KEY = 'SN:ROOT'; var ADJACENT_SLICE_THRESHOLD = 0.2; /** * Adjacent slice is 5 times more important than diagonal */ var ADJACENT_SLICE_WEIGHT = 5; var DIAGONAL_SLICE_WEIGHT = 1; /** * Main coordinate distance is 5 times more important */ var MAIN_COORDINATE_WEIGHT = 5; var DIRECTION_LEFT = 'left'; var DIRECTION_RIGHT = 'right'; var DIRECTION_UP = 'up'; var DIRECTION_DOWN = 'down'; var KEY_ENTER = 'enter'; var DEFAULT_KEY_MAP = (_DEFAULT_KEY_MAP = {}, _defineProperty(_DEFAULT_KEY_MAP, DIRECTION_LEFT, 37), _defineProperty(_DEFAULT_KEY_MAP, DIRECTION_UP, 38), _defineProperty(_DEFAULT_KEY_MAP, DIRECTION_RIGHT, 39), _defineProperty(_DEFAULT_KEY_MAP, DIRECTION_DOWN, 40), _defineProperty(_DEFAULT_KEY_MAP, KEY_ENTER, 13), _DEFAULT_KEY_MAP); var DEBUG_FN_COLORS = ['#0FF', '#FF0', '#F0F']; var THROTTLE_OPTIONS = { leading: true, trailing: false }; var getChildClosestToOrigin = exports.getChildClosestToOrigin = function getChildClosestToOrigin(children) { var childrenClosestToOrigin = (0, _sortBy2.default)(children, function (_ref) { var layout = _ref.layout; return Math.abs(layout.left) + Math.abs(layout.top); }); return (0, _first2.default)(childrenClosestToOrigin); }; /* eslint-disable no-nested-ternary */ var SpatialNavigation = function () { _createClass(SpatialNavigation, [{ key: 'sortSiblingsByPriority', /** * Inspired by: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS_for_TV/TV_remote_control_navigation#Algorithm_design * Ref Corners are the 2 corners of the current component in the direction of navigation * They used as a base to measure adjacent slices */ value: function sortSiblingsByPriority(siblings, currentLayout, direction, focusKey) { var _this = this; var isVerticalDirection = direction === DIRECTION_DOWN || direction === DIRECTION_UP; var refCorners = SpatialNavigation.getRefCorners(direction, false, currentLayout); return (0, _sortBy2.default)(siblings, function (sibling) { var siblingCorners = SpatialNavigation.getRefCorners(direction, true, sibling.layout); var isAdjacentSlice = SpatialNavigation.isAdjacentSlice(refCorners, siblingCorners, isVerticalDirection); var primaryAxisFunction = isAdjacentSlice ? SpatialNavigation.getPrimaryAxisDistance : SpatialNavigation.getSecondaryAxisDistance; var secondaryAxisFunction = isAdjacentSlice ? SpatialNavigation.getSecondaryAxisDistance : SpatialNavigation.getPrimaryAxisDistance; var primaryAxisDistance = primaryAxisFunction(refCorners, siblingCorners, isVerticalDirection); var secondaryAxisDistance = secondaryAxisFunction(refCorners, siblingCorners, isVerticalDirection); /** * The higher this value is, the less prioritised the candidate is */ var totalDistancePoints = primaryAxisDistance * MAIN_COORDINATE_WEIGHT + secondaryAxisDistance; /** * + 1 here is in case of distance is zero, but we still want to apply Adjacent priority weight */ var priority = (totalDistancePoints + 1) / (isAdjacentSlice ? ADJACENT_SLICE_WEIGHT : DIAGONAL_SLICE_WEIGHT); _this.log('smartNavigate', 'distance (primary, secondary, total weighted) for ' + sibling.focusKey + ' relative to ' + focusKey + ' is', primaryAxisDistance, secondaryAxisDistance, totalDistancePoints); _this.log('smartNavigate', 'priority for ' + sibling.focusKey + ' relative to ' + focusKey + ' is', priority); if (_this.visualDebugger) { _this.visualDebugger.drawPoint(siblingCorners.a.x, siblingCorners.a.y, 'yellow', 6); _this.visualDebugger.drawPoint(siblingCorners.b.x, siblingCorners.b.y, 'yellow', 6); } return priority; }); } }], [{ key: 'getCutoffCoordinate', /** * Used to determine the coordinate that will be used to filter items that are over the "edge" */ value: function getCutoffCoordinate(isVertical, isIncremental, isSibling, layout) { var itemX = layout.left; var itemY = layout.top; var itemWidth = layout.width; var itemHeight = layout.height; var coordinate = isVertical ? itemY : itemX; var itemSize = isVertical ? itemHeight : itemWidth; return isIncremental ? isSibling ? coordinate : coordinate + itemSize : isSibling ? coordinate + itemSize : coordinate; } /** * Returns two corners (a and b) coordinates that are used as a reference points * Where "a" is always leftmost and topmost corner, and "b" is rightmost bottommost corner */ }, { key: 'getRefCorners', value: function getRefCorners(direction, isSibling, layout) { var itemX = layout.left; var itemY = layout.top; var itemWidth = layout.width; var itemHeight = layout.height; var result = { a: { x: 0, y: 0 }, b: { x: 0, y: 0 } }; switch (direction) { case DIRECTION_UP: { var y = isSibling ? itemY + itemHeight : itemY; result.a = { x: itemX, y: y }; result.b = { x: itemX + itemWidth, y: y }; break; } case DIRECTION_DOWN: { var _y = isSibling ? itemY : itemY + itemHeight; result.a = { x: itemX, y: _y }; result.b = { x: itemX + itemWidth, y: _y }; break; } case DIRECTION_LEFT: { var x = isSibling ? itemX + itemWidth : itemX; result.a = { x: x, y: itemY }; result.b = { x: x, y: itemY + itemHeight }; break; } case DIRECTION_RIGHT: { var _x = isSibling ? itemX : itemX + itemWidth; result.a = { x: _x, y: itemY }; result.b = { x: _x, y: itemY + itemHeight }; break; } default: break; } return result; } /** * Calculates if the sibling node is intersecting enough with the ref node by the secondary coordinate */ }, { key: 'isAdjacentSlice', value: function isAdjacentSlice(refCorners, siblingCorners, isVerticalDirection) { var refA = refCorners.a, refB = refCorners.b; var siblingA = siblingCorners.a, siblingB = siblingCorners.b; var coordinate = isVerticalDirection ? 'x' : 'y'; var refCoordinateA = refA[coordinate]; var refCoordinateB = refB[coordinate]; var siblingCoordinateA = siblingA[coordinate]; var siblingCoordinateB = siblingB[coordinate]; var thresholdDistance = (refCoordinateB - refCoordinateA) * ADJACENT_SLICE_THRESHOLD; var intersectionLength = Math.max(0, Math.min(refCoordinateB, siblingCoordinateB) - Math.max(refCoordinateA, siblingCoordinateA)); return intersectionLength >= thresholdDistance; } }, { key: 'getPrimaryAxisDistance', value: function getPrimaryAxisDistance(refCorners, siblingCorners, isVerticalDirection) { var refA = refCorners.a; var siblingA = siblingCorners.a; var coordinate = isVerticalDirection ? 'y' : 'x'; return Math.abs(siblingA[coordinate] - refA[coordinate]); } }, { key: 'getSecondaryAxisDistance', value: function getSecondaryAxisDistance(refCorners, siblingCorners, isVerticalDirection) { var refA = refCorners.a, refB = refCorners.b; var siblingA = siblingCorners.a, siblingB = siblingCorners.b; var coordinate = isVerticalDirection ? 'x' : 'y'; var refCoordinateA = refA[coordinate]; var refCoordinateB = refB[coordinate]; var siblingCoordinateA = siblingA[coordinate]; var siblingCoordinateB = siblingB[coordinate]; var distancesToCompare = []; distancesToCompare.push(Math.abs(siblingCoordinateA - refCoordinateA)); distancesToCompare.push(Math.abs(siblingCoordinateA - refCoordinateB)); distancesToCompare.push(Math.abs(siblingCoordinateB - refCoordinateA)); distancesToCompare.push(Math.abs(siblingCoordinateB - refCoordinateB)); return Math.min.apply(Math, distancesToCompare); } }]); function SpatialNavigation() { _classCallCheck(this, SpatialNavigation); /** * Storage for all focusable components */ this.focusableComponents = {}; /** * Storing current focused key */ this.focusKey = null; /** * This collection contains focus keys of the elements that are having a child focused * Might be handy for styling of certain parent components if their child is focused. */ this.parentsHavingFocusedChild = []; this.enabled = false; this.nativeMode = false; this.throttle = 0; this.throttleKeypresses = false; this.pressedKeys = {}; /** * Flag used to block key events from this service * @type {boolean} */ this.paused = false; this.keyDownEventListener = null; this.keyUpEventListener = null; this.keyMap = DEFAULT_KEY_MAP; this.onKeyEvent = this.onKeyEvent.bind(this); this.pause = this.pause.bind(this); this.resume = this.resume.bind(this); this.setFocus = this.setFocus.bind(this); this.updateAllLayouts = this.updateAllLayouts.bind(this); this.navigateByDirection = this.navigateByDirection.bind(this); this.init = this.init.bind(this); this.setKeyMap = this.setKeyMap.bind(this); this.debug = false; this.visualDebugger = null; this.logIndex = 0; } _createClass(SpatialNavigation, [{ key: 'init', value: function init() { var _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, _ref2$debug = _ref2.debug, debug = _ref2$debug === undefined ? false : _ref2$debug, _ref2$visualDebug = _ref2.visualDebug, visualDebug = _ref2$visualDebug === undefined ? false : _ref2$visualDebug, _ref2$nativeMode = _ref2.nativeMode, nativeMode = _ref2$nativeMode === undefined ? false : _ref2$nativeMode, _ref2$throttle = _ref2.throttle, throttle = _ref2$throttle === undefined ? 0 : _ref2$throttle, _ref2$throttleKeypres = _ref2.throttleKeypresses, throttleKeypresses = _ref2$throttleKeypres === undefined ? false : _ref2$throttleKeypres; if (!this.enabled) { this.enabled = true; this.nativeMode = nativeMode; this.throttleKeypresses = throttleKeypresses; this.debug = debug; if (!this.nativeMode) { if (Number.isInteger(throttle) && throttle > 0) { this.throttle = throttle; } this.bindEventHandlers(); if (visualDebug) { this.visualDebugger = new _visualDebugger2.default(); this.startDrawLayouts(); } } } } }, { key: 'startDrawLayouts', value: function startDrawLayouts() { var _this2 = this; var draw = function draw() { requestAnimationFrame(function () { _this2.visualDebugger.clearLayouts(); (0, _forOwn2.default)(_this2.focusableComponents, function (component, focusKey) { _this2.visualDebugger.drawLayout(component.layout, focusKey, component.parentFocusKey); }); draw(); }); }; draw(); } }, { key: 'destroy', value: function destroy() { if (this.enabled) { this.enabled = false; this.nativeMode = false; this.throttle = 0; this.throttleKeypresses = false; this.focusKey = null; this.parentsHavingFocusedChild = []; this.focusableComponents = {}; this.paused = false; this.keyMap = DEFAULT_KEY_MAP; this.unbindEventHandlers(); } } }, { key: 'getEventType', value: function getEventType(keyCode) { return (0, _findKey2.default)(this.getKeyMap(), function (code) { return keyCode === code; }); } }, { key: 'bindEventHandlers', value: function bindEventHandlers() { var _this3 = this; // We check both because the React Native remote debugger implements window, but not window.addEventListener. if (typeof window !== 'undefined' && window.addEventListener) { this.keyDownEventListener = function (event) { if (_this3.paused === true) { return; } if (_this3.debug) { _this3.logIndex += 1; } var eventType = _this3.getEventType(event.keyCode); if (!eventType) { return; } _this3.pressedKeys[eventType] = _this3.pressedKeys[eventType] ? _this3.pressedKeys[eventType] + 1 : 1; event.preventDefault(); event.stopPropagation(); var details = { pressedKeys: _this3.pressedKeys }; if (eventType === KEY_ENTER && _this3.focusKey) { _this3.onEnterPress(details); return; } var preventDefaultNavigation = _this3.onArrowPress(eventType, details) === false; if (preventDefaultNavigation) { _this3.log('keyDownEventListener', 'default navigation prevented'); _this3.visualDebugger && _this3.visualDebugger.clear(); } else { _this3.onKeyEvent(event); } }; // Apply throttle only if the option we got is > 0 to avoid limiting the listener to every animation frame if (this.throttle) { this.keyDownEventListener = (0, _throttle2.default)(this.keyDownEventListener.bind(this), this.throttle, THROTTLE_OPTIONS); } // When throttling then make sure to only throttle key down and cancel any queued functions in case of key up this.keyUpEventListener = function (event) { var eventType = _this3.getEventType(event.keyCode); Reflect.deleteProperty(_this3.pressedKeys, eventType); if (_this3.throttle && !_this3.throttleKeypresses) { _this3.keyDownEventListener.cancel(); } if (eventType === KEY_ENTER && _this3.focusKey) { _this3.onEnterRelease(); } }; window.addEventListener('keyup', this.keyUpEventListener); window.addEventListener('keydown', this.keyDownEventListener); } } }, { key: 'unbindEventHandlers', value: function unbindEventHandlers() { // We check both because the React Native remote debugger implements window, but not window.removeEventListener. if (typeof window !== 'undefined' && window.removeEventListener) { window.removeEventListener('keydown', this.keyDownEventListener); this.keyDownEventListener = null; if (this.throttle) { window.removeEventListener('keyup', this.keyUpEventListener); this.keyUpEventListener = null; } } } }, { key: 'onEnterPress', value: function onEnterPress(details) { var component = this.focusableComponents[this.focusKey]; /* Guard against last-focused component being unmounted at time of onEnterPress (e.g due to UI fading out) */ if (!component) { this.log('onEnterPress', 'noComponent'); return; } /* Suppress onEnterPress if the last-focused item happens to lose its 'focused' status. */ if (!component.focusable) { this.log('onEnterPress', 'componentNotFocusable'); return; } component.onEnterPressHandler && component.onEnterPressHandler(details); } }, { key: 'onEnterRelease', value: function onEnterRelease() { var component = this.focusableComponents[this.focusKey]; /* Guard against last-focused component being unmounted at time of onEnterRelease (e.g due to UI fading out) */ if (!component) { this.log('onEnterRelease', 'noComponent'); return; } /* Suppress onEnterRelease if the last-focused item happens to lose its 'focused' status. */ if (!component.focusable) { this.log('onEnterRelease', 'componentNotFocusable'); return; } component.onEnterReleaseHandler && component.onEnterReleaseHandler(); } }, { key: 'onArrowPress', value: function onArrowPress() { var component = this.focusableComponents[this.focusKey]; /* Guard against last-focused component being unmounted at time of onArrowPress (e.g due to UI fading out) */ if (!component) { this.log('onArrowPress', 'noComponent'); return undefined; } /* It's okay to navigate AWAY from an item that has lost its 'focused' status, so we don't inspect * component.focusable. */ return component && component.onArrowPressHandler && component.onArrowPressHandler.apply(component, arguments); } /** * Move focus by direction, if you can't use buttons or focusing by key. * * @param {string} direction * @param {object} details * * @example * navigateByDirection('right') // The focus is moved to right */ }, { key: 'navigateByDirection', value: function navigateByDirection(direction) { var details = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; if (this.paused === true) { return; } var validDirections = [DIRECTION_DOWN, DIRECTION_UP, DIRECTION_LEFT, DIRECTION_RIGHT]; if (validDirections.includes(direction)) { this.log('navigateByDirection', 'direction', direction); this.smartNavigate(direction, null, details); } else { this.log('navigateByDirection', 'Invalid direction. You passed: `' + direction + '`, but you can use only these: ', validDirections); } } }, { key: 'onKeyEvent', value: function onKeyEvent(event) { this.visualDebugger && this.visualDebugger.clear(); var direction = (0, _findKey2.default)(this.getKeyMap(), function (code) { return event.keyCode === code; }); this.smartNavigate(direction, null, { event: event }); } /** * This function navigates between siblings OR goes up by the Tree * Based on the Direction */ }, { key: 'smartNavigate', value: function smartNavigate(direction, fromParentFocusKey, details) { var _this4 = this; this.log('smartNavigate', 'direction', direction); this.log('smartNavigate', 'fromParentFocusKey', fromParentFocusKey); this.log('smartNavigate', 'this.focusKey', this.focusKey); if (!this.nativeMode && !fromParentFocusKey) { (0, _forOwn2.default)(this.focusableComponents, function (component) { component.layoutUpdated = false; }); } var currentComponent = this.focusableComponents[fromParentFocusKey || this.focusKey]; this.log('smartNavigate', 'currentComponent', currentComponent ? currentComponent.focusKey : undefined, currentComponent ? currentComponent.node : undefined); if (currentComponent) { this.updateLayout(currentComponent.focusKey); var parentFocusKey = currentComponent.parentFocusKey, focusKey = currentComponent.focusKey, layout = currentComponent.layout; var isVerticalDirection = direction === DIRECTION_DOWN || direction === DIRECTION_UP; var isIncrementalDirection = direction === DIRECTION_DOWN || direction === DIRECTION_RIGHT; var currentCutoffCoordinate = SpatialNavigation.getCutoffCoordinate(isVerticalDirection, isIncrementalDirection, false, layout); /** * Get only the siblings with the coords on the way of our moving direction */ var siblings = (0, _filter2.default)(this.focusableComponents, function (component) { if (component.parentFocusKey === parentFocusKey && component.focusable) { _this4.updateLayout(component.focusKey); var siblingCutoffCoordinate = SpatialNavigation.getCutoffCoordinate(isVerticalDirection, isIncrementalDirection, true, component.layout); return isIncrementalDirection ? siblingCutoffCoordinate >= currentCutoffCoordinate : siblingCutoffCoordinate <= currentCutoffCoordinate; } return false; }); if (this.debug) { this.log('smartNavigate', 'currentCutoffCoordinate', currentCutoffCoordinate); this.log('smartNavigate', 'siblings', siblings.length + ' elements:', siblings.map(function (sibling) { return sibling.focusKey; }).join(', '), siblings.map(function (sibling) { return sibling.node; })); } if (this.visualDebugger) { var refCorners = SpatialNavigation.getRefCorners(direction, false, layout); this.visualDebugger.drawPoint(refCorners.a.x, refCorners.a.y); this.visualDebugger.drawPoint(refCorners.b.x, refCorners.b.y); } var sortedSiblings = this.sortSiblingsByPriority(siblings, layout, direction, focusKey); var nextComponent = (0, _first2.default)(sortedSiblings); this.log('smartNavigate', 'nextComponent', nextComponent ? nextComponent.focusKey : undefined, nextComponent ? nextComponent.node : undefined); if (nextComponent) { this.setFocus(nextComponent.focusKey, null, details); } else { var parentComponent = this.focusableComponents[parentFocusKey]; this.saveLastFocusedChildKey(parentComponent, focusKey); if (!parentComponent || !parentComponent.blockNavigationOut) { this.smartNavigate(direction, parentFocusKey, details); } } } } }, { key: 'saveLastFocusedChildKey', value: function saveLastFocusedChildKey(component, focusKey) { if (component) { this.log('saveLastFocusedChildKey', component.focusKey + ' lastFocusedChildKey set', focusKey); component.lastFocusedChildKey = focusKey; } } }, { key: 'log', value: function log(functionName, debugString) { if (this.debug) { var _console; for (var _len = arguments.length, rest = Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { rest[_key - 2] = arguments[_key]; } (_console = console).log.apply(_console, ['%c' + functionName + '%c' + debugString, 'background: ' + DEBUG_FN_COLORS[this.logIndex % DEBUG_FN_COLORS.length] + '; color: black; padding: 1px 5px;', 'background: #333; color: #BADA55; padding: 1px 5px;'].concat(rest)); } } /** * This function tries to determine the next component to Focus * It's either the target node OR the one down by the Tree if node has children components * Based on "targetFocusKey" that means the "intended component to focus" */ }, { key: 'getNextFocusKey', value: function getNextFocusKey(targetFocusKey) { var _this5 = this; var targetComponent = this.focusableComponents[targetFocusKey]; /** * Security check, if component doesn't exist, stay on the same focusKey */ if (!targetComponent || this.nativeMode) { return targetFocusKey; } var children = (0, _filter2.default)(this.focusableComponents, function (component) { return component.parentFocusKey === targetFocusKey && component.focusable; }); if (children.length > 0) { var lastFocusedChildKey = targetComponent.lastFocusedChildKey, preferredChildFocusKey = targetComponent.preferredChildFocusKey; this.log('getNextFocusKey', 'lastFocusedChildKey is', lastFocusedChildKey); this.log('getNextFocusKey', 'preferredChildFocusKey is', preferredChildFocusKey); /** * First of all trying to focus last focused child */ if (lastFocusedChildKey && !targetComponent.forgetLastFocusedChild && this.isParticipatingFocusableComponent(lastFocusedChildKey)) { this.log('getNextFocusKey', 'lastFocusedChildKey will be focused', lastFocusedChildKey); return this.getNextFocusKey(lastFocusedChildKey); } /** * If there is no lastFocusedChild, trying to focus the preferred focused key */ if (preferredChildFocusKey && this.isParticipatingFocusableComponent(preferredChildFocusKey)) { this.log('getNextFocusKey', 'preferredChildFocusKey will be focused', preferredChildFocusKey); return this.getNextFocusKey(preferredChildFocusKey); } /** * Otherwise, trying to focus something by coordinates */ children.forEach(function (component) { return _this5.updateLayout(component.focusKey); }); var _getChildClosestToOri = getChildClosestToOrigin(children), childKey = _getChildClosestToOri.focusKey; this.log('getNextFocusKey', 'childKey will be focused', childKey); return this.getNextFocusKey(childKey); } /** * If no children, just return targetFocusKey back */ this.log('getNextFocusKey', 'targetFocusKey', targetFocusKey); return targetFocusKey; } }, { key: 'addFocusable', value: function addFocusable(_ref3) { var focusKey = _ref3.focusKey, node = _ref3.node, parentFocusKey = _ref3.parentFocusKey, onEnterPressHandler = _ref3.onEnterPressHandler, onEnterReleaseHandler = _ref3.onEnterReleaseHandler, onArrowPressHandler = _ref3.onArrowPressHandler, onBecameFocusedHandler = _ref3.onBecameFocusedHandler, onBecameBlurredHandler = _ref3.onBecameBlurredHandler, forgetLastFocusedChild = _ref3.forgetLastFocusedChild, trackChildren = _ref3.trackChildren, onUpdateFocus = _ref3.onUpdateFocus, onUpdateHasFocusedChild = _ref3.onUpdateHasFocusedChild, preferredChildFocusKey = _ref3.preferredChildFocusKey, autoRestoreFocus = _ref3.autoRestoreFocus, focusable = _ref3.focusable, blockNavigationOut = _ref3.blockNavigationOut; this.focusableComponents[focusKey] = { focusKey: focusKey, node: node, parentFocusKey: parentFocusKey, onEnterPressHandler: onEnterPressHandler, onEnterReleaseHandler: onEnterReleaseHandler, onArrowPressHandler: onArrowPressHandler, onBecameFocusedHandler: onBecameFocusedHandler, onBecameBlurredHandler: onBecameBlurredHandler, onUpdateFocus: onUpdateFocus, onUpdateHasFocusedChild: onUpdateHasFocusedChild, forgetLastFocusedChild: forgetLastFocusedChild, trackChildren: trackChildren, lastFocusedChildKey: null, preferredChildFocusKey: preferredChildFocusKey, focusable: focusable, blockNavigationOut: blockNavigationOut, autoRestoreFocus: autoRestoreFocus, layout: { x: 0, y: 0, width: 0, height: 0, left: 0, top: 0, /** * Node ref is also duplicated in layout to be reported in onBecameFocused callback * E.g. used in native environments to lazy-measure the layout on focus */ node: node }, layoutUpdated: false }; if (this.nativeMode) { return; } this.updateLayout(focusKey); /** * If for some reason this component was already focused before it was added, call the update */ if (focusKey === this.focusKey) { this.setFocus(focusKey); } } }, { key: 'removeFocusable', value: function removeFocusable(_ref4) { var focusKey = _ref4.focusKey; var componentToRemove = this.focusableComponents[focusKey]; if (componentToRemove) { var parentFocusKey = componentToRemove.parentFocusKey; Reflect.deleteProperty(this.focusableComponents, focusKey); var parentComponent = this.focusableComponents[parentFocusKey]; var isFocused = focusKey === this.focusKey; /** * If the component was stored as lastFocusedChild, clear lastFocusedChildKey from parent */ parentComponent && parentComponent.lastFocusedChildKey === focusKey && (parentComponent.lastFocusedChildKey = null); if (this.nativeMode) { return; } /** * If the component was also focused at this time, focus another one */ if (isFocused && parentComponent && parentComponent.autoRestoreFocus) { this.setFocus(parentFocusKey); } } } }, { key: 'getNodeLayoutByFocusKey', value: function getNodeLayoutByFocusKey(focusKey) { var component = this.focusableComponents[focusKey]; if (component) { this.updateLayout(component.focusKey); return component.layout; } return null; } }, { key: 'setCurrentFocusedKey', value: function setCurrentFocusedKey(newFocusKey, details) { if (this.isFocusableComponent(this.focusKey) && newFocusKey !== this.focusKey) { var oldComponent = this.focusableComponents[this.focusKey]; var parentComponent = this.focusableComponents[oldComponent.parentFocusKey]; this.saveLastFocusedChildKey(parentComponent, this.focusKey); oldComponent.onUpdateFocus(false); oldComponent.onBecameBlurredHandler(this.getNodeLayoutByFocusKey(this.focusKey), details); } this.focusKey = newFocusKey; if (this.isFocusableComponent(this.focusKey)) { var newComponent = this.focusableComponents[this.focusKey]; newComponent.onUpdateFocus(true); newComponent.onBecameFocusedHandler(this.getNodeLayoutByFocusKey(this.focusKey), details); } } }, { key: 'updateParentsHasFocusedChild', value: function updateParentsHasFocusedChild(focusKey, details) { var _this6 = this; var parents = []; var currentComponent = this.focusableComponents[focusKey]; /** * Recursively iterate the tree up and find all the parents' focus keys */ while (currentComponent) { var _currentComponent = currentComponent, parentFocusKey = _currentComponent.parentFocusKey; var parentComponent = this.focusableComponents[parentFocusKey]; if (parentComponent) { var currentParentFocusKey = parentComponent.focusKey; parents.push(currentParentFocusKey); } currentComponent = parentComponent; } var parentsToRemoveFlag = (0, _difference2.default)(this.parentsHavingFocusedChild, parents); var parentsToAddFlag = (0, _difference2.default)(parents, this.parentsHavingFocusedChild); (0, _forEach2.default)(parentsToRemoveFlag, function (parentFocusKey) { var parentComponent = _this6.focusableComponents[parentFocusKey]; parentComponent && parentComponent.trackChildren && parentComponent.onUpdateHasFocusedChild(false); _this6.onIntermediateNodeBecameBlurred(parentFocusKey, details); }); (0, _forEach2.default)(parentsToAddFlag, function (parentFocusKey) { var parentComponent = _this6.focusableComponents[parentFocusKey]; parentComponent && parentComponent.trackChildren && parentComponent.onUpdateHasFocusedChild(true); _this6.onIntermediateNodeBecameFocused(parentFocusKey, details); }); this.parentsHavingFocusedChild = parents; } }, { key: 'updateParentsLastFocusedChild', value: function updateParentsLastFocusedChild(focusKey) { var currentComponent = this.focusableComponents[focusKey]; /** * Recursively iterate the tree up and update all the parent's lastFocusedChild */ while (currentComponent) { var _currentComponent2 = currentComponent, parentFocusKey = _currentComponent2.parentFocusKey; var parentComponent = this.focusableComponents[parentFocusKey]; if (parentComponent) { this.saveLastFocusedChildKey(parentComponent, currentComponent.focusKey); } currentComponent = parentComponent; } } }, { key: 'getKeyMap', value: function getKeyMap() { return this.keyMap; } }, { key: 'setKeyMap', value: function setKeyMap(keyMap) { this.keyMap = _extends({}, this.getKeyMap(), keyMap); } }, { key: 'isFocusableComponent', value: function isFocusableComponent(focusKey) { return !!this.focusableComponents[focusKey]; } /** * Checks whether the focusableComponent is actually participating in spatial navigation (in other words, is a * 'focusable' focusableComponent). Seems less confusing than calling it isFocusableFocusableComponent() */ }, { key: 'isParticipatingFocusableComponent', value: function isParticipatingFocusableComponent(focusKey) { return this.isFocusableComponent(focusKey) && this.focusableComponents[focusKey].focusable; } }, { key: 'onIntermediateNodeBecameFocused', value: function onIntermediateNodeBecameFocused(focusKey, details) { this.isParticipatingFocusableComponent(focusKey) && this.focusableComponents[focusKey].onBecameFocusedHandler(this.getNodeLayoutByFocusKey(focusKey), details); } }, { key: 'onIntermediateNodeBecameBlurred', value: function onIntermediateNodeBecameBlurred(focusKey, details) { this.isParticipatingFocusableComponent(focusKey) && this.focusableComponents[focusKey].onBecameBlurredHandler(this.getNodeLayoutByFocusKey(focusKey), details); } }, { key: 'pause', value: function pause() { this.paused = true; } }, { key: 'resume', value: function resume() { this.paused = false; } }, { key: 'setFocus', value: function setFocus(focusKey, overwriteFocusKey) { var details = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; if (!this.enabled) { return; } var targetFocusKey = overwriteFocusKey || focusKey; this.log('setFocus', 'targetFocusKey', targetFocusKey); var lastFocusedKey = this.focusKey; var newFocusKey = this.getNextFocusKey(targetFocusKey); this.log('setFocus', 'newFocusKey', newFocusKey); this.setCurrentFocusedKey(newFocusKey, details); this.updateParentsHasFocusedChild(newFocusKey, details); this.updateParentsLastFocusedChild(lastFocusedKey); } }, { key: 'updateAllLayouts', value: function updateAllLayouts() { var _this7 = this; if (this.nativeMode) { return; } (0, _forOwn2.default)(this.focusableComponents, function (component, focusKey) { _this7.updateLayout(focusKey); }); } }, { key: 'updateLayout', value: function updateLayout(focusKey) { var component = this.focusableComponents[focusKey]; if (!component || this.nativeMode || component.layoutUpdated) { return; } var node = component.node; (0, _measureLayout2.default)(node, function (x, y, width, height, left, top) { component.layout = { x: x, y: y, width: width, height: height, left: left, top: top, node: node }; }); } }, { key: 'updateFocusable', value: function updateFocusable(focusKey, _ref5) { var node = _ref5.node, preferredChildFocusKey = _ref5.preferredChildFocusKey, focusable = _ref5.focusable, blockNavigationOut = _ref5.blockNavigationOut; if (this.nativeMode) { return; } var component = this.focusableComponents[focusKey]; if (component) { component.preferredChildFocusKey = preferredChildFocusKey; component.focusable = focusable; component.blockNavigationOut = blockNavigationOut; if (node) { component.node = node; } } } }, { key: 'isNativeMode', value: function isNativeMode() { return this.nativeMode; } }]); return SpatialNavigation; }(); /** * Export singleton */ exports.default = new SpatialNavigation();