Monday, February 4, 2013

AngularJS and setting focus on elements

[update: 2/14/2013] There is new information in the angular group discussion that obviates much of this post. Support for setting focus and blur are on the Angular 1.1 roadmap. The question in this post that remains is how to clean up the timing issue.

~~

At last week’s AngularJS meetup in Chicago, I had a discussion about setting focus on an element, which is not built-in to angular. This is a challenge because you basically have to address a specific DOM element from your controller, which we have learned is Bad Form.

I believe that AngularJS should provide for setting focus (and blur, and select). This one of those common things that developers have to face, and we only end up with various solutions posted online, and no canonical form. I’d like angular to say, “here’s how we can set focus”. So after playing around with this problem, this is my proposal.

Why is setting focus troublesome in angular?

The underlying problem is that setting focus is transitional, and it flows from the controller to the DOM. To be clear, a click or keyboard event is also transitional, but those transitions flow from the DOM to the controller. And unlike our elements bound with ng-bind or ng-model, transitional events are not represented by a state, nor do they participate in the “binding event loop”, which I believe they should.

The other concern I expressed is that this same problem probably applies to more than just setting focus. But how many other “fire-an-event-at-a-DOM-element” things are there? Offhand I could think of calling blur() on an element. Once back at my desk, I looked at events in the W3C DOM Level 2 spec (better summarized on wikipedia), and it would seem that there are really only three events that are appropriate to fire from the controller. These are focus(), blur(), and select().

How might we do it?

The only method I have seen to solve this problem is to $emit() or $broadcast() a message from the controller, and have a directive listening with $on() who can fire .focus() on the raw element. This is fine, and it achieves the proper decoupling, i.e., not letting the raw element leak into the controller. But it feels somewhat clumsy to address your element by way of calling $scope.$emit('agreed-upon-name'). Contrast this with changing a variable bound to an input element, which feels more direct. For that we simply assign to the bound variable, e.g., $scope.form.personName = "Alice".

I propose decoupling focus events the same way we decouple value assignment to a DOM element. Use an attribute directive to indicate a $scope variable that we will $watch. And when that variable changes from 0 to 1, fire the focus event and reset the value to 0 again. In fact, we can go the extra step and generate a focus() function that will set this variable to 1 for you. This feels more natural, actually calling a focus() function for the element you care about, and doing so without addressing the DOM directly.

The result is clean and simple. Here’s the simplest form, which doesn’t have any controller code:

  1: <input type="text" x-ng-model="form.color" x-ng-target="form.colorTarget">
  2: <button class="btn" x-ng-click="form.colorTarget.focus()">do focus</button>

For me, it is clearer to see form.colorTarget.focus() than $emit(‘colorFieldFocus’).

Note that by having our directive place a .focus() function directly into our scope, we can now use a list of objects in ng-repeat and have each object be blessed with a .focus() method. We don’t have to use $index to construct some naming convention for our event names. In fact, I’m not certain how we’d do this with the $emit() solution. The ng-repeat example looks like:

  1: <h3>demo with ng-repeat</h3>
  2: <div x-ng-repeat="p in people">
  3:     <span>{{$index}}</span>
  4:     <span x-ng-bind="p.name"></span>
  5:     <input type="text" x-ng-target="p">
  6:     <button class="btn btn-mini" x-ng-click="p.select()">select</button>
  7: </div>
  8: <button class="btn" x-ng-click="people[0].focus()">focus item 0</button>
  9: <button class="btn" x-ng-click="people[2].blur()">blur item 2</button>
 10: <button class="btn" x-ng-click="people[3].select()">select item 3</button>

The controller just has a list of people objects, e.g.

  1: angular.module('app', []);
  2: angular.module('app').controller('DemoCtrl', function($scope) {
  3: 
  4:     $scope.people = [
  5:         {id: 123, name: 'alice'},
  6:         {id: 714, name: 'bob'},
  7:         {id: 531, name: 'carly'},
  8:         {id: 284, name: 'dave'},
  9:     ];
 10: 
 11: });

Each one of those objects will gain a .focus(), .blur(), and .select() function by virtue of the ng-target attribute. The downside of this approach is that you would not want to have multiple DOM elements with ng-target pointed at the same underlying people objects. In that case, whoever writes over the .focus() function last wins.

Here is a functioning jsfiddle:  http://jsfiddle.net/bseib/WUcQX/

Timing is everything

With this approach, I like that the firing of the raw element.focus() is placed where it needs to be, such that it participates in the $digest/$watch event loop. But why should it belong here?

The actual focus event doesn’t fire right away, not until its transition happens to be noticed, along with other variables that are being $watched. We inevitably want to fire one of these focus events on the heels of changing a class, or changing an attribute of an element. We might remove the attribute disabled from a <button> followed by a call to focus() for the same button. Or we might remove a class like .hide={ display: none; } , and then call focus(). Say we want to show a hidden panel, then set focus on a text element within. The key here is that we want these events to happen in a particular order.

To do this correctly means having some priority assigned in Angular’s binding mechanism. Basically, during the binding cycle, you want to apply all the data binds first and let them actually take effect in the DOM. Then once the DOM element attributes or classes have changed, then we can fire the lower priority items, i.e. any of focus(), blur(), or select() events that are ready to fire.

Considering Angular’s event loop, it seems that the $watch callbacks themselves are the place to tackle this timing problem. I think it can be solved by passing an optional listenerPriority integer when you setup your $watch, so that the execution of the callbacks can be sorted by their priority. We would use the same priority semantics established by $compile, i.e. lower numbers get applied last. I think a middle-of-the-road default priority (like 100) should be used if none supplied. And the ng-target events would default at 50. This leaves wiggle room on all sides. The $watch signature could look like:

