'use strict'; /* global evt */ (function(exports) { /** * SpatialNavigator simulates four-direction navigation in Javascript level. * * Navigation is the ability to navigate between focusable elements * within a structured document or user interface according to the spatial * location. Users are assumed navigating among elements on a 2D plane by * arrow keys (up/down/left/right). * * SpatialNavigator keeps a 'focused' element by itself. When navigating, * focus/unfocus events are triggered automatically. Notice the focus is just * an internal state rather than actual focus of DOM Element. User should * add event listeners of those events, and design required behaviors. * * @class SpatialNavigator * @param {Array.} [collection=[]] * An initial set of traversable elements. * @param {Object} [config] * An initial set of configurations. */ /** * SpatialNavigatorElement is a navigable element which can be traversed * by {@link SpatialNavigator}. Valid types are as follows: * * 1. a standard HTMLElement. * 2. an Object contains at least 4 properties: left, top, width, and height. * 3. an Object implementing getBoundingRect() which returns an object of 2. * * @typedef {Object} SpatialNavigatorElement */ /** * Fired when an element is focused. * @event SpatialNavigator#focus * @property {SpatialNavigatorElement} elem The element which got focus. */ /** * Fired when an element is unfocused. * @event SpatialNavigator#unfocus * @property {SpatialNavigatorElement} elem The element which lost focus. */ function SpatialNavigator(collection, config) { this._focus = null; this._previous = null; this.setCollection(collection); if (config) { for (var key in config) { this[key] = config[key]; } } } SpatialNavigator.prototype = evt({ /** * Limit the navigating direction to vertical and horizontal only. Targets * in oblique (left-top, right-top, left-bottom, and right-bottom) * directions are always ignored. * @type {Boolean} * @default false * @memberof SpatialNavigator.prototype */ straightOnly: false, /** * This threshold is used to determine whether an element is considered in * straight (vertical or horizontal) directions. Valid number is between 0 * to 1.0. Setting it to 0.3 means an element is counted in the straight * directions if it overlaps the straight area at least 0.3x of width of the * area. * @type {Number} * @default 0.5 * @memberof SpatialNavigator.prototype */ straightOverlapThreshold: 0.5, /** * Ignore elements with "display: none", "visibility: hidden" or * "aria-hidden=true". * @type {Boolean} * @default false * @memberof SpatialNavigator.prototype */ ignoreHiddenElement: false, /** * The previous focused element has high priority to be chosen as the next * candidate. * @type {Boolean} * @default false * @memberof SpatialNavigator.prototype */ rememberSource: false, /** * A callback function that accepcts an element as the first argument will * be triggered everytime when SpatialNavigator tries to traverse every * single candidate. You can ignore arbitrary elements by returning "false" * in this function. * @type {Function} * @default null * @memberof SpatialNavigator.prototype */ navigableFilter: null, /** * setting this property true prevents spatialNavigator from emitting any * events. * @type {Boolean} */ silent: false, /** * Rect represents position and dimension of a 2D object. * @typedef {Object} Rect * @property {Integer} left Left position * @property {Integer} top Top position * @property {Integer} right Right position * @property {Integer} bottom Bottom position * @property {Integer} width Width dimension * @property {Integer} height Height dimension * @property {Rect} [center] Center position * @property {Integer} [x] same as left * @property {Integer} [y] same as top * @access private * @memberof SpatialNavigator.prototype */ /** * Get {@link Rect} of a {@link SpatialNavigatorElement}. * * @param {SpatialNavigatorElement} elem * * @return {Rect} dimension of elem. * * @access private * @memberof SpatialNavigator.prototype */ _getRect: function snGetRect(elem) { var rect = null; if (!this._isNavigable(elem)) { return null; } if (elem.getBoundingClientRect) { var cr = elem.getBoundingClientRect(); rect = { left: cr.left, top: cr.top, width: cr.width, height: cr.height }; } else if (elem.left !== undefined) { rect = { left: parseInt(elem.left || 0, 10), top: parseInt(elem.top || 0, 10), width: parseInt(elem.width || 0, 10), height: parseInt(elem.height || 0, 10) }; } else { return null; } rect.element = elem; rect.right = rect.left + rect.width; rect.bottom = rect.top + rect.height; rect.center = { x: rect.left + Math.floor(rect.width / 2), y: rect.top + Math.floor(rect.height / 2) }; rect.center.left = rect.center.right = rect.center.x; rect.center.top = rect.center.bottom = rect.center.y; return rect; }, /** * Get all {@link Rect} objects from the collection. * * @param {SpatialNavigatorElement} [excludedElem] * You can pass excludedElem here to ignore it from calculating. * (most likely, the currently focused element is passed). * * @return {Array.} {@link Rect} objects of all traversable elements. * * @access private * @memberof SpatialNavigator.prototype */ _getAllRects: function snGetAllRects(excludedElem) { var rects = []; this._collection.forEach(function(elem) { if (!excludedElem || excludedElem !== elem) { var rect = this._getRect(elem); if (rect) { rects.push(rect); } } }, this); return rects; }, /** * Check whether a {@link SpatialNavigatorElement} is navigable. * * @param {SpatialNavigatorElement} elem * * @return {Boolean} true if it's navigable. * * @access private * @memberof SpatialNavigator.prototype */ _isNavigable: function snIsNavigable(elem) { if (this.ignoreHiddenElement && elem instanceof HTMLElement) { var computedStyle = window.getComputedStyle(elem); if ((elem.offsetWidth <= 0 && elem.offsetHeight <= 0) || computedStyle.getPropertyValue('visibility') == 'hidden' || computedStyle.getPropertyValue('display') == 'none' || elem.hasAttribute('aria-hidden')) { return false; } } if (this.navigableFilter && !this.navigableFilter(elem)) { return false; } return true; }, /** * Given a set of {@link Rect} array, divide them into 9 groups with * respect to the position of targetRect. Rects centered inside targetRect * are grouped as 4th group; straight left as 3rd group; straight right as * 5th group; ..... and so on. See below for the corresponding group number: * *
     *  |---+---+---|
     *  | 0 | 1 | 2 |
     *  |---+---+---|
     *  | 3 | 4 | 5 |
     *  |---+---+---|
     *  | 6 | 7 | 8 |
     *  |---+---+---|
     * 
* * @param {Array.} rects * {@link RectS} to be divided. * @param {Rect} targetRect * Reference position for groups. * * @return {Array.Array.} * A 9-elements array of array, where rects are categorized into * these 9 arrays by their group number. * * @access private * @memberof SpatialNavigator.prototype * */ _partition: function snDemarcate(rects, targetRect) { var groups = [[], [], [], [], [], [], [], [], []]; var threshold = this.straightOverlapThreshold; if (threshold > 1 || threshold < 0) { // Fallback to default value threshold = 0.5; } rects.forEach(function(rect) { var center = rect.center; var x, y, groupId; if (center.x < targetRect.left) { x = 0; } else if (center.x <= targetRect.right) { x = 1; } else { x = 2; } if (center.y < targetRect.top) { y = 0; } else if (center.y <= targetRect.bottom) { y = 1; } else { y = 2; } groupId = y * 3 + x; groups[groupId].push(rect); // Although a rect is in the oblique directions, we categorize it in // the straight area as well if it overlaps the straight directions more // than a specified threshold (0.5 by default). if ([0, 2, 6, 8].indexOf(groupId) !== -1) { if (rect.left <= targetRect.right - targetRect.width * threshold) { if (groupId === 2) { groups[1].push(rect); } else if (groupId === 8) { groups[7].push(rect); } } if (rect.right >= targetRect.left + targetRect.width * threshold) { if (groupId === 0) { groups[1].push(rect); } else if (groupId === 6) { groups[7].push(rect); } } if (rect.top <= targetRect.bottom - targetRect.height * threshold) { if (groupId === 6) { groups[3].push(rect); } else if (groupId === 8) { groups[5].push(rect); } } if (rect.bottom >= targetRect.top + targetRect.height * threshold) { if (groupId === 0) { groups[3].push(rect); } else if (groupId === 2) { groups[5].push(rect); } } } }); return groups; }, /** * Bind targetRect to a set of distance functions for ranking. These * functions work with another {@link Rect} object passed to get a ranking * value relative to targetRect. * * @param {Rect} targetRect * * @return {Object.} * An object containing a bunch of functions bound with targetRect. * * @access private * @memberof SpatialNavigator.prototype */ _getDistanceFunction: function snGetDistanceFunction(targetRect) { return { /* Plumb Line: a vertical line through the center of the targetRect. */ nearPlumbLineIsBetter: function(rect) { var d; if (rect.center.x < targetRect.center.x) { d = targetRect.center.x - rect.right; } else { d = rect.left - targetRect.center.x; } return d < 0 ? 0 : d; }, /* Horizon: a horizontal line through the center of the "targetRect". */ nearHorizonIsBetter: function(rect) { var d; if (rect.center.y < targetRect.center.y) { d = targetRect.center.y - rect.bottom; } else { d = rect.top - targetRect.center.y; } return d < 0 ? 0 : d; }, /* Target Left: a coincident line of the left edge of the "targetRect". */ nearTargetLeftIsBetter: function(rect) { var d; if (rect.center.x < targetRect.center.x) { d = targetRect.left - rect.right; } else { d = rect.left - targetRect.left; } return d < 0 ? 0 : d; }, /* Target Top: a coincident line of the top edge of the "targetRect". */ nearTargetTopIsBetter: function(rect) { var d; if (rect.center.y < targetRect.center.y) { d = targetRect.top - rect.bottom; } else { d = rect.top - targetRect.top; } return d < 0 ? 0 : d; }, /* top, bottom, left, and right: Just ranking by absolute coordinate without respect to targetRect. Usually they are used as fallback rules when ranks above are draw. */ topIsBetter: function(rect) { return rect.top; }, bottomIsBetter: function(rect) { return -1 * rect.bottom; }, leftIsBetter: function(rect) { return rect.left; }, rightIsBetter: function(rect) { return -1 * rect.right; } }; }, /** * PrioritySet contains a set of elements with distance functions that * should be used to rank them (obtained from {@link * SpatialNavigator#_getDistanceFunction}). * * @typedef PrioritySet * @property {Array.} group * {@link Rects} of elements that need to be prioritized. * @property {Array.} distance * Distance functions. Primary ranking rule should be put in index * 0; secondary in index 1 (fallback rule when primary rule draws * ); and so on. * @access private * @memberof SpatialNavigator.prototype */ /** * Pick a {@link Rect} with highest priority. * * @param {Array.} priorities * An array of {@link PrioritySet} that need to be prioritized. The * set with lowest index containing non-empty {PrioritySet.group} * would be prioritized. * @param {SpatialNavigatorElement} target * The origin of coordinates for traversal. * @param {String} direction * It should be "left", "right", "up" or "down". * * @return {Rect} the {@link Rect} of highest priority. * * @access private * @memberof SpatialNavigator.prototype */ _prioritize: function snPrioritize(priorities, target, direction) { var destPriority = priorities.find(function(p) { return !!p.group.length; }); if (!destPriority) { return null; } if (this.rememberSource && this._previous && target == this._previous.destination && direction == this._previous.reverse) { var source = this._previous.source; var found = destPriority.group.find(function(dest) { return dest.element == source; }); if (found) { return found; } } destPriority.group.sort(function(a, b) { return destPriority.distance.reduce(function(answer, distance) { return answer || (distance(a) - distance(b)); }, 0); }); return destPriority.group[0]; }, /** * Replace the set of traversable elements. * * @param {Array.} [collection=[]] elements to be replaced. The array is deep-copied and never be changed directly by SpatialNavigator. * * @fires SpatialNavigator#unfocus * @memberof SpatialNavigator.prototype */ setCollection: function snSetCollection(collection) { this.unfocus(); this._collection = []; if (collection) { this.multiAdd(collection); } }, /** * Add an element to traversable set. * * @param {SpatialNavigatorElement} elem * @return {Boolean} true if succeed. * * @memberof SpatialNavigator.prototype */ add: function snAdd(elem) { var index = this._collection.indexOf(elem); if (index >= 0) { return false; } this._collection.push(elem); return true; }, /** * Add a bunch of elements to traversable set. * * @param {Array.} elements * @return {Boolean} true if all elements are added successfully. * * @memberof SpatialNavigator.prototype */ multiAdd: function snMultiAdd(elements) { return Array.from(elements).every(this.add, this); }, /** * Remove an element from traversable set. * * @param {SpatialNavigatorElement} elem * @return {Boolean} true if succeed. false if elem does not exist. * * @fires SpatialNavigator#unfocus * @memberof SpatialNavigator.prototype */ remove: function snRemove(elem) { var index = this._collection.indexOf(elem); if (index < 0) { return false; } if (this._focus === elem) { this.unfocus(); } this._collection.splice(index, 1); return true; }, /** * Remove a bunch of elements to traversable set. * * @param {Array.} elements * @return {Boolean} true if all elements are removed successfully. * * @memberof SpatialNavigator.prototype */ multiRemove: function snMultiRemove(elements) { return Array.from(elements).every(this.remove, this); }, /** * Move focus to an existing element. * * @param {SpatialNavigatorElement} [elem] * when omitted, it focuses the last focused element or the first * navigable element if no previously-focused element is found. * * @return {Boolean} true if succeed. false if element doesn't exist. * * @fires SpatialNavigator#focus * @fires SpatialNavigator#unfocus * @memberof SpatialNavigator.prototype */ focus: function snFocus(elem) { // If no arguments are received, let current focused element be the // default. if (!elem && this._focus && this._isNavigable(this._focus)) { elem = this._focus; } if (!this._collection) { return false; } if (!elem) { var navigableElems = this._collection.filter(this._isNavigable, this); if (!navigableElems.length) { return false; } elem = navigableElems[0]; } else if (this._collection.indexOf(elem) < 0 || !this._isNavigable(elem)) { return false; } this.unfocus(); this._focus = elem; if (!this.silent) { this.fire('focus', this._focus); } return true; }, /** * Remove focus if any. * * It will trigger "unfocus" event. * * @return {Boolean} succeed or not. * * @fires SpatialNavigator#unfocus * @memberof SpatialNavigator.prototype */ unfocus: function snUnfocus() { if (this._focus) { var elem = this._focus; this._focus = null; if (!this.silent) { this.fire('unfocus', elem); } } return true; }, /** * Get the currently focused element. * * @return {SpatialNavigatorElement} or null if nothing focused. * * @memberof SpatialNavigator.prototype */ getFocusedElement: function snGetFocusedElement() { return this._focus; }, /** * Given a direction, find the element nearest to the focus element in that * direction. This is equivalent to {@link SpatialNavigator#navigate} with * focused element passed as target. * * @param {String} direction * It should be "left", "right", "up" or "down". * * @return {Boolean} true if succeed, false if nothing can be focused. * * @memberof SpatialNavigator.prototype */ move: function snMove(direction) { var reverse = { 'left': 'right', 'up': 'down', 'right': 'left', 'down': 'up' }; if (!this._focus) { this._previous = null; this.focus(); } else { var elem = this.navigate(this._focus, direction); if (!elem) { return false; } if (this.rememberSource) { this._previous = { source: this._focus, destination: elem, reverse: reverse[direction.toLowerCase()] }; } this.unfocus(); this.focus(elem); } return true; }, /** * Given a direction, find an element nearest to the target element in that * direction. * * @param {SpatialNavigatorElement} target * The origin of coordinates for traversal. * @param {String} direction * It should be "left", "right", "up" or "down". * * @return {SpatialNavigatorElement} * The destination of the element which has the highest priority. * * @memberof SpatialNavigator.prototype */ navigate: function snNavigate(target, direction) { if (!target || !direction || !this._collection) { return null; } direction = direction.toLowerCase(); var rects = this._getAllRects(target); var targetRect = this._getRect(target); if (!targetRect || !rects.length) { return null; } /* Get distance functions for ranking priorities relative to targetRect */ var distanceFunction = this._getDistanceFunction(targetRect); /* Candidate {@link Rect}s are divided into nine regions based on its position with respect to targetRect. */ var groups = this._partition(rects, targetRect); /* {@link Rect}s in group 4 overlaps with targetRect. We distribute them further into 9 regions based on its position with respect to the center point of targetRect. */ var internalGroups = this._partition(groups[4], targetRect.center); /* priorities: This big array carrys candidate elements with related * distance functions by appropriate priority we want. Depenging on the * direction, 3 kinds of elements are added separately in the following * order: * * - 1st: candidates centered inside targetRect (group 4) * (so we pick up corresponding internalGroups). * - 2nd: in groups of straight direction (group 1, 3, 5, 7). * - 3rd: in groups of oblique direction (group 0, 2, 6, 8). * * For each kind of element above, ranking is performed by the following * rules (distance functions) in order: * * - 1st: distance between candidate and target. * - 2nd: absolute coordinate of candidates. * - 3rd: distance of left or top coordinate between candidate and * target (for oblique direction only) * * The switch...case block below is just to construct this array. * We just pick the required order into array here, then call * {SpatialNavigator#_prioritize} to do the trick. */ var priorities; switch (direction) { case 'left': priorities = [ { group: internalGroups[0].concat(internalGroups[3]) .concat(internalGroups[6]), distance: [ distanceFunction.nearPlumbLineIsBetter, distanceFunction.topIsBetter ] }, { group: groups[3], distance: [ distanceFunction.nearPlumbLineIsBetter, distanceFunction.topIsBetter ] }, { group: groups[0].concat(groups[6]), distance: [ distanceFunction.nearHorizonIsBetter, distanceFunction.rightIsBetter, distanceFunction.nearTargetTopIsBetter ] } ]; break; case 'right': priorities = [ { group: internalGroups[2].concat(internalGroups[5]) .concat(internalGroups[8]), distance: [ distanceFunction.nearPlumbLineIsBetter, distanceFunction.topIsBetter ] }, { group: groups[5], distance: [ distanceFunction.nearPlumbLineIsBetter, distanceFunction.topIsBetter ] }, { group: groups[2].concat(groups[8]), distance: [ distanceFunction.nearHorizonIsBetter, distanceFunction.leftIsBetter, distanceFunction.nearTargetTopIsBetter ] } ]; break; case 'up': priorities = [ { group: internalGroups[0].concat(internalGroups[1]) .concat(internalGroups[2]), distance: [ distanceFunction.nearHorizonIsBetter, distanceFunction.leftIsBetter ] }, { group: groups[1], distance: [ distanceFunction.nearHorizonIsBetter, distanceFunction.leftIsBetter ] }, { group: groups[0].concat(groups[2]), distance: [ distanceFunction.nearPlumbLineIsBetter, distanceFunction.bottomIsBetter, distanceFunction.nearTargetLeftIsBetter ] } ]; break; case 'down': priorities = [ { group: internalGroups[6].concat(internalGroups[7]) .concat(internalGroups[8]), distance: [ distanceFunction.nearHorizonIsBetter, distanceFunction.leftIsBetter ] }, { group: groups[7], distance: [ distanceFunction.nearHorizonIsBetter, distanceFunction.leftIsBetter ] }, { group: groups[6].concat(groups[8]), distance: [ distanceFunction.nearPlumbLineIsBetter, distanceFunction.topIsBetter, distanceFunction.nearTargetLeftIsBetter ] } ]; break; default: return null; } if (this.straightOnly) { // Ignore candidates in oblique direction. priorities.pop(); } var dest = this._prioritize(priorities, target, direction); if (!dest) { return null; } return dest.element; } }); exports.SpatialNavigator = SpatialNavigator; })(window);