Developing on Staxmanade

TVJS TVHelpers DirectionalNavigation and Adapting/Hacking some WinJS Focus Management

(Comments)

So, Microsoft created what really turned out to be an amazing set of HTML/JS/CSS controls when they released the WinJS library. Not to go too much into the history, but honestly I hated it when I first had to use it. But, let me clarify. It wasn't until this last year when I learned that I didn't hate the WinJS controls by themselves, but I despised the way you declared their usage using the specialized win-* html attributes. It felt like a total hack to get an app up and running by littering semantic html with these attributes.

Then along comes a little toy project they created called react-winjs and all of a sudden the WinJS "Components" made total sense. When looking at them through the lense of WinJS through ReactJS components was the first time that I not only clicked with WinJS, but I actually fell in lov... (well I won't go that far), but was excited enough about them to pick them as the primary U.I. control suite while building out a little side-project.

Fast forward a year of development, and Microsoft essentially bailed on WinJS but at least they left it out in the open so I could hack on it and continue to depend on my own fork for the time being.

Then, they announce a NEW & SHINY library that can be used to help develop UWP and TV/Xbox One apps which is great. Except, WinJS doesn't work with this new library out-of-the-box, and since Microsoft isn't adding new features to WinJS, they likely never will build-in compatibility with the new & shiny library.

Guess that means we (I) have to figure it out on my own. And although I write this knowing that I'm probably the ONLY developer on the planet using this combination of libraries, I wanted to put out some of the hacks/code I've thrown together to get some WinJS controls to play nice with TVJS with regards to focus management.

What is focus management you say?

In the context of an Xbox app, the idea is to take your'e web-page-app and get rid of the ugly mouse-like-cursor you'd see if you didn't do this and replace it with a controller navigable approach - so up/down/left/right on the controller moves the visible "focus" around the application and the A button "presses enter" (or invokes) the control/button/etc.

What IS provided by TVJS

The TVJS library has a helper within it called DirectionalNavigation and is great in that it provides a focused and specific API to enable focus management while developing a Xbox App UWP Javascript (& C#) apps.

Just dropping the library in is enough to get much of the basics to work with most web apps.

However, the conflict between this and WinJS comes into play because WinJS also tried to implement some of their own focus management and the mix of these two just doesn't quite cut it.

Get rid of mouse cursor

Well, this isn't really a hack:

If you're looking at building a UWP JavaScript app for the Xbox, and tried to run your app on the Xbox (in dev mode), you may have noticed that your app behaves almost like it was just another web-page and doesn't default the cursor focus the way other xbox apps work. You're app just has a mouse-like cursor.

The way to deal with this is just by accessing the browser's gamepad api. Now, the Microsoft TVJS TVHelpers DirectionalNavigation library automatically does this for you, but for a better experience if you don't want to wait for the browser to download this library, you can manually access the api to hide the mouse cursor by throwing this at the top of you're start page EX: index.html

    <script>
        // Hide the Xbox/Edge mouse cursor during load.
        try {
            navigator.getGamepads();
        } catch(err) {
            console && console.error('Error with navigator.getGamepads()', err);
        }
    </script>

Just by calling navigator.getGamepads(), this tells the browser/hosted web app that you are going to take control of the app's focus management and to hide the mouse cursor.

Once you've done this and you're app loads up with the TVJS DirectionalNavigation library and in my case some WinJS controls, focus management mostly works (sort-of).

Completely Remove XYFocus built-in to WinJS:

This is about as ugly as they get...

The below code is bascially looking for the XYFocus handlers that WinJS is trying add to the document and we wan to not allow it to get added.

This XYFocus handler really creates havoc when we add the XYFocus handler from TVSJ DirectionalNavigation.

// HacktyHackHack
// The goal of this is to remove XYFocus management from WinJS
(function() {
  var totalRemovedHandlers = 0;
  var checkRemovedHandler = function() {
    totalRemovedHandlers++;
    if (totalRemovedHandlers > 2) {
      console.error("EEEK, removing more than 2 handlers... be sure to validate that we're removing the right ones...");
    }
  };
  var realAddEventListener = document.addEventListener;
  document.addEventListener = function(eventName, handler, c){
    if (handler.toString().indexOf('function _handleKeyEvent(e)') >= 0) {
      console.warn("Ignoring _handleKeyEvent...", eventName, handler, c);
      checkRemovedHandler();
      return;
    }
    if (handler.toString().indexOf('function _handleCaptureKeyEvent(e)') >= 0) {
      console.warn("Ignoring _handleCaptureKeyEvent...", eventName, handler, c);
      checkRemovedHandler();
      return;
    }
    return realAddEventListener(eventName, handler, c);
  };
}());

By not allowing WinJS to add it's XYFocus handlers, we can avoid many of the issues that I worked through below...

Dealing with a WinJS Pivot control

For my app, the first control I ran into trouble with was the WinJS Pivot control. This control already does some focus management all by itself, and it's own management style contradicts the way the DirectionalNavigation helper works. So we basically have to detect focus on it, turn of TVJS focus management and handle it internally (until we leave focus of the Pivot).

To work through that, I created the following helper function:


WinJS.UI.Pivot.prototype._headersKeyDown = function (e) {
    if (this.locked) {
        return;
    }
    if (e.keyCode === Keys.leftArrow ||
        e.keyCode === Keys.pageUp ||
        e.keyCode === Keys.GamepadDPadLeft ||
        e.keyCode === Keys.GamepadLeftThumbstickLeft) {
        this._rtl ? this._goNext() : this._goPrevious();
        e.preventDefault();
    } else if (e.keyCode === Keys.rightArrow ||
               e.keyCode === Keys.pageDown ||
               e.keyCode === Keys.GamepadDPadRight ||
               e.keyCode === Keys.GamepadLeftThumbstickRight) {
        this._rtl ? this._goPrevious() : this._goNext();
        e.preventDefault();
    }
};

function handlePivotNavigation(pivotElement) {
  console.log("handlePivotNavigation", pivotElement);
  if (!pivotElement) {
    throw new Error("handlePivotNavigation cannot use pivotElement as it wasn't passed in");
  }

  var pivotHeader = pivotElement.querySelector('.win-pivot-headers')

  if (!pivotHeader) {
    let msg = "handlePivotNavigation cannot find .win-pivot-headers in";
    console.error(msg, pivotElement);
    throw new Error(msg);
  }


  pivotHeader.addEventListener('focus', function() {
    console.log("pivotHeader focus");
    DirectionalNavigation.enabled = false;
  });
  pivotHeader.addEventListener('keyup', function(eventInfo) {
    console.log('pivot keyup ', eventInfo.keyCode, eventInfo.key);

    switch(eventInfo.keyCode) {
      case 204: // gampead down
      case 40: // keyboard down
        DirectionalNavigation.enabled = true;
        var target = DirectionalNavigation.findNextFocusElement('down');
        if (target) {
          target.focus();
          eventInfo.preventDefault();
        }
        break;
      case 203: // gamepad up
        // since the Pivot is at the top of the page - we won't release
        // control, or try to navigate up??? (maybe consider flowing up from the bottom of the page?)
        break;
      // case 205: // gamepad left arrow
      // case 211: // gamepad 211 GamepadLeftThumbstickUp
      // case 200: // gamepad left bumper
      //   pivotElement.winControl._goPrevious();
      //   eventInfo.preventDefault();
      //   break;
      // case 206: // gamepad right arrow
      // case 213: // gamepad 213 GamepadLeftThumbstickRight
      // case 199: // gamepad 199 GamepadRightShoulder
      //   pivotElement.winControl._goNext();
      //   eventInfo.preventDefault();
      //   break;
    }
  });
}

And use it by doing the following in my React page:

    componentDidMount() {
        var pivot = ReactDOM.findDOMNode(this.refs.pivot);
        handlePivotNavigation(pivot);
    }

Or if you're not using React you can likely just go:

    var pivot = document.getElementById('my-pivot-id');
    handlePivotNavigation(pivot);

It's not pretty, but has been working for me so far.

Now when I navigate around using an Xbox controller I can properly navigate around the WinJS Pivot.

Next up are ItemContainers.

UPDATE:

With the added (remove XYFocus above - I removed the below hack)

This one is a total hack, and I look forward to a better solution, but for now it's been working.

The issue I was seeing was with WinJS ItemContainers and the TVJS library applying a separate forced "click" on the element when the control itself has already "clicked/invoked" the element.

The real fix would likely to figure out how to get the ItemContainer to event.preventDefault() and/or event.stopPropagation() and avoid the bubbling up to the document keyup event handler that DirectionalNavigation has under it's control, but WinJS ItemControl management is just so complicated that this hack was easier to figure out at the time I threw it together.

So what does this do?

It's basically hijacking the DirectionalNavigation._handleKeyUpEvent function, and re-writing it with one that ignores the keyup event if the currently focused element is an ItemContainer.

// Hack to avoid Item containers getting double click
var originalDNKeyUp = TVJS.DirectionalNavigation._handleKeyUpEvent
TVJS.DirectionalNavigation._handleKeyUpEvent = function (e) {
console.log("Check for itemContaner", event.target.className)
  if (e.target.className.split(" ").indexOf("win-itemcontainer") >= 0) {
    console.log("MonkeyHack on DirectionalNavigation - SKIPPING CLICK");
    return;
  }
  return originalDNKeyUp.apply(null, arguments);
}
document.removeEventListener("keyup", originalDNKeyUp);
document.addEventListener("keyup", TVJS.DirectionalNavigation._handleKeyUpEvent);

It's not pretty, but meh, is working so far.

ItemContainers within a ContentDialog

UPDATE

I gave up on ContentDialog, and just started using react-modal

That's just a big mess from what I could figure out. I was able to get it working by using the ContentDialog but manually creat my own buttons as the ItemContainer in combination with the dialog kept swallowing events that didn't allow focus navigation to be sucessful. The internals of what was holding me back didn't appear to be monkey-patch-able from what I could tell... ugh...

Next up is a ListView hack,

This one is a hack proposed by Todd over on the GitHub issues.

I've essentially taken the original implementation of WinJS.UI.ListView.prototype._onFocusIn, and if you look for the line starting with /* JJ */ below you can see the change there.

Don't know what this actually could mean from other scenarios, but for now it's allowing the ListView to focus properly on my initial xbox testing.

var _Constants = WinJS.UI;
var _UI = WinJS.UI;

WinJS.UI.ListView.prototype._onFocusIn = function ListView_onFocusIn(event) {
                    this._hasKeyboardFocus = true;
                    var that = this;
                    function moveFocusToItem(keyboardFocused) {
                        that._changeFocus(that._selection._getFocused(), true, false, false, keyboardFocused);
                    }
                    // The keyboardEventsHelper object can get focus through three ways: We give it focus explicitly, in which case _shouldHaveFocus will be true,
                    // or the item that should be focused isn't in the viewport, so keyboard focus could only go to our helper. The third way happens when
                    // focus was already on the keyboard helper and someone alt tabbed away from and eventually back to the app. In the second case, we want to navigate
                    // back to the focused item via changeFocus(). In the third case, we don't want to move focus to a real item. We differentiate between cases two and three
                    // by checking if the flag _keyboardFocusInbound is true. It'll be set to true when the tab manager notifies us about the user pressing tab
                    // to move focus into the listview.
                    if (event.target === this._keyboardEventsHelper) {
                        if (!this._keyboardEventsHelper._shouldHaveFocus && this._keyboardFocusInbound) {
                            moveFocusToItem(true);
                        } else {
                            this._keyboardEventsHelper._shouldHaveFocus = false;
                        }
                    } else if (event.target === this._element) {
                        // If someone explicitly calls .focus() on the listview element, we need to route focus to the item that should be focused
                        moveFocusToItem();
                    } else {
                        if (this._mode.inboundFocusHandled) {
                            this._mode.inboundFocusHandled = false;
                            return;
                        }

                        // In the event that .focus() is explicitly called on an element, we need to figure out what item got focus and set our state appropriately.
                        var items = this._view.items,
                            entity = {},
                            element = this._getHeaderOrFooterFromElement(event.target),
                            winItem = null;
                        if (element) {
                            entity.index = 0;
                            entity.type = (element === this._header ? _UI.ObjectType.header : _UI.ObjectType.footer);
                            this._lastFocusedElementInGroupTrack = entity;
                        } else {
                            element = this._groups.headerFrom(event.target);
                            if (element) {
                                entity.type = _UI.ObjectType.groupHeader;
                                entity.index = this._groups.index(element);
                                this._lastFocusedElementInGroupTrack = entity;
                            } else {
                                entity.index = items.index(event.target);
                                entity.type = _UI.ObjectType.item;
                                element = items.itemBoxAt(entity.index);
                                winItem = items.itemAt(entity.index);
                            }
                        }

                        // In the old layouts, index will be -1 if a group header got focus
                        if (entity.index !== _Constants._INVALID_INDEX) {
/* JJ */                            /*if (this._keyboardFocusInbound || this._selection._keyboardFocused())*/ {
                                if ((entity.type === _UI.ObjectType.groupHeader && event.target === element) ||
                                        (entity.type === _UI.ObjectType.item && event.target.parentNode === element)) {
                                    // For items we check the parentNode because the srcElement is win-item and element is win-itembox,
                                    // for header, they should both be the win-groupheader
                                    this._drawFocusRectangle(element);
                                }
                            }
                            if (this._tabManager.childFocus !== element && this._tabManager.childFocus !== winItem) {
                                this._selection._setFocused(entity, this._keyboardFocusInbound || this._selection._keyboardFocused());
                                this._keyboardFocusInbound = false;
                                if (entity.type === _UI.ObjectType.item) {
                                    element = items.itemAt(entity.index);
                                }
                                this._tabManager.childFocus = element;

                                if (that._updater) {
                                    var elementInfo = that._updater.elements[uniqueID(element)],
                                        focusIndex = entity.index;
                                    if (elementInfo && elementInfo.newIndex) {
                                        focusIndex = elementInfo.newIndex;
                                    }

                                    // Note to not set old and new focus to the same object
                                    that._updater.oldFocus = { type: entity.type, index: focusIndex };
                                    that._updater.newFocus = { type: entity.type, index: focusIndex };
                                }
                            }
                        }
                    }
                }

One big improvement could be to consider setting up a unit-test that could take the original "string" value of the entire function code, and comparing it to the current version of WinJS library you're using and fail if they're even one character different. This would allow you to detect if say a fix were applied, or you need to update our local hacked version with some remote changes... It's not pretty, but one way to avoid over-writing possible working WinJS code with our potentially not-so-future-proof hacked version.

Next one is the WinJS [ToggleSwitch].

This control just seemed to have all behavior wrong for me. So I hacked the keyDownHandler and simplified it's implementation which seems to have really made it more usable (for me).

var _ElementUtilities = WinJS.Utilities

WinJS.UI.ToggleSwitch.prototype._keyDownHandler =  function ToggleSwitch_keyDown(e) {
    if (this.disabled) {
        return;
    }

    // Toggle checked on spacebar
    if (e.keyCode === _ElementUtilities.Key.space ||
        e.keyCode === _ElementUtilities.Key.GamepadA ||
        e.keyCode === _ElementUtilities.Key.enter) {
        e.preventDefault();
        this.checked = !this.checked;
    }

}

The original had up/down/left/right configured to toggle the switch on/off which meant focus in/out was nearly impossible, it also only listened to space as a toggle option. So by removing the up/down/left/right we can navigate in/around the control and we wanted to listen to space, GamepadA, and enter to toggle the control on/off.

What else?

The WinJS control set is quite large, and I certainly haven't worked with each control in this manor, however, it's a step forward eh and if you managed to come across this random post on the interweb I hope it was useful?

Comments