gtvzone
Version:
Jquery google tv adapcion, libreria para desarrollo de aplicaciones para smart-TV
1,551 lines (1,361 loc) • 57.4 kB
JavaScript
// Copyright 2010 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Classes for KeyBehaviorZone, KeyZoneLayer, KeyController.
* A page uses a KeyController to manage the selection of elements and make
* sure that only one element is selected at a time. The controller can
* manage selection between multiple logical areas of the page, or "zones".
*
* Each KeyBehaviorZone can have its own distinct behaviors for keys
* (including arrow keys), track entry/exit, make sure that the selected item
* is visible. The controller does this for the control so it does not have
* to listen for keyDown events, etc, on its on.
*
* Multiple layers can be defined in the controller representing a logical
* z-axis separation of elements. All controls in a layer are managed
* independently, but only one layer can be active at a time and have a
* selected item.
*
* In general, compound controls define a zone by their bounds, define any
* keys to override or selection behaviors (e.g., handling wrap-around by
* by overriding the left/right or up/down keys, and returning the left item
* when there is no item to select when the right arrow key is pressed.)
*
* The KeyController handles most behaviors automatically, including arrow
* keys, mouseenter, etc.
*
*
*/
module.exports = function(gtv, $) {
/**
* KeyZoneCreationParams class. Holds params used to initialize a new
* KeyBehaviorZone.
* @constructor
*/
gtv.jq.KeyZoneCreationParams = function() {};
/**
* jQuery selector that uniquely identifies the containing zone on the page.
* @type string
*/
gtv.jq.KeyZoneCreationParams.prototype.containerSelector = null;
/**
* Mapping of key code numbers to callback methods.
* @type gtv.jq.KeyMapping
*/
gtv.jq.KeyZoneCreationParams.prototype.keyMapping = null;
/**
* Callbacks for supported Key Controller actions.
* @type gtv.jq.KeyActions
*/
gtv.jq.KeyZoneCreationParams.prototype.actions = null;
/**
* A collection of jQuery selectors for non-geometry key navigation.
* @type gtv.jq.KeyNavSelectors
*/
gtv.jq.KeyZoneCreationParams.prototype.navSelectors = null;
/**
* CSS classes to determine how to highlight a particular selected item.
* @type gtv.jq.KeySelectionCssClasses
*/
gtv.jq.KeyZoneCreationParams.prototype.selectionClasses = null;
/**
* Optional string that, if found in the element's jQuery.data(), will
* cause the item to be highlighted with the hasData selection class.
* @type string
*/
gtv.jq.KeyZoneCreationParams.prototype.navigableData = null;
/**
* If true, a multi-row zone will save the selected item for each row,
* and return selection to that item when selection returns to that row.
* @type boolean
*/
gtv.jq.KeyZoneCreationParams.prototype.saveRowPosition = null;
/**
* If true, the key controller determines the next selected item by
* examining the page and finding the closest item in the
* direction the user has navigated.
* @type boolean
*/
gtv.jq.KeyZoneCreationParams.prototype.useGeometry = null;
/**
* If true, the key controller will move the selection to a non-visible
* element on the page. (The zone would likely supply a scrollIntoView
* callback that made the item visible when selected.)
* @type boolean
*/
gtv.jq.KeyZoneCreationParams.prototype.selectHidden = null;
/**
* KeyMapping class. Holds key mappings to be called by the key controller
* when a key event is received. Each entry should be numbered by the keycode
* (e.g., ENTER = 13) paired with a callback.
* @constructor
*/
gtv.jq.KeyMapping = function() {};
/**
* Key mapping callbacks should be numbered by keycode, for example, passing:
* var keyMapping = { 13: enterCallback };
* will cause the function 'enterCallback' to be called when the ENTER key
* is pressed.
* @param {jQuery.Element} selectedItem The currently selected item. May be
* undefined/null if there is no currently selected item.
* @param {jQuery.Element} newSelected A candidate item to be the next
* selected item. For movement keys (e.g., arrows) this will be set
* unless the controller cannot find a candidate item in the direction
* of the arrow movement. For other keys, it will be unset.
* @return {gtv.jq.Selection} A Selection object telling the key controller
* what to do in response to the callback (may be to ignore the result,
* skip further processing, or change the selected item; see
* gtv.jq.Selection for details).
*/
gtv.jq.KeyMapping.prototype.keyCallback = null;
/**
* KeyActions class. Provides the key controller zone with a set of callbacks
* to make when certain events happen in the key controller.
* @constructor
*/
gtv.jq.KeyActions = function() {};
/**
* Called when a zone is entered. This is called in descending order from
* a parent to child zones. Optionally returns a the item to be selected
* after the zone is entered. This is only honored if the zone is at the
* end of the parent-child heirarchy.
* @type Function()
*/
gtv.jq.KeyActions.prototype.enterZone = null;
/**
* Called when a zone is exited. This is called in ascending order from
* child to parent zones.
* @type Function(selectedItem)
* @param {jQuery.Element} selectedItem The selected item in the zone.
*/
gtv.jq.KeyActions.prototype.leaveZone = null;
/**
* Called every time an item in the zone receives the selection. This method
* must determine if the selected item is in view and, if it is, position it
* such that it is completely visible on the page. If the item is already
* visible, for performance reasons it is best to do nothing and return
* immediately.
* @type Function(newZone, newSelected, syncCallback)
* @param {gtv.jq.KeyBehaviorZone} newZone The zone where the item resides.
* @param {jQuery.Element} newSelected The item to be selected.
* @param {Function} syncCallback Function to call when any movement
* animation related to moving the item into view is completed. The
* key controller will wait for these animations to complete before
* processing further event input.
*/
gtv.jq.KeyActions.prototype.scrollIntoView = null;
/**
* Called after the selection has moved to a new item in the zone.
* @type Function(selectedItem, newSelected)
* @param {jQuery.Element} selectedItem The currently selected item (that
* selection is moving away from).
* @param {jQuery.Element} newSelected Item that selection is moving to.
*/
gtv.jq.KeyActions.prototype.moveSelected = null;
/**
* Called when the user clicks the mouse button on an item.
* @type Function(item)
* @param {jQuery.Element} item The item clicked on.
*/
gtv.jq.KeyActions.prototype.click = null;
/**
* KeyNavSelectors class.
* @constructor
*/
gtv.jq.KeyNavSelectors = function() {};
/**
* The jQuery selector to use to determine if an element is an item, that is,
* a navigable element on the page.
* @type string
*/
gtv.jq.KeyNavSelectors.prototype.item = null;
/**
* The jQuery selector to use to determine if an element is the immediate
* container of an 'item'
* @type string
*/
gtv.jq.KeyNavSelectors.prototype.itemParent = null;
/**
* The jQuery selector to use to determine if an element is the parent of
* an item parent. All itemParent children of a row are navigated horizontally,
* that is, with the left/right arrows. Rows themselves are navigated
* vertically (up/down arrows).
* @type string
*/
gtv.jq.KeyNavSelectors.prototype.itemRow = null;
/**
* The jQuery selector to use to determine if an element represents a page of
* itemRows or itemParents. Used to segment items to support paging.
* @type string
*/
gtv.jq.KeyNavSelectors.prototype.itemPage = null;
/**
* KeySelectionCssClasses class. A collection of CSS classes for highlighting
* selected items of various states
* @constructor
*/
gtv.jq.KeySelectionCssClasses = function() {};
/**
* Used to highlight items that do not meet the criteria for any other
* selection class. At present, this means that if an item does not have
* a jQuery.data() value for zone's navigableData string, this class will
* be used to highlight.
* @type string
*/
gtv.jq.KeySelectionCssClasses.prototype.basic = null;
/**
* Used to highlight items that have a jQuery.data() value for the zone's
* navigableData string. That is, if navigableData is 'destUrl', then any
* element where element.data('destUrl') != undefined will be highlighted
* with this CSS class.
* @type string
*/
gtv.jq.KeySelectionCssClasses.prototype.hasData = null;
/**
* KeyBehaviorZone class. Defines a zone of key navigation rules.
* @param {gtv.jq.KeyZoneCreationParams} createParams Initialization data for
* the new zone.
* @constructor
*/
gtv.jq.KeyBehaviorZone = function(createParams) {
this.params = createParams;
this.params.keyMapping = this.params.keyMapping || {};
this.params.actions = this.params.actions || {};
this.params.navSelectors = this.params.navSelectors || {};
this.params.selectionClasses = this.params.selectionClasses || {};
};
/**
* Representation of the return code from key nav callback functions.
* @param {string} status One of:
* none Continue to process the event.
* skip Stop processing the event.
* selected Replace the selected item with the one returned.
* @param {jQuery.Element} selected The item to use as the newly selected item.
*/
gtv.jq.Selection = function(status, selected) {
this.status = status;
this.selected = selected;
};
/**
* KeyController class. Manages navigation for a page.
* @constructor
*/
gtv.jq.KeyController = function() {
this.selectedItem_ = null;
this.zoneLayers_ = [];
this.currentZone_ = null;
this.globalKeyMapping_ = {};
this.moving_ = false;
this.started_ = false;
this.activeLayer_ = null;
this.zoneLayers_['default'] = new gtv.jq.KeyZoneLayer_(0);
};
/**
* Creates a layer of the specified name and priority. If priority isn't needed,
* callers can instead rely on the implicit layer creation when adding a zone.
* @param {string} layerName The name of the layer.
* @param {?number} priority Priority of the layer (presently unused).
* @return {KeyZoneLayer_} The newly created layer.
*/
gtv.jq.KeyController.prototype.createLayer = function(layerName, priority) {
var keyController = this;
if (keyController.zoneLayers_[layerName]) {
return keyController.zoneLayers_[layerName];
}
var zoneLayer = new gtv.jq.KeyZoneLayer_(priority);
keyController.zoneLayers_[layerName] = zoneLayer;
return zoneLayer;
};
/**
* Deletes the named layer. If the layer is the currently active one, the
* default layer is selected as a replacement and selection is moved to the
* first zone.
* @param {string} layerName The name of the layer to delete.
* @return {boolean} False if the layer doesn't exist or the caller is trying
* to delete the default layer. True otherwise.
*/
gtv.jq.KeyController.prototype.deleteLayer = function(layerName) {
var keyController = this;
if (!layerName || layerName == 'default') {
return false;
}
var zoneLayer = keyController.zoneLayers_[layerName];
if (!zoneLayer) {
return false;
}
if (keyController.activeLayer_ == layerName) {
keyController.moveSelected_();
keyController.activeLayer_ = 'default';
keyController.setZone(
keyController.zoneLayers_['default'].behaviorZones[0],
true
);
}
keyController.zoneLayers_[layerName] = null;
return true;
};
/**
* Removes all layers from the Key Controller.
*/
gtv.jq.KeyController.prototype.removeAllLayers = function() {
var keyController = this;
for (var layer in keyController.zoneLayers_) {
keyController.deleteLayer(layer);
}
};
/**
* Sets the specified item as the selected item. The item can be in any zone.
* @param {jQuery.Element} newSelected The item to move selection to.
* @param {Function} finishedCallback The function to call when any movement
* animations have completed.
*/
gtv.jq.KeyController.prototype.setSelected = function(
newSelected,
finishedCallback
) {
var keyController = this;
var syncCallback = new gtv.jq.SynchronizedCallback(finishedCallback);
var getCallback = syncCallback.getCallback();
var animFinishedAction = getCallback();
keyController.moveSelected_(null, newSelected, animFinishedAction);
syncCallback.done();
};
/**
* Makes the specified layer the active one, and moves selection to the first
* zone in that layer.
* @param {?string} opt_layerName The name of the layer to active. If not,
* supplied activates the default layer.
*/
gtv.jq.KeyController.prototype.setLayer = function(opt_layerName) {
var keyController = this;
var layerName = opt_layerName || 'default';
var zoneLayer = keyController.zoneLayers_[layerName];
if (keyController.activeLayer_ != layerName) {
keyController.activeLayer_ = layerName;
keyController.setZone(zoneLayer.behaviorZones[0], true);
}
};
/**
* Sets a key mapping for the global key controller.
* @param {KeyMap} keyMapping The key mapping to install.
*/
gtv.jq.KeyController.prototype.setGlobalKeyMapping = function(keyMapping) {
this.globalKeyMapping_ = {};
this.globalKeyMapping_ = keyMapping || {};
};
/**
* Sets a key mapping for specified layer.
* @param {KeyMap} keyMapping The key mapping to install in the layer.
* @param {?string} opt_layerName The name of the layer to install the mapping.
* If not supplied, the default layer is used.
*/
gtv.jq.KeyController.prototype.setLayerKeyMapping = function(
keyMapping,
opt_layerName
) {
var keyController = this;
var layerName = opt_layerName || 'default';
var zoneLayer = keyController.zoneLayers_[layerName];
zoneLayer.setKeyMapping_(keyMapping);
};
/**
* Adds a new zone to a layer in the key controller.
* @param {KeyBehaviorZone} zone The zone to add.
* @param {?boolean} opt_selectOnInit If true, the zone will receive the
* selection if no other zone in the controller has the selection.
* @param {?Array.<string>} opt_layerNames The names of the layers to add the
* zone to. If not supplied, the default layer is used.
*/
gtv.jq.KeyController.prototype.addBehaviorZone = function(
zone,
opt_selectOnInit,
opt_layerNames,
cancelStopPropagation
) {
var keyController = this;
var layerNames = opt_layerNames || ['default'];
var selectOnInit = opt_selectOnInit;
for (var layer = 0; layer < layerNames.length; layer++) {
var zoneLayer = keyController.zoneLayers_[layerNames[layer]];
if (!zoneLayer) {
zoneLayer = keyController.createLayer(layerNames[layer]);
}
zoneLayer.behaviorZones.push(zone);
}
keyController.attachZone_(zone, false, cancelStopPropagation);
zone.layers = layerNames;
if (keyController.started_ && selectOnInit) {
keyController.setZone(zone);
}
};
/**
* Removes a zone from the key controller. If the active zone is removed,
* selection is moved to the next zone.
* @param {KeyBehaviorZone} zone The zone to remove.
*/
gtv.jq.KeyController.prototype.removeBehaviorZone = function(zone) {
var keyController = this;
for (var layer = 0; layer < zone.layers.length; layer++) {
var zoneLayer = keyController.zoneLayers_[zone.layers[layer]];
for (var i = 0; i < zoneLayer.behaviorZones.length; i++) {
if (zoneLayer.behaviorZones[i] == zone) {
zoneLayer.behaviorZones.splice(i, 1);
break;
}
}
}
if (zone == keyController.currentZone_) {
keyController.moveSelected_();
}
keyController.detachZone_(zone);
};
/**
* Installs an array of zones into a layer. Replaces any existing zones.
* @param {Array.<KeyBehaviorZone>} zones Array of zones to add.
* @param {?boolean} opt_selectOnInit If true, selects the first zone.
* @param {?Array.<string>} opt_layerNames The layer to install the zones into.
* If not supplied, uses the default layer.
*/
gtv.jq.KeyController.prototype.setZones = function(
zones,
opt_selectOnInit,
opt_layerNames
) {
var keyController = this;
var selectOnInit = opt_selectOnInit;
var layerNames = opt_layerNames || ['default'];
var zoneLayer = keyController.zoneLayers_[layerName];
zoneLayer.behaviorZones = zones;
for (var i = 0; i < zones.length; i++) {
zones[i].layers = layerNames;
keyController.attachZone_(zones[i], false);
}
if (selectOnInit && zones.length > 0) {
keyController.setZone(zoneLayer.behaviorZones[0]);
}
};
/**
* Removes all zones from a layer and returns the zones. Selection is moved to
* the next zone (thus activating a new layer), if available.
* @param {?string} opt_layerName The layer to remove the zones from. If not
* supplied, uses the default layer.
* @return {Array.<KeyBehaviorZone>} Array of zones removed.
*/
gtv.jq.KeyController.prototype.removeAllZones = function(opt_layerName) {
var keyController = this;
var layerName = opt_layerName || 'default';
var zoneLayer = keyController.zoneLayers_[layerName];
for (var i = 0; i < zoneLayer.behaviorZones.length; i++) {
keyController.detachZone_(zoneLayer.behaviorZones[i]);
if (zoneLayer.behaviorZones[i] == keyController.currentZone_) {
keyController.leaveZoneHeirarchy_(keyController.selectedItem_);
}
}
var zones = zoneLayer.behaviorZones;
zoneLayer.behaviorZones = [];
keyController.moveSelected_();
return zones;
};
/**
* Starts the Key Controller. Zones can be added to the controller both before
* and after it is started.
* @param {?KeyBehaviorZone} initialZone Optional initial zone to select. If not
* supplied, the first zone is selected.
* @param {?boolean} opt_selectOnInit If supplied, initialZone will be selected.
* @param {?string} opt_layerName If supplied, the specified layer will be the
* active layer. If both an initialZone and opt_layerName are supplied, the
* layer of initialZone will be active layer, and this value will be
* ignored.
* @return {boolean} True if the controller starts successfully.
*/
gtv.jq.KeyController.prototype.start = function(
initialZone,
opt_selectOnInit,
opt_layerName
) {
var keyController = this;
if (keyController.started_) return true;
var layerName = opt_layerName || 'default';
var selectOnInit = opt_selectOnInit;
if (!initialZone) {
keyController.activeLayer_ = layerName;
initialZone =
keyController.zoneLayers_[keyController.activeLayer_].behaviorZones[0];
} else {
keyController.currentZone_ = initialZone;
keyController.activeLayer_ = initialZone.layers[0];
}
$(document).bind('keydown.keycontroller', function(e) {
keyController.keyDown_(e);
});
if (selectOnInit) {
var items = $(
initialZone.params.containerSelector +
' ' +
initialZone.params.navSelectors.item
);
items.first().mouseenter();
}
keyController.started_ = true;
return true;
};
/**
* Stops all key controller activity. Unbinds event handlers and removes all
* layers.
*/
gtv.jq.KeyController.prototype.stop = function() {
var keyController = this;
keyController.removeAllLayers();
$(document).unbind('.keycontroller');
keyController.started_ = false;
};
/**
* Moves the selection to the specified zone only if there is not already a
* zone with selection.
* @param {KeyBehaviorZone} newZone The zone to move selection to.
* @param {boolean} opt_force Set the zone even if there is already an active
* zone.
* @return {boolean} True if selection moved, false otherwise.
*/
gtv.jq.KeyController.prototype.setZone = function(newZone, opt_force) {
var keyController = this;
var zoneInLayer = false;
for (var layer = 0; layer < newZone.layers.length; layer++) {
if (keyController.activeLayer_ == newZone.layers[layer]) {
zoneInLayer = true;
break;
}
}
if (!opt_force && keyController.currentZone_ && zoneInLayer) {
// If there's already a currentZone, we're not forcing the new zone,
// and the currentZone is in the active layer, don't set the new zone
return false;
}
var newSelected = keyController.shiftZone_(newZone);
if (newSelected && newSelected.length > 0) {
if (!zoneInLayer) {
// Only change the active layer if the new zone does not exist
// in it.
keyController.activeLayer_ = newZone.layers[0];
}
keyController.moveSelected_(newZone, newSelected);
}
return true;
};
/***************************************************************************/
/*Private Properties and Methods********************************************/
/**
* KeyZoneLayer_ class defines a distinct layer of zones in the key controller.
* @param {?number} priority The priority of the zone. Presently unused.
* @private
* @constructor
*/
gtv.jq.KeyZoneLayer_ = function(priority) {
this.priority = priority || 0;
this.behaviorZones = new Array();
this.globalKeyMapping = {};
};
/**
* Priority of the zone, currently unused.
* @type number
* @protected
*/
gtv.jq.KeyZoneLayer_.prototype.priority = null;
/**
* Behavior zones that are members of this layer.
* @type number
* @protected
*/
gtv.jq.KeyZoneLayer_.prototype.behaviorZones = null;
/**
* Key mapping to use for all input in this layer.
* @type Object
* @protected
*/
gtv.jq.KeyZoneLayer_.prototype.globalKeyMapping = null;
/**
* Sets the key mapping for a layer. Outside callers should use the Key
* Controller's setLayerKeyMapping()
* @param {KeyMap} keyMapping The key mapping to install in the layer.
* @private
*/
gtv.jq.KeyZoneLayer_.prototype.setKeyMapping_ = function(keyMapping) {
this.globalKeyMapping = {};
this.globalKeyMapping = keyMapping || {};
};
/**
* The item on the page that is currently selected, if any.
* @type jQuery.Element
* @private
*/
gtv.jq.KeyController.prototype.selectedItem_ = null;
/**
* The currently active zone. The selectedItem, if set, is always in this zone.
* @type gtv.jq.KeyBehaviorZone
* @private
*/
gtv.jq.KeyController.prototype.currentZone_ = null;
/**
* The current key mapping set for the global page.
* @type Object
* @private
*/
gtv.jq.KeyController.prototype.globalKeyMapping_ = {};
/**
* Tracks animation in progress. While true, animations to scroll into view
* are active, and input events are ignored.
* @type boolean
* @private
*/
gtv.jq.KeyController.prototype.moving_ = null;
/**
* The current state of the KeyController. True if is started and listening
* for events.
* @type boolean
* @private
*/
gtv.jq.KeyController.prototype.started_ = null;
/**
* The currently active layer. Defaults to 'default'. Only this layer will
* process input events, get the selection, etc.
* @type gtv.jq.KeyZoneLayer_
* @private
*/
gtv.jq.KeyController.prototype.activeLayer_ = null;
/**
* The available layers in the controller. There is always the 'default' layer.
* @type Array.<gtv.jq.KeyZoneLayer_>
* @private
*/
gtv.jq.KeyController.prototype.zoneLayers_ = null;
/**
* Attaches a zone to the Key Controller, connecting events to the items in the
* container and numbering the items, rows and pages if necessary.
* @param {KeyBehaviorZone} zone The zone to attach
* @private
*/
gtv.jq.KeyController.prototype.attachZone_ = function(
zone,
unkwonVar,
cancelStopPropagation
) {
var keyController = this;
var items = $(
zone.params.containerSelector + ' ' + zone.params.navSelectors.item
);
items.bind('mouseenter.keycontroller', function(e) {
if (!keyController.moving_) {
for (var layer = 0; layer < zone.layers.length; layer++) {
if (zone.layers[layer] == keyController.activeLayer_) {
keyController.moveSelected_(null, $(this));
break;
}
}
}
e.stopPropagation();
});
items.bind('click.keycontroller', function(e) {
if (!keyController.moving_) {
for (var layer = 0; layer < zone.layers.length; layer++) {
if (zone.layers[layer] == keyController.activeLayer_) {
keyController.click_($(this));
break;
}
}
}
if (!cancelStopPropagation) {
e.preventDefault();
e.stopPropagation();
}
});
var pages = $(zone.params.containerSelector);
if (zone.params.navSelectors.itemPage) {
if (
!$(zone.params.containerSelector).is(zone.params.navSelectors.itemPage)
) {
pages = $(
zone.params.containerSelector +
' ' +
zone.params.navSelectors.itemPage
);
}
}
for (var i = 0; i < pages.length; i++) {
var pageRows = $(zone.params.containerSelector);
if (zone.params.navSelectors.itemRow) {
if (pages.eq(i).is(zone.params.navSelectors.itemRow)) {
pageRows = pages.eq(i);
} else {
pageRows = pages.eq(i).find(zone.params.navSelectors.itemRow);
}
}
for (var j = 0; j < pageRows.length; j++) {
var pageRowItems = pageRows.eq(j).find(zone.params.navSelectors.item);
for (var k = 0; k < pageRowItems.length; k++) {
if (pageRowItems.eq(k).data('index') == undefined) {
pageRowItems.eq(k).data('index', k);
}
}
}
}
};
/**
* Detaches a zone from the controller, unbinding event handlers from items.
* @param {KeyBehaviorZone} zone The zone to detach.
* @private
*/
gtv.jq.KeyController.prototype.detachZone_ = function(zone) {
var keyController = this;
var items = $(
zone.params.containerSelector + ' ' + zone.params.navSelectors.item
);
items.unbind('.keycontroller');
};
/**
* Finds the next zone in a layer. This method will only return a zone if it is
* a leaf zone (has no children); it traverses down parent zones to find the
* leaves and selects the next leaf.
* @param {KeyBehaviorZone} zone The current zone.
* @return {KeyBehaviorZone} The next zone, or null if the layer has only one
* zone.
* @private
*/
gtv.jq.KeyController.prototype.nextZone_ = function(zone) {
var keyController = this;
var zoneLayer = keyController.zoneLayers_[keyController.activeLayer_];
if (zoneLayer.behaviorZones.length <= 1) {
return null;
}
if (!zone) {
return null;
}
var parentZones = [];
var item = $(zone.params.containerSelector);
while (item.length) {
for (var j = 0; j < zoneLayer.behaviorZones.length; j++) {
var parentContainer = item.parent(
zoneLayer.behaviorZones[j].params.containerSelector
);
if (parentContainer.length != 0) {
parentZones.push(zoneLayer.behaviorZones[j]);
}
}
item = item.parent();
}
var newZone;
var zoneIndex;
for (var i = 0; i < zoneLayer.behaviorZones.length; i++) {
if (zoneLayer.behaviorZones[i] == zone) {
// This is the zone we're at, we want to find the next non-parent
zoneIndex = i;
} else if (zoneIndex != undefined) {
// zoneIndex is set, meaning we're actively looking for next non-parent
if ($.inArray(zoneLayer.behaviorZones[i], parentZones)) {
newZone = zoneLayer.behaviorZones[i];
break;
}
} else if (!newZone) {
// zoneIndex not set, find first non-parent to handle wrap-around case
if ($.inArray(zoneLayer.behaviorZones[i], parentZones)) {
newZone = zoneLayer.behaviorZones[i];
}
}
}
return newZone || zone;
};
/**
* Handles the keyDown event for the document where the Key Controller is
* started. This handler will return immediately if there is no current zone
* or if there is scrollIntoView animation in progress.
* @param {Event} e The keydown event from the browser
* @private
*/
gtv.jq.KeyController.prototype.keyDown_ = function(e) {
var DIRECTIONS = {
left: [-1, 0],
up: [0, -1],
right: [1, 0],
down: [0, 1]
};
var keyController = this;
if (!keyController.currentZone_ || keyController.moving_) {
return;
}
var direction;
var selectedIndex;
if (keyController.selectedItem_) {
selectedIndex = keyController.selectedItem_.data('index');
}
var rowIndex = selectedIndex;
var visibleSelector = ':visible';
if (keyController.currentZone_.params.selectHidden) {
visibleSelector = '';
}
// We only want to allow navigation to items that are visible.
var itemClass =
keyController.currentZone_.params.navSelectors.item + visibleSelector;
var itemParentClass =
keyController.currentZone_.params.navSelectors.itemParent +
visibleSelector;
var itemParentRowClass =
keyController.currentZone_.params.navSelectors.itemRow + visibleSelector;
var itemParentRowPageClass =
keyController.currentZone_.params.navSelectors.itemPage + visibleSelector;
var newZone;
var newSelected;
if (keyController.selectedItem_) {
if (keyController.selectedItem_.is('input[type=text]')) {
// If the selected item is an input box, we want special arrow key
// nav code.
if (keyController.selectedItem_.get(0) == document.activeElement) {
// If the input has focus and is not empty, ignore the left/right
// arrow keys for navigation, since the user probably wants to
// move around in the input box
if (
keyController.selectedItem_.val() &&
(e.keyCode == 37 || e.keyCode == 39 || e.keyCode == 8)
) {
return;
}
} else if (e.keyCode == 37 || e.keyCode == 39) {
// If the input does not have focus, allow navigation away from
// it, and prevent the key from reaching the input control
e.preventDefault();
}
} else if (keyController.selectedItem_.is('select')) {
// If the selected item is a dropdown box, eat all the navigation
// codes meant for it so we can navigate away instead of being
// stuck cycling through values in the box.
if (e.keyCode >= 37 && e.keyCode <= 40) {
e.preventDefault();
}
}
}
switch (e.keyCode) {
case 9: {
// TAB
newZone = keyController.nextZone_(keyController.currentZone_);
e.preventDefault();
break;
}
case 37: {
// left
if (!keyController.selectedItem_) break;
direction = DIRECTIONS.left;
if (itemParentClass) {
var parent = keyController.selectedItem_.parent();
newSelected = parent
.prevAll(itemParentClass)
.eq(0)
.find(itemClass);
} else {
newSelected = keyController.selectedItem_.prevAll(itemClass).eq(0);
}
break;
}
case 38: {
// up
if (!keyController.selectedItem_) break;
direction = DIRECTIONS.up;
if (!itemParentRowClass) {
break;
}
if (keyController.currentZone_.params.saveRowPosition) {
keyController.selectedItem_
.parents(itemParentRowClass)
.data('index', selectedIndex);
}
var parentRow = keyController.selectedItem_.parents(itemParentRowClass);
var newRow = parentRow.prevAll(itemParentRowClass).eq(0);
if (
keyController.currentZone_.params.saveRowPosition &&
newRow.length
) {
rowIndex = newRow.data('index');
}
while (rowIndex >= 0 && (!newSelected || newSelected.length == 0)) {
newSelected = newRow
.find(itemParentClass)
.eq(rowIndex)
.find(itemClass);
rowIndex -= 1;
}
break;
}
case 39: {
// right
if (!keyController.selectedItem_) {
break;
}
direction = DIRECTIONS.right;
if (itemParentClass) {
var _parent = keyController.selectedItem_.parent();
newSelected = _parent
.nextAll(itemParentClass)
.eq(0)
.find(itemClass);
} else {
newSelected = keyController.selectedItem_.nextAll(itemClass).eq(0);
}
break;
}
case 40: {
// down
if (!keyController.selectedItem_) {
break;
}
direction = DIRECTIONS.down;
if (!itemParentRowClass) {
break;
}
if (keyController.currentZone_.params.saveRowPosition) {
keyController.selectedItem_
.parents(itemParentRowClass)
.data('index', selectedIndex);
}
var parentRow = keyController.selectedItem_.parents(itemParentRowClass);
var newRow = parentRow.nextAll(itemParentRowClass).eq(0);
if (
keyController.currentZone_.params.saveRowPosition &&
newRow.length
) {
rowIndex = newRow.data('index');
}
while (rowIndex >= 0 && (!newSelected || newSelected.length == 0)) {
newSelected = newRow
.find(itemParentClass)
.eq(rowIndex)
.find(itemClass);
rowIndex -= 1;
}
break;
}
}
if (
keyController.currentZone_.params.useGeometry &&
e.keyCode >= 37 &&
e.keyCode <= 40
) {
newSelected = keyController.nearestElement_(
keyController.currentZone_,
keyController.selectedItem_,
direction
);
}
var keyAction;
// If there's a global mapping action for this key, call it.
keyAction = keyController.globalKeyMapping_[e.keyCode];
if (keyAction) {
e.preventDefault();
var result = keyAction(keyController.selectedItem_, newSelected);
if (result.status == 'skip') {
return;
} else if (result.status == 'selected') {
newSelected = result.selected;
}
}
// If there's a layer mapping action for this key, call it.
keyAction =
keyController.zoneLayers_[keyController.activeLayer_].globalKeyMapping[
e.keyCode
];
if (keyAction) {
e.preventDefault();
var result = keyAction(keyController.selectedItem_, newSelected);
if (result.status == 'skip') {
return;
} else if (result.status == 'selected') {
newSelected = result.selected;
}
}
// If the zone has a mapped action for this key, call it.
keyAction = keyController.currentZone_.params.keyMapping[e.keyCode];
if (keyAction) {
var result = keyAction(keyController.selectedItem_, newSelected);
if (result.status == 'skip') {
return;
} else if (result.status == 'selected') {
newSelected = result.selected;
}
}
keyController.processSelection_(newZone, newSelected, direction);
};
/**
* Interprets the results of keyDown processing and moves the selection as
* appropriate. May shift to a new zone if a new zone is provided or if the
* newly selected item is unset and a directional key was pressed.
* @param {KeyBehaviorZone} newZone The new zone to be selected. If this is
* null and newSelected is null and direction is specified, this method
* will look for a new zone in the direction of movement. Otherwise, it
* will assume the current zone.
* @param {jQuery.Element} newSelected The new item to move selection to. If
* null and direction is set, the value will be obtained either by moving
* into a proximate zone (if newZone is null) or by entering newZone.
* @param {Array.<number>} direction A two number array specifying the
* direction of movement, if any. The first number represents horizontal
* direction, the second vertical direction.
* @private
*/
gtv.jq.KeyController.prototype.processSelection_ = function(
newZone,
newSelected,
direction
) {
var keyController = this;
// If the selection resulted in a change of zone or the movement
// resulted in no new selected item, try moving to a new zone.
if (newZone || ((!newSelected || newSelected.length == 0) && direction)) {
if (!newZone) {
newZone = keyController.getNewZone_(
keyController.selectedItem_,
direction
);
}
if (newZone) {
if ($(newZone.params.containerSelector).is(':visible'))
newSelected = keyController.shiftZone_(newZone);
}
} else {
newZone = keyController.currentZone_;
}
// If after all that we have a new item to select, move the selection.
if (newSelected && newSelected.length > 0) {
keyController.moveSelected_(newZone, newSelected);
}
};
/**
* Leaves the current zone, calling a leaveZone action if the zone defines one,
* otherwise, sets the zone's last selected item to the specified selectedItem.
* @param {KeyBehaviorZone} zone The zone to leave.
* @param {jQuery.Element} selectedItem The item in the zone currently selected.
* @private
*/
gtv.jq.KeyController.prototype.leaveZone_ = function(zone, selectedItem) {
if (zone.params.actions.leaveZone) {
zone.params.actions.leaveZone(selectedItem);
} else {
zone.lastSelected = selectedItem;
}
};
/**
* Leaves a heirarchy of zones, that is, a zone with the current selection
* and then each parent zone in turn until there are no more parent zones.
* This ensures that the heirarchy of leaveZone actions are called when a
* child zone is left.
* @param {jQuery.Element} selectedItem Child zone item currently selected.
* @private
*/
gtv.jq.KeyController.prototype.leaveZoneHeirarchy_ = function(selectedItem) {
var keyController = this;
var zoneLayer = keyController.zoneLayers_[keyController.activeLayer_];
var item = selectedItem;
while (item.length) {
for (var i = 0; i < zoneLayer.behaviorZones.length; i++) {
var parentContainer = item.parent(
zoneLayer.behaviorZones[i].params.containerSelector
);
if (parentContainer.length != 0) {
keyController.leaveZone_(zoneLayer.behaviorZones[i], selectedItem);
selectedItem = null;
}
}
item = item.parent();
}
};
/**
* Enters the specified zone, calling the enterZone action if supplied.
* @param {KeyBehaviorZone} zone The zone to enter.
* @return {jQuery.Element} The item to select in the new zone as returned by
* the zone's enterZone action, or null if none.
* @private
*/
gtv.jq.KeyController.prototype.enterZone_ = function(zone) {
if (zone.params.actions.enterZone) {
return zone.params.actions.enterZone();
}
return null;
};
/**
* Enters a zone heirarchy, ensuring that the parents of a zone have their
* enterZone actions called when a child zone is entered.
* @param {jQuery.Element} selectedItem The iten to select in the child zone.
* @private
*/
gtv.jq.KeyController.prototype.enterZoneHeirarchy_ = function(selectedItem) {
var keyController = this;
var zoneLayer = keyController.zoneLayers_[keyController.activeLayer_];
var item = selectedItem;
while (item.length) {
for (var i = 0; i < zoneLayer.behaviorZones.length; i++) {
var parentContainer = item.parent(
zoneLayer.behaviorZones[i].params.containerSelector
);
if (parentContainer.length != 0) {
keyController.enterZone_(zoneLayer.behaviorZones[i]);
}
}
item = item.parent();
}
};
/**
* Leaves the current zone, if any.
* @private
*/
gtv.jq.KeyController.prototype.leaveCurrentZone_ = function() {
var keyController = this;
if (!keyController.currentZone_) {
return;
}
keyController.leaveZoneHeirarchy_(keyController.selectedItem_);
};
/**
* Shifts from the current zone to a new zone and determines the item to be
* selected.
* @param {KeyBehaviorZone} newZone The new zone to enter.
* @param {jQuery.Element} newSelected The item to be selected in the zone.
* @return {jQuery.Element} The new item to selected.
* @private
*/
gtv.jq.KeyController.prototype.shiftZone_ = function(newZone, newSelected) {
var keyController = this;
if (newZone.params.actions.enterZone) {
// If the new zone has an enterZoneAction, call it to enter the zone
newSelected = newZone.params.actions.enterZone();
} else if (
newZone.lastSelected &&
$.contains(
$(newZone.params.containerSelector).get(0),
newZone.lastSelected.get(0)
)
) {
// If the new zone has lastSelected set and the lastSelected item is
// still in that zone's container, set it as selected.
newSelected = newZone.lastSelected;
}
// If there was no zone enter action, or it didn't select a new item,
// select one naively, on its behalf.
if (!newSelected || newSelected.length == 0) {
newSelected = $(
newZone.params.containerSelector +
' ' +
newZone.params.navSelectors.item
).first();
}
return newSelected;
};
/**
* Finds the closest zone in the direction of movement from a specified item,
* and returns it.
* @param {jQuery.Element} fromItem The item selection is moving from.
* @param {Array.<number>} direction The direction of movement.
* @return {KeyBehaviorZone} The best choice for the zone in the direction
* of movement, or null if there are none.
* @private
*/
gtv.jq.KeyController.prototype.getNewZone_ = function(fromItem, direction) {
var keyController = this;
var zoneLayer = keyController.zoneLayers_[keyController.activeLayer_];
var minZoneDistance;
var newZone;
for (var i = 0; i < zoneLayer.behaviorZones.length; i++) {
var zone = zoneLayer.behaviorZones[i];
if (
zone == keyController.currentZone_ ||
!$(zone.params.containerSelector).is(':visible')
) {
continue;
}
var zoneContainer = $(zone.params.containerSelector);
var zoneDistance = keyController.calcElementDistance_(
fromItem,
zoneContainer,
direction
);
if (
zoneDistance >= 0 &&
(minZoneDistance == undefined || zoneDistance < minZoneDistance)
) {
minZoneDistance = zoneDistance;
newZone = zone;
}
}
return newZone;
};
/**
* Determines the visually "nearest" item in the zone, as laid out on the page,
* in the direction of movement.
* @param {KeyBehaviorZone} zone The zone to check.
* @param {jQuery.Element} fromItem The item being moved from.
* @param {Array.<number>} direction The direction of movement.
* @return {jQuery.Element} The item, or null if none qualify.
* @private
*/
gtv.jq.KeyController.prototype.nearestElement_ = function(
zone,
fromItem,
direction
) {
var keyController = this;
var minCheckItemDistance;
var newCheckItem;
var visibleSelector = ':visible';
if (zone.params.selectHidden) {
visibleSelector = '';
}
var items = $(
zone.params.containerSelector +
' ' +
zone.params.navSelectors.item +
visibleSelector
);
var r, il;
il = items.length;
for (var i = 0; i < il; i++) {
var checkItem = items.eq(i);
if (checkItem.get(0) == fromItem.get(0)) {
continue;
}
var checkItemDistance = keyController.calcElementDistance_(
fromItem,
checkItem,
direction
);
if (checkItemDistance > minCheckItemDistance) {
continue;
}
if (
checkItemDistance >= 0 &&
(minCheckItemDistance == undefined ||
checkItemDistance < minCheckItemDistance)
) {
minCheckItemDistance = checkItemDistance;
r = i;
}
}
newCheckItem = items.eq(r);
return newCheckItem;
};
/**
* Calculates a weighted Euclidean distance between two elements on the page
* in a given direction.
* @param {jQuery.Element} fromItem The start element.
* @param {jQuery.Element} toItem The destination element.
* @param {Array.<number>} direction The direction of movement.
* @return {number} The weighted Euclidean distance, or -1 if the toItem is
* not in the specified direction.
* @private
*/
gtv.jq.KeyController.prototype.calcElementDistance_ = function(
fromItem,
toItem,
direction
) {
function calcDistance(dx, dy) {
return Math.floor(Math.sqrt(dx * dx + dy * dy));
}
function calcOffset(elt) {
var rect = elt.getBoundingClientRect(),
bodyElt = document.body;
return {
top: rect.top + bodyElt.scrollTop,
left: rect.left + bodyElt.scrollLeft
};
}
var nativeFromItem = fromItem.get(0);
var fromItemOffset = calcOffset(nativeFromItem);
var fromItemLeft = fromItemOffset.left;
var fromItemTop = fromItemOffset.top;
var fromItemRight = fromItemLeft + nativeFromItem.offsetWidth;
var fromItemBottom = fromItemTop + nativeFromItem.offsetHeight;
var fromItemCenterX = fromItemLeft + nativeFromItem.offsetWidth / 2;
var fromItemCenterY = fromItemTop + nativeFromItem.offsetHeight / 2;
var nativeToItem = toItem.get(0);
var toItemOffset = calcOffset(nativeToItem);
var toItemLeft = toItemOffset.left;
var toItemTop = toItemOffset.top;
var toItemRight = toItemLeft + nativeToItem.offsetWidth;
var toItemBottom = toItemTop + nativeToItem.offsetHeight;
var toItemCenterX = toItemLeft + nativeToItem.offsetWidth / 2;
var toItemCenterY = toItemTop + nativeToItem.offsetHeight / 2;
var toItemDistance;
var distanceX;
var distanceY;
if (direction[1] == 0) {
if (direction[0] < 0) {
if (toItemRight <= fromItemLeft) {
distanceX = fromItemLeft - toItemRight;
}
if (toItemCenterX <= fromItemLeft) {
if (distanceX != undefined) {
distanceX = Math.min(distanceX, fromItemLeft - toItemCenterX);
} else {
distanceX = fromItemLeft - toItemCenterX;
}
}
if (toItemRight <= fromItemLeft) {
if (distanceX != undefined) {
distanceX = Math.min(distanceX, fromItemLeft - toItemRight);
} else {
distanceX = fromItemLeft - toItemRight;
}
}
} else {
if (fromItemRight <= toItemLeft) {
distanceX = toItemLeft - fromItemRight;
}
if (fromItemRight <= toItemCenterX) {
if (distanceX != undefined) {
distanceX = Math.min(distanceX, toItemCenterX - fromItemRight);
} else {
distanceX = toItemCenterX - fromItemRight;
}
}
if (fromItemLeft < toItemLeft) {
if (distanceX != undefined) {
distanceX = Math.min(distanceX, toItemLeft - fromItemLeft);
} else {
distanceX = toItemLeft - fromItemLeft;
}
}
}
distanceY =
Math.min(
Math.abs(fromItemCenterY - toItemTop),
Math.abs(fromItemCenterY - toItemCenterY),
Math.abs(fromItemCenterY - toItemBottom)
) * 2;
} else if (direction[0] == 0) {
if (direction[1] < 0) {
if (toItemBottom <= fromItemTop) {
distanceY = fromItemTop - toItemBottom;
}
if (toItemCenterY <= fromItemTop) {
if (distanceY != undefined) {
distanceY = Math.min(distanceY, fromItemTop - toItemCenterY);
} else {
distanceY = fromItemTop - toItemCenterY;
}
}
if (toItemBottom <= fromItemTop) {
if (distanceY != undefined) {
distanceY = Math.min(distanceY, fromItemTop - toItemBottom);
} else {
distanceY = fromItemTop - toItemBottom;
}
}
} else {
if (fromItemBottom <= toItemTop) {
distanceY = toItemTop - fromItemBottom;
}
if (fromItemBottom <= toItemCenterY