Adding a View to a Custom Attribute in Aurelia

Have you ever wanted to add a view to a custom attribute within Aurelia?

Tuesday February 21, 2017 - Permalink - Category: aurelia - Tags: custom-attribute, viewEngine, templating

Introduction

I was recently working on a datetimepicker. I didn't want to limit the component to a single element, or use a variable to determine which element to use. I needed the datetimepicker to be attachable to any element. Which meant my only option was to use a custom attribute.

The issue was how do I insert a templated view? By default custom attributes do not have a view. After some research into the depths of Aurelia's template repository I found the answer which I will share with you today.

The beautiful thing about Aurelia is after you've gotten familar with the framework it's relatively easy to complete complex tasks.

So let's create a custom attribute that uses a view. For this example let's say when an element is focused or clicked a popup appears with a selectable list.

Creating our View

First let's create a templated view which will show our list.

picker.html

<template>
  <require from="./picker.css"></require>
  <div class="picker">
    <ul>
      <li repeat.for="item of items" click.trigger="pick(item)" class="${value === item ? 'active' : ''}">${item}</li>
    </ul>
  </div>
</template>

Our view iterates over the items. When a user clicks it calls pick() with the item. The selected item will have a class of active.

We'll need some CSS in case our picker's list overlaps an existing element.

picker.css

.picker {
  background-color: #fff;
  box-shadow: 0px 0px 10px 2px rgba(40,40,40,0.25);
  border: 1px solid #e4e4e4;
}
.picker > ul {
  list-style: none;
  margin: 0px;
  padding: 0px;
}
.picker > ul > li {
  line-height: 1.5em;
  cursor: pointer;
  padding: 0.5em
}
.picker > ul > li:hover, .picker > ul > li.active {
  background: #c7c7c7;
}

OK! We're all set to get to the meat and potatoes of what you really came here for!

Creating our Custom Attribute

Main course time! We're creating a custom attribute named 'picker'. The custom attribute will have two bindable properties: 'value' and 'items'. When a user interacts by clicking or giving focus to an input it will display the list of items. When a user selects one of the items it will update the property 'value' with the selected item.

We'll start by injecting what we require from Aurelia to insert the view. 

picker.ts

import { inject, bindable, Container, ViewEngine, bindingMode} from 'aurelia-framework';
import { DOM } from 'aurelia-pal';

@inject(Element, Container,  ViewEngine)
export class PickerCustomAttribute {

  constructor(private element: any, private container: Container, private viewEngine: ViewEngine) {}
}

We are injecting:

  • Element - the HTML element with our custom attribute.
  • Container - the current Container. We need this to create a child container for our view.
  • ViewEngine - Aurelia's templating View Engine. This is what we'll use to dynamically insert the view and take care of binding.

We're also importing DOM so that we can create insert and delete the actual elements.

Inserting the View

Alright everything we need is injected. We'll add some more variables for our view and include the functions to add and remove the picker. 

import { inject, bindable, Container, ViewEngine, bindingMode} from 'aurelia-framework';
import { DOM } from 'aurelia-pal';

@inject(Element, Container,  ViewEngine)
export class PickerCustomAttribute {
  show = false;
  divElement: HTMLElement;
  @bindable({ defaultBindingMode: bindingMode.twoWay }) value: any;
  @bindable({ defaultBindingMode: bindingMode.twoWay }) items: Array<any>;

  constructor(private element: any, private container: Container, private viewEngine: ViewEngine) { }

  createPicker() {
    this.viewEngine.loadViewFactory('resources/attributes/picker.html').then(factory => {
      const childContainer = this.container.createChild();
      const view = factory.create(childContainer);

      view.bind(this);

      this.createElement(view)
      this.setPosition()

      if (this.isInputElement)
        document.addEventListener('mouseup', this.mouseupListener);

      this.show = true;
    })

  }

  removePicker() {
    const body = DOM.querySelectorAll('body')[0];
    body.removeChild(this.divElement);

    if (this.isInputElement)
      document.removeEventListener('mouseup', this.mouseupListener);

    this.show = false;

  }

}