$watch(watchExpression, listenerPriority, listener, objectEquality);

Without a listener priority, we have no control over the order that $watch callbacks will fire, and thus, we cannot control the order that we apply visual changes to the UI. Presently, I wrap the element.focus() call inside a $timeout with a 50ms delay. It seems to work, but wow is it super hacky.

Here is an example of the timing problem in action.

 

I’d like some discussion/feedback from angular folks on having a listenerPriority in $watch.
Please leave comments in this thread: http://goo.gl/ipsx4

 

How might we expose this functionality from the DOM?

We could have individual attributes to cover each of these three events, e.g., ng-selectable, ng-focusable, ng-blurable, or something similar. (Wow, “blurable” is a little awkward looking…)

I feel it is better to create a “mothership” attribute, so that we expose all three methods focus(), blur(), or select() all at once on the same scope variable. But to pile these all on one attribute begs the question, what elements can you actually set focus()? blur()? select()?  The W3C spec shows the following elements accept the corresponding functions.

HTMLSelectElement focus() blur()
HTMLInputElement focus() blur() select()
HTMLTextAreaElement focus() blur() select()
HTMLAnchorElement focus() blur()

Remember that browsers don’t necessarily adhere to the spec. For example, where’s HTMLButtonElement in the spec? (e.g. <button> rather than <input type=”submit”>) We expect to be able to call focus() on buttons.

My proposal is an attribute directive with a name of ng-target that has all three functions attached to it. The name ng-target still fits whether the element is an anchor, button, or input field.

Directive Implementation

Here is the directive implementation as proposed. This is what I am currently using in my code. However it still has the ugly super-hacky $timeout delay. A listenerPriority in $watch should address this issue. Or perhaps I have overlooked another solution. If anyone is interested, I am willing to add documentation to this code and contribute it per the Angular contribution guidelines. I need some feedback first.

  1: angular.module('ng').directive('ngTarget', function($parse, $timeout) {
  2:     var NON_ASSIGNABLE_MODEL_EXPRESSION = 'Non-assignable model expression: ';
  3:     return {
  4:         restrict: "A",
  5:         link: function(scope, element, attr) {
  6:             var buildGetterSetter = function(name) {
  7:                 var me = {};
  8:                 me.get = $parse(name);
  9:                 me.set = me.get.assign;
 10:                 if (!me.set) {
 11:                     throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + name);
 12:                 }
 13:                 return me;
 14:             };
 15:             
 16:             // *********** focus *********** 
 17:             var focusTriggerName = attr.ngTarget+"._focusTrigger";
 18:             var focusTrigger = buildGetterSetter(focusTriggerName);
 19:             var focus = buildGetterSetter(attr.ngTarget+".focus");
 20: 
 21:             focusTrigger.set(scope, 0);
 22:             focus.set(scope, function() {
 23:                 focusTrigger.set(scope, 1);
 24:             });
 25:             
 26:             // $watch the trigger variable for a transition
 27:             scope.$watch(focusTriggerName, function(newValue, oldValue) {
 28:                 if ( newValue > 0 ) {
 29:                     $timeout(function() { // a timing workaround hack
 30:                         element[0].focus(); // without jQuery, need [0]
 31:                         focusTrigger.set(scope, 0);
 32:                     }, 50);
 33:                 }
 34:             });
 35: 
 36:             // *********** blur *********** 
 37:             var blurTriggerName = attr.ngTarget+"._blurTrigger";
 38:             var blurTrigger = buildGetterSetter(blurTriggerName);
 39:             var blur = buildGetterSetter(attr.ngTarget+".blur");
 40: 
 41:             blurTrigger.set(scope, 0);
 42:             blur.set(scope, function() {
 43:                 blurTrigger.set(scope, 1);
 44:             });
 45:             
 46:             // $watch the trigger variable for a transition
 47:             scope.$watch(blurTriggerName, function(newValue, oldValue) {
 48:                 if ( newValue > 0 ) {
 49:                     $timeout(function() { // a timing workaround hack
 50:                         element[0].blur(); // without jQuery, need [0]
 51:                         blurTrigger.set(scope, 0);
 52:                     }, 50);
 53:                 }
 54:             });
 55: 
 56:             // *********** select *********** 
 57:             var selectTriggerName = attr.ngTarget+"._selectTrigger";
 58:             var selectTrigger = buildGetterSetter(selectTriggerName);
 59:             var select = buildGetterSetter(attr.ngTarget+".select");
 60: 
 61:             selectTrigger.set(scope, 0);
 62:             select.set(scope, function() {
 63:                 selectTrigger.set(scope, 1);
 64:             });
 65:             
 66:             // $watch the trigger variable for a transition
 67:             scope.$watch(selectTriggerName, function(newValue, oldValue) {
 68:                 if ( newValue > 0 ) {
 69:                     $timeout(function() { // a timing workaround hack
 70:                         element[0].select(); // without jQuery, need [0]
 71:                         selectTrigger.set(scope, 0);
 72:                     }, 50);
 73:                 }
 74:             });
 75:             
 76:         }
 77:     };
 78: });

I welcome your feedback.

 

Other random thoughts:

How might this relate to setting elements “tabbable”? Is unit testing ok with this? Are there any memory leak possibilities using inside an ng-repeat? How can I break it?