Aurelia

Building Aurelia's Focus Attribute

Introduction

Aurelia

Aurelia


Building Aurelia's Focus Attribute

Posted by Aurelia on .
Featured

Building Aurelia's Focus Attribute

Posted by Aurelia on .

This week we are really excited to have our first community member highlight post! Manuel Guilbault is a Canadian-born, Paris-based developer, where he works as a consultant in the financial sector. Passionate about software craftsmanship, agility and lean principles, he loves to learn and debate about how we do things, why we do them this way and how they can be improved. This week we've invited Manuel to guest blog with us and share the work he did implementing a new custom attribute that's shipping in the next Aurelia update.

Take it away Manuel....

Introduction

I've been a huge fan of Durandal and Knockout JS for many years now, and I've been closely following Aurelia since I first heard about it. After playing with it for a while, I noticed that one of the features I used with Knockout was missing from Aurelia: a focusbinding. I decided to take advantage of the Custom Attribute API to develop a focus custom attribute for Aurelia.

Requirements

The custom attribute I have in mind would be used this way:

export class ViewModel {  
  hasFocus = false;
}
<input focus.bind="hasFocus" />  

The requirements are as follows:

  • When hasFocus is set to true, the input gets focus;
  • When hasFocus is set to false, the input loses focus;
  • When input gains focus following a user action, hasFocus is set to true;
  • When input loses focus following a user action, hasFocus is set to false.

So, what I want is basically a two-way binding between the bound property and the focus state of the input. You might think that this kind of two-way binding will trigger an infinite loop, but rest assured: Aurelia's binding module will take care of that.

Getting Started

First, let's follow the documentation and create an empty custom attribute:

import {customAttribute, inject, bindingMode} from 'aurelia-framework';

@customAttribute('focus', bindingMode.twoWay)
@inject(Element)
export class Focus {  
  constructor(element) {
      this.element = element;
  }

  valueChanged(newValue) {
  }
}

The first and easiest step is to listen for changes of the value property, and to react accordingly, by focusing or bluring the target element. This is done inside the valueChanged method:

valueChanged(newValue) {  
  if (newValue) {
      this.element.focus();
  } else {
      this.element.blur();
  }
}

Pretty simple! Now, when our view model's hasFocus property changes, the input is properly focused or blured.

Next, the attribute's value property needs to be updated when the input receives or loses focus after a user action. To do this, we need to register event listeners on the element. As mentioned in the documentation, this should be done in the attached() method:

attached() {  
  this.element.addEventListener('focus', e => this.value = true);
  this.element.addEventListener('blur', e => this.value = false);
}

Still pretty straight forward, right? Yet, something's still missing: the event listeners need to be removed when the element is detached() from the document:

detached() {  
  this.element.removeEventListener('focus', e => this.value = true);
  this.element.removeEventListener('blur', e => this.value = false);
}

Now if you go and test what we have so far, and you are running this on a setup that doesn't fully support ECMAScript 6 and uses a transpiler (like Babel), you will see the removeEventListener calls don't work. This is because the attached() and detached() methods get transpiled this way:

function attached() {  
  var _this2 = this;
  this.element.addEventListener('focus', function(e) { _this2.value = true; });
  this.element.addEventListener('blur', function(e) { _this2.value = false; });
}

function detached() {  
  var _this3 = this;
  this.element.removeEventListener('focus', function(e) { _this3.value = true; });
  this.element.removeEventListener('blur', function(e) { _this3.value = false; });
}

As you can see, the removed listeners are are not the same as the added ones. Let's try and make them methods, to see if it works:

onFocus(e) {  
  this.value = true;
}

onBlur(e) {  
  this.value = false;
}

attached() {  
  this.element.addEventListener('focus', this.onFocus);
  this.element.addEventListener('blur', this.onBlur);
}

detached() {  
  this.element.removeEventListener('focus', this.onFocus);
  this.element.removeEventListener('blur', this.onBlur);
}

It still doesn't work: when the onFocus and onBlur listeners are called by the browser, this doesn't contain the Focus instance but the element that fired the event. That's actually a common mistake; I should have known better. Let's solve this issue by creating instance functions that capture this in their scope:

constructor(element) {  
  this.element = element;

  this.focusListener = e => this.value = true;
  this.blurListener = e => this.value = false;
}

attached() {  
  this.element.addEventListener('focus', this.focusListener);
  this.element.addEventListener('blur', this.blurListener);
}

detached() {  
  this.element.removeEventListener('focus', this.focusListener);
  this.element.removeEventListener('blur', this.blurListener);
}

That works fine now.

Fine-tuning

We now have a Focus custom attribute that answers to all of our initial requirements. But if you play a little bit with it, you will see that there are still some edge cases that are not yet covered.

Interaction With Other Attributes

By definition, a custom attribute is used to decorate an element, so it has to work along fine with other custom attributes that can be on the same element.

What if the view model property bound to our focus attribute is also bound to the show attribute? This will be problematic, because depending on the order of evaluation when the bound property turns to true, the target element may not be visible yet when our attribute tries to give it focus. We can solve this problem by using another part of Aurelia's API: the TaskQueue class.

import {customAttribute, inject, bindingMode, TaskQueue} from 'aurelia-framework';

@customAttribute('focus', bindingMode.twoWay)
@inject(Element, TaskQueue)
export class Focus {  
  constructor(element, taskQueue) {
    this.element = element;
    this.taskQueue = taskQueue;
  }

  giveFocus() {
    this.taskQueue.queueMicroTask(() => {
      if (this.value) {
          this.element.focus();
      }
    });
  }

  valueChanged(newValue) {
    if (newValue) {
      this.giveFocus();
    } else {
      this.element.blur();
    }
  }
} 

In the above snippet, I first added injection of the TaskQueue instance into the Focus constructor. I also added a giveFocus() method, which will enqueue a microtask responsible for giving focus to the element. This will actually delay the focus() call by pushing it to the end of the binding queue. This will ensure that all queued events, including the bound property's value change, are processed before the focus is given.

Handling Window Change

You may have noticed that our Focus custom attribute does not react correctly when the element is focused and you change browser tabs. How can we fix that?

constructor(element) {  
  this.element = element;

  this.focusListener = e => this.value = true;
  this.blurListener = e => {
    if (document.activeElement !== this.element) {
      this.value = false;
    }
  };
}

In the above code snippet, I changed the blurListener function, so that, when a blur event is triggered, the value is set to false only if the element is not the document's active element. This scenario occurs typically when you change tabs in the browser (or change window in the OS). By preventing setting value to false, we prevent valueChanged(false) from being called, which would call element.blur() and would make the document's active element to become the body, and therefore cause the element to have lost focus when you go back to the browser tab.

Summary

As you can see, it is pretty easy to create new features for Aurelia. We were able to quickly come up with a new focus attribute, thanks to Aurelia's modular and extensible design.

View Comments...