Nowe we add these variables:

  • show - this will allow us to determine if the picker is on the DOM.
  • divElement - we will insert the picker view into this div element.

loadViewFactory will load and compile a ViewFactory from a url. It returns a promise with the compiled ViewFactory.

Once we get the returned factory, we use it to create the view. With the view we bind it to our PickerCustomAttribute context. This allows our template to access the properties on our model.

Now it's time to insert the compiled view into the DOM. We'll add some more functions.

picker.ts

  isInputElement() {
    return this.element.nodeType === 1 && this.element.tagName.toLowerCase() == 'input';
  }
  
  inElement(e) {
    let containerRect = this.divElement.getBoundingClientRect();
    let elementRect = this.element.getBoundingClientRect();
    let inContainerRect = e.clientX > containerRect.left && e.clientX < containerRect.right && e.clientY > containerRect.top && e.clientY < containerRect.bottom;
    let inElementRect = e.clientX > elementRect.left && e.clientX < elementRect.right && e.clientY > elementRect.top && e.clientY < elementRect.bottom;
    return inContainerRect && inElementRect
  }
  
  addElement(view: View) {
    const body = DOM.querySelectorAll('body')[0];

    this.divElement = <HTMLElement>DOM.createElement('div');
    view.appendNodesTo(this.divElement);


    const elementRect = this.element.getBoundingClientRect();
    const left = elementRect.left + window.scrollX;
    const height = this.divElement.getBoundingClientRect().height;
    var top = elementRect.top + elementRect.height;

    top = ((top+height) < window.innerHeight) ? top + window.scrollY : (elementRect.top - height + window.scrollY);

    this.divElement.style.top = top + 'px';
    this.divElement.style.left = left + 'px';
    this.divElement.style.position = 'absolute';
    this.divElement.style.zIndex = '2001';
    
    body.insertBefore(this.divElement, body.firstChild);
  }

Here we are inserting the newly created div (which contains our picker view) into the body element, before the first child. We set the top and left and give it a position of absolute with a z-index.

We're using view.appendNodesTo to actually insert our compiled view into the created div element.

We create a function to determine if we're attached to an input element and a function to determine if a click is outside the div client bounds.

Adding Event Listeners

Finally we add in some event listeners to call createPicker() and removePicker() depending on user interaction. We also use the attach() and dettach() methods to add and remove the event listeners.

Our picker custom attribute's file will now look like:

picker.ts

import { inject, bindable, Container, ViewEngine, View, bindingMode} from 'aurelia-framework';
import { DOM } from 'aurelia-pal';

@inject(Element, Container,  ViewEngine)
export class PickerCustomAttribute {
  show = false;
  mouseupListener: EventListener;
  focusListener: EventListener;
  divElement: HTMLElement;
  @bindable({ defaultBindingMode: bindingMode.twoWay }) value: any;
  @bindable({ defaultBindingMode: bindingMode.twoWay }) items: Array<any>;

  constructor(private element: any, private container: Container, private viewEngine: ViewEngine) {
    this.mouseupListener = e => this.handleMouseUp(e);
    this.focusListener = e => this.handleFocus(e);
  }

  attached() {
    if (this.isInputElement()) {
      this.element.addEventListener('focus', this.focusListener);
      this.element.addEventListener('blur', this.focusListener);
    } else {
      this.element.addEventListener('click', this.mouseupListener);
    }
  }

  detached() {
    if (this.isInputElement) {
      this.element.removeEventListener('focus', this.focusListener);
      this.element.removeEventListener('blur', this.focusListener);
    } else {
      this.element.removeEventListner('click', this.mouseupListener);
    }
    document.removeEventListener('mouseup', this.mouseupListener);
  }

  pick(item) {
    this.value = item;
    if (this.isInputElement()) {
      //Since we're not binding the value on the input element we update it here.
      this.element.value = item;
    }

    this.removePicker();
  }
  
