// # The MIT License (MIT) // # // # Copyright (c) 2016 Microsoft. All rights reserved. // # // # Permission is hereby granted, free of charge, to any person obtaining a copy // # of this software and associated documentation files (the "Software"), to deal // # in the Software without restriction, including without limitation the rights // # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // # copies of the Software, and to permit persons to whom the Software is // # furnished to do so, subject to the following conditions: // # // # The above copyright notice and this permission notice shall be included in // # all copies or substantial portions of the Software. // # // # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // # THE SOFTWARE. //High Level // -Provides direct APIs to navigate focus to each cardinal direction relative // to the current focused element within a specified container // -Directly listens to configurable key inputs and invokes navigation // automatically // -Coordinates with XYFocus implementations within iframes for seamless focus // navigation into and out of an iframe //Navigation Algorithm // -Navigation API is called with a given direction // -Source element is established, usually the current focused element // -All elements within the focus root (document.body by default) are gathered // -Each element's distance from the source element is measured // -Each element is assigned a score value which takes the following into // account, in order of importance: // -The relative size and alignment difference between the element // and history rectangle // -The distance between the element and source element along the // main axis // -The relative size and alignment difference between the element // and source element // -The distance the element and source element along the co- axis // -The highest scoring element is resolved which is the candidate for navigation // -NOTE: a more in -depth summary of this algorithm can be found in the actual // source code which has been well- maintained as the code evolved //Customizations // The focus request can be customized by the following: // -Specific source rectangle/ element, algorithm calculates from this // rectangle instead of the current focused element // -A focus root, only elements within the focus root are considered as // potential targets // -A history rectangle, which heavily favors potential target elements that // are aligned and of similar size to the history rectangle // -Any focusable element can be also annotated with a // "data-tv-focus='{ left: "#myDiv" }'" attribute.This attribute is referred // to as an XYFocus override.This allows you to directly control where focus // should move from this element, given the requested direction. //Automatic Focus // The XYFocus module can be configured to listen to specific hotkeys via // XYFocus.keyCodeMap.When a mapped key input is detected, it will automatically // invoke the above navigation algorithm.By default, the Xbox DPad and left thumbstick // keys are mapped.These can be cleared and new keys(e.g.arrow keys, WASD) can be added. //Considerations when authoring new controls // When authoring a new control, you may just let XYFocus handle all your focus navigation if the // focus navigation story is trivial. //Dealing with edge cases // Rule of thumb: Ignore it! One important thing to accept is that XYFocus is a heuristic-it is // not a general focus management solution.It works very well in orthogonal, grid - like layouts // and will fail utterly outside of them. // -There are numerous layouts that can break the XYFocus' heuristic for determining the next // focusable element.Most of these are unnatural and contrived layouts where buttons are // purposely misaligned and overlapping.Historically, we have ignored these issues. // -If a valid edge case is found, we handle it on a case-by -case basis.In most cases, // leveraging the XYFocus override is good enough. // -If anything more fundamental with the heuristics are found (which has not happened since // XYFocus was handed to WinJS by Xbox), consider tweaking the scoring constants - this is the // most EXTREME case. // -Another category of edge cases revolves around history focus.These have also been historically // ignored as no real app has produced any valid layout that triggers these issues. // One common example here is that you could have a list of buttons so long that the score from // the primary axis trumps the history score, however, you'd need 10,000s of pixels of consecutive // buttons which is unrealistic as a focus movement likely will trigger a scroll which invalidates history. (function () { "use strict"; var CrossDomainMessageConstants = { messageDataProperty: "msWinJSXYFocusControlMessage", register: "register", unregister: "unregister", dFocusEnter: "dFocusEnter", dFocusExit: "dFocusExit" }; var DirectionNames = { left: "left", right: "right", up: "up", down: "down" }; var EventNames = { focusChanging: "focuschanging", focusChanged: "focuschanged" }; var FocusableTagNames = [ "A", "BUTTON", "IFRAME", "INPUT", "SELECT", "TEXTAREA", "X-MS-WEBVIEW" ]; var FocusableSelectors = []; // These factors can be tweaked to adjust which elements are favored by the focus algorithm var ScoringConstants = { primaryAxisDistanceWeight: 30, secondaryAxisDistanceWeight: 20, percentInHistoryShadowWeight: 100000 }; /** * Gets the mapping object that maps keycodes to XYFocus actions. **/ var _keyCodeMap = { left: [], right: [], up: [], down: [], accept: [] }; var _enabled = true; /** * Gets or sets the focus root when invoking XYFocus APIs. **/ var _focusRoot; function _findNextFocusElement(direction, options) { var result = _findNextFocusElementInternal(direction, options); return result ? result.target : null; } function _moveFocus(direction, options) { var result = _findNextFocusElement(direction, options); if (result) { var previousFocusElement = document.activeElement; if (_trySetFocus(result, -1)) { eventSrc.dispatchEvent(EventNames.focusChanged, { previousFocusElement: previousFocusElement, keyCode: -1 }); return result; } } return null; } function _mergeAll(list) { // Merge a list of objects together var o = {}; list.forEach(function (part) { Object.keys(part).forEach(function (k) { o[k] = part[k]; }); }); return o; }; function _createEventProperty(name) { var eventPropStateName = "_on" + name + "state"; return { get: function () { var state = this[eventPropStateName]; return state && state.userHandler; }, set: function (handler) { var state = this[eventPropStateName]; if (handler) { if (!state) { state = { wrapper: function (evt) { return state.userHandler(evt); }, userHandler: handler }; Object.defineProperty(this, eventPropStateName, { value: state, enumerable: false, writable: true, configurable: true }); this.addEventListener(name, state.wrapper, false); } state.userHandler = handler; } else if (state) { this.removeEventListener(name, state.wrapper, false); this[eventPropStateName] = null; } }, enumerable: true }; }; // Privates var _lastTarget; var _cachedLastTargetRect; var _historyRect; /** * Executes XYFocus algorithm with the given parameters. Returns true if focus was moved, false otherwise. * @param direction The direction to move focus. * @param keyCode The key code of the pressed key. * @param referenceRect (optional) A rectangle to use as the source coordinates for finding the next focusable element. * @param dontExit (optional) Indicates whether this focus request is allowed to propagate to its parent if we are in iframe. **/ function _xyFocus(direction, keyCode, referenceRect, dontExit) { // If focus has moved since the last XYFocus movement, scrolling occured, or an explicit // reference rectangle was given to us, then we invalidate the history rectangle. if (referenceRect || document.activeElement !== _lastTarget) { _historyRect = null; _lastTarget = null; _cachedLastTargetRect = null; } else if (_lastTarget && _cachedLastTargetRect) { var lastTargetRect = _toIRect(_lastTarget.getBoundingClientRect()); // Sometimes the bounds calculation for top is off by 1 even though the element has not moved. if (lastTargetRect.left !== _cachedLastTargetRect.left || Math.abs(lastTargetRect.top - _cachedLastTargetRect.top) > 1) { _cachedLastTargetRect = lastTargetRect; } } var activeElement = document.activeElement; var lastTarget = _lastTarget; var result = _findNextFocusElementInternal(direction, { focusRoot: _focusRoot, historyRect: _historyRect, referenceElement: _lastTarget, referenceRect: referenceRect }); if (result && _trySetFocus(result.target, keyCode)) { // A focus target was found updateHistoryRect(direction, result); _lastTarget = result.target; _cachedLastTargetRect = result.targetRect; if (result.target.tagName === "IFRAME") { var targetIframe = result.target; if (IFrameHelper.isXYFocusEnabled(targetIframe)) { // If we successfully moved focus and the new focused item is an IFRAME, then we need to notify it // Note on coordinates: When signaling enter, DO transform the coordinates into the child frame's coordinate system. var refRect = _toIRect({ left: result.referenceRect.left - result.targetRect.left, top: result.referenceRect.top - result.targetRect.top, width: result.referenceRect.width, height: result.referenceRect.height }); var message = {}; message[CrossDomainMessageConstants.messageDataProperty] = { type: CrossDomainMessageConstants.dFocusEnter, direction: direction, referenceRect: refRect }; // postMessage API is safe even in cross-domain scenarios. targetIframe.contentWindow.postMessage(message, "*"); } } else if (typeof result.target.navigateFocus === "function") { // No need to adjust coordinate space of result.referenceRect. navigateFocus/departFocus and corresponding // events handle the coordinate space translation. result.target.navigateFocus(direction, WebViewHelper.refRectToNavigateFocusRect(result.referenceRect)); } eventSrc.dispatchEvent(EventNames.focusChanged, { previousFocusElement: activeElement, keyCode: keyCode }); return true; } else { // No focus target was found; if we are inside an IFRAME or webview and focus is allowed to propagate out, notify the parent that focus is exiting // Note on coordinates: When signaling exit, do NOT transform the coordinates into the parent's coordinate system. if ((!dontExit && top !== window) || typeof window.departFocus === "function") { var refRect = referenceRect; if (!refRect) { refRect = document.activeElement ? _toIRect(document.activeElement.getBoundingClientRect()) : _defaultRect(); } if (top === window && typeof window.departFocus === "function") { document.activeElement.blur(); departFocus(direction, WebViewHelper.refRectToNavigateFocusRect(refRect)); } else { var message = {}; message[CrossDomainMessageConstants.messageDataProperty] = { type: CrossDomainMessageConstants.dFocusExit, direction: direction, referenceRect: refRect }; // postMessage API is safe even in cross-domain scenarios. parent.postMessage(message, "*"); } return true; } } return false; // Nested Helpers function updateHistoryRect(direction, result) { var newHistoryRect = _defaultRect(); // It's possible to get into a situation where the target element has no overlap with the reference edge. // //..╔══════════════╗.......................... //..║ reference ║.......................... //..╚══════════════╝.......................... //.....................╔═══════════════════╗.. //.....................║ ║.. //.....................║ target ║.. //.....................║ ║.. //.....................╚═══════════════════╝.. // // If that is the case, we need to reset the coordinates to the edge of the target element. if (direction === DirectionNames.left || direction === DirectionNames.right) { newHistoryRect.top = Math.max(result.targetRect.top, result.referenceRect.top, _historyRect ? _historyRect.top : Number.MIN_VALUE); newHistoryRect.bottom = Math.min(result.targetRect.bottom, result.referenceRect.bottom, _historyRect ? _historyRect.bottom : Number.MAX_VALUE); if (newHistoryRect.bottom <= newHistoryRect.top) { newHistoryRect.top = result.targetRect.top; newHistoryRect.bottom = result.targetRect.bottom; } newHistoryRect.height = newHistoryRect.bottom - newHistoryRect.top; newHistoryRect.width = Number.MAX_VALUE; newHistoryRect.left = Number.MIN_VALUE; newHistoryRect.right = Number.MAX_VALUE; } else { newHistoryRect.left = Math.max(result.targetRect.left, result.referenceRect.left, _historyRect ? _historyRect.left : Number.MIN_VALUE); newHistoryRect.right = Math.min(result.targetRect.right, result.referenceRect.right, _historyRect ? _historyRect.right : Number.MAX_VALUE); if (newHistoryRect.right <= newHistoryRect.left) { newHistoryRect.left = result.targetRect.left; newHistoryRect.right = result.targetRect.right; } newHistoryRect.width = newHistoryRect.right - newHistoryRect.left; newHistoryRect.height = Number.MAX_VALUE; newHistoryRect.top = Number.MIN_VALUE; newHistoryRect.bottom = Number.MAX_VALUE; } _historyRect = newHistoryRect; } } function _findNextFocusElementInternal(direction, options) { options = options || {}; options.focusRoot = options.focusRoot || _focusRoot || document.body; options.historyRect = options.historyRect || _defaultRect(); var maxDistance = Math.max(window.screen.availHeight, window.screen.availWidth); var refObj = getReferenceObject(options.referenceElement, options.referenceRect); // Handle override var refElement = refObj.element; if (refElement) { var overrideSelector = refElement.getAttribute("data-tv-focus-" + direction); if (overrideSelector) { if (overrideSelector) { var target; var element = refObj.element; while (!target && element) { target = element.querySelector(overrideSelector); element = element.parentElement; } if (target) { if (target === document.activeElement) { return null; } return { target: target, targetRect: _toIRect(target.getBoundingClientRect()), referenceRect: refObj.rect, usedOverride: true }; } } } } // Calculate scores for each element in the root var bestPotential = { element: null, rect: null, score: 0 }; var allElements = options.focusRoot.querySelectorAll("*"); for (var i = 0, length = allElements.length; i < length; i++) { var potentialElement = allElements[i]; if (refObj.element === potentialElement || !_isFocusable(potentialElement)) { continue; } var potentialRect = _toIRect(potentialElement.getBoundingClientRect()); // Skip elements that have either a width of zero or a height of zero if (potentialRect.width === 0 || potentialRect.height === 0) { continue; } var score = calculateScore(direction, maxDistance, options.historyRect, refObj.rect, potentialRect); if (score > bestPotential.score) { bestPotential.element = potentialElement; bestPotential.rect = potentialRect; bestPotential.score = score; } } return bestPotential.element ? { target: bestPotential.element, targetRect: bestPotential.rect, referenceRect: refObj.rect, usedOverride: false } : null; // Nested Helpers function calculatePercentInShadow(minReferenceCoord, maxReferenceCoord, minPotentialCoord, maxPotentialCoord) { /// Calculates the percentage of the potential element that is in the shadow of the reference element. if ((minReferenceCoord >= maxPotentialCoord) || (maxReferenceCoord <= minPotentialCoord)) { // Potential is not in the reference's shadow. return 0; } var pixelOverlap = Math.min(maxReferenceCoord, maxPotentialCoord) - Math.max(minReferenceCoord, minPotentialCoord); var shortEdge = Math.min(maxPotentialCoord - minPotentialCoord, maxReferenceCoord - minReferenceCoord); return shortEdge === 0 ? 0 : (pixelOverlap / shortEdge); } function calculateScore(direction, maxDistance, historyRect, referenceRect, potentialRect) { var score = 0; var percentInShadow; var primaryAxisDistance; var secondaryAxisDistance = 0; var percentInHistoryShadow = 0; switch (direction) { case DirectionNames.left: // Make sure we don't evaluate any potential elements to the right of the reference element if (potentialRect.left >= referenceRect.left) { break; } percentInShadow = calculatePercentInShadow(referenceRect.top, referenceRect.bottom, potentialRect.top, potentialRect.bottom); primaryAxisDistance = referenceRect.left - potentialRect.right; if (percentInShadow > 0) { percentInHistoryShadow = calculatePercentInShadow(historyRect.top, historyRect.bottom, potentialRect.top, potentialRect.bottom); } else { // If the potential element is not in the shadow, then we calculate secondary axis distance secondaryAxisDistance = (referenceRect.bottom <= potentialRect.top) ? (potentialRect.top - referenceRect.bottom) : referenceRect.top - potentialRect.bottom; } break; case DirectionNames.right: // Make sure we don't evaluate any potential elements to the left of the reference element if (potentialRect.right <= referenceRect.right) { break; } percentInShadow = calculatePercentInShadow(referenceRect.top, referenceRect.bottom, potentialRect.top, potentialRect.bottom); primaryAxisDistance = potentialRect.left - referenceRect.right; if (percentInShadow > 0) { percentInHistoryShadow = calculatePercentInShadow(historyRect.top, historyRect.bottom, potentialRect.top, potentialRect.bottom); } else { // If the potential element is not in the shadow, then we calculate secondary axis distance secondaryAxisDistance = (referenceRect.bottom <= potentialRect.top) ? (potentialRect.top - referenceRect.bottom) : referenceRect.top - potentialRect.bottom; } break; case DirectionNames.up: // Make sure we don't evaluate any potential elements below the reference element if (potentialRect.top >= referenceRect.top) { break; } percentInShadow = calculatePercentInShadow(referenceRect.left, referenceRect.right, potentialRect.left, potentialRect.right); primaryAxisDistance = referenceRect.top - potentialRect.bottom; if (percentInShadow > 0) { percentInHistoryShadow = calculatePercentInShadow(historyRect.left, historyRect.right, potentialRect.left, potentialRect.right); } else { // If the potential element is not in the shadow, then we calculate secondary axis distance secondaryAxisDistance = (referenceRect.right <= potentialRect.left) ? (potentialRect.left - referenceRect.right) : referenceRect.left - potentialRect.right; } break; case DirectionNames.down: // Make sure we don't evaluate any potential elements above the reference element if (potentialRect.bottom <= referenceRect.bottom) { break; } percentInShadow = calculatePercentInShadow(referenceRect.left, referenceRect.right, potentialRect.left, potentialRect.right); primaryAxisDistance = potentialRect.top - referenceRect.bottom; if (percentInShadow > 0) { percentInHistoryShadow = calculatePercentInShadow(historyRect.left, historyRect.right, potentialRect.left, potentialRect.right); } else { // If the potential element is not in the shadow, then we calculate secondary axis distance secondaryAxisDistance = (referenceRect.right <= potentialRect.left) ? (potentialRect.left - referenceRect.right) : referenceRect.left - potentialRect.right; } break; } if (primaryAxisDistance >= 0) { // The score needs to be a positive number so we make these distances positive numbers primaryAxisDistance = maxDistance - primaryAxisDistance; secondaryAxisDistance = maxDistance - secondaryAxisDistance; if (primaryAxisDistance >= 0 && secondaryAxisDistance >= 0) { // Potential elements in the shadow get a multiplier to their final score primaryAxisDistance += primaryAxisDistance * percentInShadow; score = primaryAxisDistance * ScoringConstants.primaryAxisDistanceWeight + secondaryAxisDistance * ScoringConstants.secondaryAxisDistanceWeight + percentInHistoryShadow * ScoringConstants.percentInHistoryShadowWeight; } } return score; } function getReferenceObject(referenceElement, referenceRect) { var refElement; var refRect; if ((!referenceElement && !referenceRect) || (referenceElement && !referenceElement.parentNode)) { // Note: We need to check to make sure 'parentNode' is not null otherwise there is a case // where _lastTarget is defined, but calling getBoundingClientRect will throw a native exception. // This case happens if the innerHTML of the parent of the _lastTarget is set to "". // If no valid reference is supplied, we'll use document.activeElement unless it's the body if (document.activeElement !== document.body) { referenceElement = document.activeElement; } } if (referenceElement) { refElement = referenceElement; refRect = _toIRect(refElement.getBoundingClientRect()); } else if (referenceRect) { refRect = _toIRect(referenceRect); } else { refRect = _defaultRect(); } return { element: refElement, rect: refRect }; } } function _defaultRect() { // We set the top, left, bottom and right properties of the referenceBoundingRectangle to '-1' // (as opposed to '0') because we want to make sure that even elements that are up to the edge // of the screen can receive focus. return { top: -1, bottom: -1, right: -1, left: -1, height: 0, width: 0 }; } function _toIRect(rect) { return { top: Math.floor(rect.top), bottom: Math.floor(rect.top + rect.height), right: Math.floor(rect.left + rect.width), left: Math.floor(rect.left), height: Math.floor(rect.height), width: Math.floor(rect.width), }; } function _trySetFocus(element, keyCode) { // We raise an event on the focusRoot before focus changes to give listeners // a chance to prevent the next focus target from receiving focus if they want. var canceled = eventSrc.dispatchEvent(EventNames.focusChanging, { nextFocusElement: element, keyCode: keyCode }); if (!canceled) { element.focus(); } return document.activeElement === element; } function _isFocusable(element) { var elementTagName = element.tagName; var tabIndex = parseInt(element.getAttribute("tabIndex")); if (FocusableTagNames.indexOf(elementTagName) === -1 && isNaN(tabIndex)) { // Loop through the selectors var matchesSelector = false; for (var i = 0, len = FocusableSelectors.length; i < len; i++) { if (_matchesSelector(element, FocusableSelectors[i])) { if (isNaN(tabIndex)) { element.setAttribute("tabIndex", 0); } matchesSelector = true; break; } } // If the current potential element is not one of the tags we consider to be focusable, then exit if (!matchesSelector) { return false; } } if (elementTagName === "IFRAME" && !IFrameHelper.isXYFocusEnabled(element)) { // Skip IFRAMEs without compatible XYFocus implementation return false; } if (elementTagName === "DIV" && element["winControl"] && element["winControl"].disabled) { // Skip disabled WinJS controls return false; } var style = window.getComputedStyle(element); if (style && tabIndex === -1 || style.display === "none" || style.visibility === "hidden" || element.disabled) { // Skip elements that are hidden // Note: We don't check for opacity === 0, because the browser cannot tell us this value accurately. return false; } return true; }; function _matchesSelector(element, selectorString) { var matchesSelector = element.matches || element.msMatchesSelector || element.mozMatchesSelector || element.webkitMatchesSelector; return matchesSelector.call(element, selectorString); }; function _handleKeyDownEvent(e) { if (e.defaultPrevented) { return; } var direction = ""; var keyCode = e.keyCode; if (_keyCodeMap.up.indexOf(keyCode) !== -1) { direction = "up"; } else if (_keyCodeMap.down.indexOf(keyCode) !== -1) { direction = "down"; } else if (_keyCodeMap.left.indexOf(keyCode) !== -1) { direction = "left"; } else if (_keyCodeMap.right.indexOf(keyCode) !== -1) { direction = "right"; } if (direction && _enabled) { var shouldPreventDefault = _xyFocus(direction, keyCode); if (shouldPreventDefault) { e.preventDefault(); } } }; function _handleKeyUpEvent(e) { if (e.defaultPrevented) { return; } if (_keyCodeMap.accept.indexOf(e.keyCode) !== -1) { e.srcElement.click(); } }; var WebViewHelper; (function (WebViewHelper) { function refRectToNavigateFocusRect(refRect) { return { originLeft: refRect.left, originTop: refRect.top, originWidth: refRect.width, originHeight: refRect.height }; } WebViewHelper.refRectToNavigateFocusRect = refRectToNavigateFocusRect; function navigateFocusRectToRefRect(navigateFocusRect) { return _toIRect({ left: navigateFocusRect.originLeft, top: navigateFocusRect.originTop, width: navigateFocusRect.originWidth, height: navigateFocusRect.originHeight }); } WebViewHelper.navigateFocusRectToRefRect = navigateFocusRectToRefRect; })(WebViewHelper || (WebViewHelper = {})); var IFrameHelper; (function (IFrameHelper) { // XYFocus caches registered iframes and iterates over the cache for its focus navigation implementation. // However, since there is no reliable way for an iframe to unregister with its parent as it can be // spontaneously taken out of the DOM, the cache can go stale. This helper module makes sure that on // every query to the iframe cache, stale iframes are removed. // Furthermore, merely accessing an iframe that has been garbage collected by the platform will cause an // exception so each iteration during a query must be in a try/catch block. var iframes = []; function count() { // Iterating over it causes stale iframes to be cleared from the cache. _safeForEach(function () { return false; }); return iframes.length; } IFrameHelper.count = count; function getIFrameFromWindow(win) { var iframes = document.querySelectorAll("IFRAME"); var found = Array.prototype.filter.call(iframes, function (x) { return x.contentWindow === win; }); return found.length ? found[0] : null; } IFrameHelper.getIFrameFromWindow = getIFrameFromWindow; function isXYFocusEnabled(iframe) { var found = false; _safeForEach(function (ifr) { if (ifr === iframe) { found = true; } }); return found; } IFrameHelper.isXYFocusEnabled = isXYFocusEnabled; function registerIFrame(iframe) { iframes.push(iframe); }; IFrameHelper.registerIFrame = registerIFrame; function unregisterIFrame(iframe) { var index = -1; _safeForEach(function (ifr, i) { if (ifr === iframe) { index = i; } }); if (index !== -1) { iframes.splice(index, 1); } }; IFrameHelper.unregisterIFrame = unregisterIFrame; function _safeForEach(callback) { for (var i = iframes.length - 1; i >= 0; i--) { try { var iframe = iframes[i]; if (!iframe.contentWindow) { iframes.splice(i, 1); } else { callback(iframe, i); } } catch (e) { // Iframe has been GC'd iframes.splice(i, 1); } } }; })(IFrameHelper || (IFrameHelper = {})); // Default mappings _keyCodeMap.left.push( 37, // LeftArrow 214, // GamepadLeftThumbstickLeft 205, // GamepadDPadLeft 140); // NavigationLeft _keyCodeMap.right.push( 39, // RightArrow 213, // GamepadLeftThumbstickRight 206, // GamepadDPadRight 141); // NavigationRight _keyCodeMap.up.push( 38, // UpArrow 211, // GamepadLeftThumbstickUp 203, // GamepadDPadUp 138); // NavigationUp _keyCodeMap.down.push( 40, // UpArrow 212, // GamepadLeftThumbstickDown 204, // GamepadDPadDown 139); // NavigationDown _keyCodeMap.accept.push( 142, // NavigationAccept 195); // GamepadA window.addEventListener("message", function (e) { // Note: e.source is the Window object of an iframe which could be hosting content // from a different domain. No properties on e.source should be accessed or we may // run into a cross-domain access violation exception. var sourceWindow = null; try { // Since messages are async, by the time we get this message, the iframe could've // been removed from the DOM and e.source is null or throws an exception on access. sourceWindow = e.source; if (!sourceWindow) { return; } } catch (e) { return; } if (!e.data || !e.data[CrossDomainMessageConstants.messageDataProperty]) { return; } var data = e.data[CrossDomainMessageConstants.messageDataProperty]; switch (data.type) { case CrossDomainMessageConstants.register: var iframe = IFrameHelper.getIFrameFromWindow(sourceWindow); iframe && IFrameHelper.registerIFrame(iframe); break; case CrossDomainMessageConstants.unregister: var iframe = IFrameHelper.getIFrameFromWindow(sourceWindow); iframe && IFrameHelper.unregisterIFrame(iframe); break; case CrossDomainMessageConstants.dFocusEnter: // The coordinates stored in data.refRect are already in this frame's coordinate system. // First try to focus anything within this iframe without leaving the current frame. var focused = _xyFocus(data.direction, -1, data.referenceRect, true); if (!focused) { // No focusable element was found, we'll focus document.body if it is focusable. if (_isFocusable(document.body)) { document.body.focus(); } else { // Nothing within this iframe is focusable, we call _xyFocus again without a refRect // and allow the request to propagate to the parent. _xyFocus(data.direction, -1); } } break; case CrossDomainMessageConstants.dFocusExit: var iframe = IFrameHelper.getIFrameFromWindow(sourceWindow); if (document.activeElement !== iframe) { break; } // The coordinates stored in data.refRect are in the IFRAME's coordinate system, // so we must first transform them into this frame's coordinate system. var refRect = data.referenceRect; var iframeRect = iframe.getBoundingClientRect(); refRect.left += iframeRect.left; refRect.top += iframeRect.top; if (typeof refRect.right === "number") { refRect.right += iframeRect.left; } if (typeof refRect.bottom === "number") { refRect.bottom += iframeRect.top; } _xyFocus(data.direction, -1, refRect); break; } }); // Receiving departingFocus event in the app as a result of any child webview calling // window.departFocus from within the webview. Indicates focus transitioning into the app // from the webview. The navigatingfocus event handles transforming the // coordinate space so we just pass the values along. document.addEventListener("departingfocus", function(eventArg) { var focusChanged = _xyFocus( eventArg.navigationReason, -1, WebViewHelper.navigateFocusRectToRefRect(eventArg)); if (focusChanged && eventArg && (typeof eventArg.focus === "function")) { eventArg.focus(); } }); // Receiving a navigatingfocus event in the webview as a result of the host app calling // webview.navigateFocus on our containing webview element indicating focus transitioning // into the webview from the app. The navigatingfocus event handles transforming the // coordinate space so we just pass the values along. window.addEventListener("navigatingfocus", function(eventArg) { var focusChanged = _xyFocus( eventArg.navigationReason, -1, WebViewHelper.navigateFocusRectToRefRect(eventArg)); if (focusChanged && eventArg && (typeof eventArg.focus === "function")) { eventArg.focus(); } }); var _initRun = false; var _init = function () { if (_initRun) { return; } _initRun = true; // Subscribe on bubble phase to allow developers to override XYFocus behaviors for directional keys. document.addEventListener("keydown", _handleKeyDownEvent); document.addEventListener("keyup", _handleKeyUpEvent); // If we are running within an iframe, we send a registration message to the parent window if (window.top !== window.window) { var message = {}; message[CrossDomainMessageConstants.messageDataProperty] = { type: CrossDomainMessageConstants.register, version: 1.0 }; window.parent.postMessage(message, "*"); } } document.addEventListener("DOMContentLoaded", function () { _init(); }); var EventMixinEvent = (function () { function EventMixinEvent(type, detail, target) { this.detail = detail; this.target = target; this.timeStamp = Date.now(); this.type = type; this.bubbles = { value: false, writable: false }; this.cancelable = { value: false, writable: false }; this.trusted = { value: false, writable: false }; this.eventPhase = { value: 0, writable: false }; this.supportedForProcessing = true; }; Object.defineProperty(EventMixinEvent.prototype, "currentTarget", { get: function () { return this.target; }, enumerable: true, configurable: true }); Object.defineProperty(EventMixinEvent.prototype, "defaultPrevented", { get: function () { return this._preventDefaultCalled; }, enumerable: true, configurable: true }); EventMixinEvent.prototype.preventDefault = function () { this._preventDefaultCalled = true; }; EventMixinEvent.prototype.stopImmediatePropagation = function () { this._stopImmediatePropagationCalled = true; }; EventMixinEvent.prototype.stopPropagation = function () { }; return EventMixinEvent; })(); var eventMixin = { _listeners: null, addEventListener: function (type, listener, useCapture) { /// /// /// Adds an event listener to the control. /// /// /// The type (name) of the event. /// /// /// The listener to invoke when the event is raised. /// /// /// if true initiates capture, otherwise false. /// /// useCapture = useCapture || false; this._listeners = this._listeners || {}; var eventListeners = (this._listeners[type] = this._listeners[type] || []); for (var i = 0, len = eventListeners.length; i < len; i++) { var l = eventListeners[i]; if (l.useCapture === useCapture && l.listener === listener) { return; } } eventListeners.push({ listener: listener, useCapture: useCapture }); }, dispatchEvent: function (type, details) { /// /// /// Raises an event of the specified type and with the specified additional properties. /// /// /// The type (name) of the event. /// /// /// The set of additional properties to be attached to the event object when the event is raised. /// /// /// true if preventDefault was called on the event. /// /// var listeners = this._listeners && this._listeners[type]; if (listeners) { var eventValue = new EventMixinEvent(type, details, this); // Need to copy the array to protect against people unregistering while we are dispatching listeners = listeners.slice(0, listeners.length); for (var i = 0, len = listeners.length; i < len && !eventValue._stopImmediatePropagationCalled; i++) { listeners[i].listener(eventValue); } return eventValue.defaultPrevented || false; } return false; }, removeEventListener: function (type, listener, useCapture) { /// /// /// Removes an event listener from the control. /// /// /// The type (name) of the event. /// /// /// The listener to remove. /// /// /// Specifies whether to initiate capture. /// /// useCapture = useCapture || false; var listeners = this._listeners && this._listeners[type]; if (listeners) { for (var i = 0, len = listeners.length; i < len; i++) { var l = listeners[i]; if (l.listener === listener && l.useCapture === useCapture) { listeners.splice(i, 1); if (listeners.length === 0) { delete this._listeners[type]; } // Only want to remove one element for each call to removeEventListener break; } } } } }; // Publish to WinJS namespace var toPublish = { init: _init, findNextFocusElement: _findNextFocusElement, keyCodeMap: _keyCodeMap, focusableSelectors: FocusableSelectors, moveFocus: _moveFocus, onfocuschanged: _createEventProperty(EventNames.focusChanged), onfocuschanging: _createEventProperty(EventNames.focusChanging), _xyFocus: _xyFocus, _handleKeyDownEvent: _handleKeyDownEvent, _handleKeyUpEvent: _handleKeyUpEvent, _iframeHelper: IFrameHelper }; toPublish = _mergeAll([toPublish, eventMixin]); toPublish["_listeners"] = {}; var eventSrc = toPublish; window.TVJS = window.TVJS || {}; TVJS.DirectionalNavigation = toPublish; Object.defineProperty(window.TVJS.DirectionalNavigation, "enabled", { get: function () { return _enabled; }, set: function (value) { _enabled = value; }, enumerable: true, configurable: true }); Object.defineProperty(window.TVJS.DirectionalNavigation, "focusRoot", { get: function () { return _focusRoot; }, set: function (value) { _focusRoot = value; }, enumerable: true, configurable: true }); // The gamepadInputEmulation is a string property that exists in JavaScript UWAs and in WebViews in UWAs. // It won't exist in Win8.1 style apps or browsers. if (window.navigator && typeof window.navigator.gamepadInputEmulation === "string") { // We want the gamepad to provide gamepad VK keyboard events rather than moving a // mouse like cursor. Set to "keyboard", the gamepad will provide such keyboard events // and provide input to the DOM navigator.getGamepads API. window.navigator.gamepadInputEmulation = "keyboard"; } })();