Introduction

If you're familiar with working with Aurelia custom attributes, you'll know that you can have the Element that the attribute is attached to injected in to your attribute's viewmodel. But did you know that you can actually determine if your custom attribute is attached to an Aurelia custom element or simply to a "normal" DOM element? Let's learn how!

Creating Our Custom Attribute

First, let's imagine we have a custom attribute named custom-config and a custom element named say-hello. What custom-config does is take a list of options and set them on the element its attached to. If the element is a custom element, it will set the properties on the custom element's viewmodel, otherwise it will set html attributes on the DOM element. The custom element say-hello has two bindable properties, firstName and lastName. It says "Hello" to the person named by its bindable properties.

say-hello.html

<template>
  <p>
    Hello, ${fullName}!
  </p>
</template>

say-hello.js

import {bindable, customElement} from 'aurelia-framework';

@customElement('say-hello')
export class SayHello {
  @bindable firstName = '';
  @bindable lastName = '';
  
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

Now, in our page's view, we have two instances of say-hello and one div element.

<say-hello first-name="Ashley" last-name="Grant"></say-hello>
<say-hello custom-config="firstName.bind: fn; lastName.bind: ln"></say-hello>
<div custom-config="style: border: solid 1px red;">
  Hello
</div>

To see this code running, check out the gist.run here.

Working with Both Custom and Standard Elements

So, how would our custom-config attribute manage to deal with both firstName and lastName as well as handling style? Well, it needs to determine if it's working with an Aurelia custom element or just a plain ol' DOM element. How do we do that? Well, Aurelia, before it injects the Element instance in to a custom attribute, attaches an au property to the Element. When the element is not an Aurelia custom element, this will be an empty object. But, when it is a custom element, this object will have a controller property. The controller property is an instance of Aurelia's Controller class. The Controller class has a viewModel property, which is the custom element's viewmodel! Now we're cooking with gas!

But we still haven't learned how to deal with arbitrary options being passed to custom-config. This is where Aurelia's dynamicOptions decorator comes in handy. Using dynamic options on a custom attribute allows it to deal with options that aren't known at development time.

Let's look at the code for custom-config

import {inject, dynamicOptions} from 'aurelia-framework';

@dynamicOptions
@inject(Element)
export class CustomConfigCustomAttribute {
  constructor(element) {
    if( typeof(element.au.controller) === 'object' && typeof(element.au.controller.viewModel) === 'object') {
      this.isCustomElement = true;
      this.elementVM = element.au.controller.viewModel;
    }
    else {
      this.isCustomElement = false;
      this.element = element;
    }
  }
  
  propertyChanged(name, value) {
    if(this.isCustomElement === true ) {
      this.elementVM[name] = value;      
    }
    else {
      this.element.setAttribute(name.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase(), value);
    }
  }
}

In the constructor, the DOM element is injected. We check to determine if the element has a au.controller.viewModel property set. If so, we're working with an Aurelia custom element, otherwise we're working with a standard DOM element. Aurelia will call the propertyChanged method whenever the value of an option changes. If the attribute is working with a custom element, we set the JavaScript property on the element's VM. Otherwise, we convert the option name to dash case and set the attribute value on the DOM element (I looked up a quick and dirty regex to do this, so don't yell if it isn't perfect!).

How you might use this is up to you, but it is pretty awesome how simple this was to do with Aurelia!

Tying an Attribute to a Specific Type of Custom Element

What we've done here creates a very generic custom attribute that can work with any element. What if I wanted my custom attribute to work only with the say-hello element, and throw an error otherwise? This could be really useful if you're creating a custom attribute that is used to help configure a custom element. But we need to do this in a way that won't be confused if, by chance, there are two custom elements in a view that have the same name (before at least one of them is aliased).

We're going to use Aurelia's Dependency Injection (DI) provider to accomplish this. Aurelia creates a child DI container for each component being displayed, and by default component VMs are singletons within their container. Thus, if a custom attribute tells Aurelia to inject an instance of a custom element's VM and the custom attribute is located on that custom element, then Aurelia will inject the custom element's VM instance. I know what you're wondering (because I wondered it myself): What if my custom attribute gets attached to a custom element inside of the proper custom element like below.

<foo>
  <bar my-attribute="..."></bar>
</foo

Won't Aurelia's DI container just pull the VM instance from the parent element? Nope. Inside a container, you have to explicitly tell Aurelia to look for an instance from the container's parent container. This is awesome for us, because if we tell Aurelia to inject a custom element's VM in to our custom attribute, we can be sure that it is either the VM for the custom element our attribute is attached to, or it's simply an empty VM (only the constructor was run) that was created for injection. We can tell Aurelia to only give us an instance of the custom element's VM if it already exists in the container using the Optional.of() function. This way we can get rid of the "empty VM" scenario from above and simply check that the constructor argument is not null.

import {inject, dynamicOptions, Optional} from 'aurelia-framework';
import {SayHello} from "./say-hello-one";

@dynamicOptions
@inject(Optional.of(SayHello))
export class CustomConfigCustomAttribute {
  constructor(sayHello) {
    if( sayHello !== null ) {
      this.elementVM = sayHello;
    }
    else {
      throw new Error( 'Invalid Element. Must use say-hello.');
    }
  }

  propertyChanged(name, value) {
    this.elementVM[name] = value;
  }
}

Now we've got a custom attribute that will throw an error if a developer puts it on the wrong type of custom element. Think of the possiblities of using these techniques, for example, in a grid component. There could be custom attributes that alter the behavior of the custom element but that cannot be placed on other elements.

A Warning

It's important to note that the technique I just described will create a custom attribute that is tightly coupled to a custom element. Thus, be careful when doing this. This probably isn't a technique you should use willy-nilly all over your code, or your likely to create a maintenance headache for yourself. But, when applied judiciously, this technique can help you solve certain types of issues in an elegant way.

In my next post, I'll take this concept one step further and show how to create custom elements that will throw an error if they aren't inside of a specific kind of custom element.