  handleMouseUp(e) {
    if (!this.isInputElement() && !this.show) {
      this.createPicker();
    }
    if (this.show && !this.inElement(e)) {
        this.removePicker();
    }
  }

  handleFocus(e) {
    if (e.type === 'focus' && !this.show) {
      this.createPicker();
    }
    if (e.type === 'blur') {
      if (this.isInputElement() && this.element.value !== this.value && typeof this.value !== "undefined") {
        this.element.value = this.value;
      }
    }
  }

  createPicker() {
    this.viewEngine.loadViewFactory('resources/attributes/picker.html').then(factory => {
      const childContainer = this.container.createChild();
      const view = factory.create(childContainer);

      view.bind(this);

      this.createElement(view)
      this.setPosition()

      if (this.isInputElement)
        document.addEventListener('mouseup', this.mouseupListener);

      this.show = true;
    })

  }

  removePicker() {
    const body = DOM.querySelectorAll('body')[0];
    body.removeChild(this.divElement);

    if (this.isInputElement)
      document.removeEventListener('mouseup', this.mouseupListener);

    this.show = false;

  }
  
  isInputElement() {
    return this.element.nodeType === 1 && this.element.tagName.toLowerCase() == 'input';
  }
  
  inElement(e) {
    let containerRect = this.divElement.getBoundingClientRect();
    let elementRect = this.element.getBoundingClientRect();
    let inContainerRect = e.clientX > containerRect.left && e.clientX < containerRect.right && e.clientY > containerRect.top && e.clientY < containerRect.bottom;
    let inElementRect = e.clientX > elementRect.left && e.clientX < elementRect.right && e.clientY > elementRect.top && e.clientY < elementRect.bottom;
    return inContainerRect && inElementRect
  }
  
  createElement(view: View) {
    const body = DOM.querySelectorAll('body')[0];

    this.divElement = <HTMLElement>DOM.createElement('div');
    view.appendNodesTo(this.divElement);
    body.insertBefore(this.divElement, body.firstChild);
  }

  setPosition() {
    const elementRect = this.element.getBoundingClientRect();
    const left = elementRect.left + window.scrollX;
    const height = this.divElement.getBoundingClientRect().height;
    var top = elementRect.top + elementRect.height;

    top = ((top+height) < window.innerHeight) ? top + window.scrollY : (elementRect.top - height + window.scrollY);

    this.divElement.style.top = top + 'px';
    this.divElement.style.left = left + 'px';
    this.divElement.style.position = 'absolute';
    this.divElement.style.zIndex = '2001';
  }
}

Putting It All Together

Great! We've got our picker custom attribute. Now it's time to see it in action. We'll create a button and an input element within our App.

app.html

<template>
  <h1>Custom Attribute with a View</h1>
  <button picker="value.bind:buttonValue; items.bind:buttonList">Click me</button>
  <p>The value of button is: ${buttonValue}</p>
  <input type="text" picker="value.bind:inputValue; items.bind:inputList">
  <p>the value of input is: ${inputValue}</p>
</template>

We've got our button and input. Each one has a picker attribute with the two bindings. Note that for the input element we do not include value.bind as we're updating the value within our picker custom attribute.

Under each element we will be able to see what the selected value is from our list of items.

Now we just create the properties in our app view model and we're good to try it out.

app.ts

import { bindable } from 'aurelia-framework';

export class App {
  @bindable inputValue: any;
  @bindable buttonValue: any;
  @bindable inputList = [
    "Chocolate",
    "Vanilla",
    "Orange"
  ]
  @bindable buttonList = [
    "Jump",
    "Run",
    "Skip",
    "Walk"
  ]
}

Conclusion

Well how about that, together we created a simple dropdown menu that can be used on any element.

Harnessing the power of Aurelia's View Engine you could insert templated view's anywhere in the DOM. You don't need a custom attribute.

You could create a service that inserts a templated view onto the DOM for whatever your needs. The benefit is you get to use Aurelia's templating to limit the amount of code you need to use!

I've put together a github repo for this example in case you want to quickly demo it out. You can view it here

comments powered by Disqus