Attribute mapping

Learn about binding values to attributes of DOM elements and how to extend the attribute mapping with great ease.

When dealing with Aurelia and custom elements, we tend to use the @bindable decorator to define bindable properties. The bindable properties are members of the underlying view model class. However, there are cases where we want to work directly with attributes of the DOM elements.

For example, we want an <input> element with a maxlength attribute and map a view model property to the attribute. Let us assume that we have the following view model class:

export class App {
  private inputMaxLength: number = 10;
  private input: HTMLInputElement;
}

Then, intuitively, we would write the following template:

<input maxlength.bind="inputMaxLength" ref="input">

This binds the value to the maxlength attribute of the <input> element. Consequently, the input.maxLength is also bound to be 10. Note that binding the value of the maxLength attribute also sets the value of the maxLength property of the input element. This happens because Aurelia, in the background, does the mapping for us.

On a broad level, this is what attribute mapping is about. This article provides further information about how it works and how to extend it.

How it works

To facilitate the attribute mapping, Aurelia uses IAttrMapper, which has information about how to map an attribute to a property. While creating property binding instructions from binding commands, it is first checked if the attribute is a bindable. If it is a bindable property, the attribute name (in kebab-case) is converted to the camelCase property name. However, the attribute mapper is queried for the target property name when it is not a bindable. If the attribute mapper returns a property name, then the property binding instruction is created with that property name. Otherwise, the standard camelCase conversion is applied.

If we want to bind a non-standard <input> attribute, such as fizz-buzz, we can expect the input.fizzBuzz property to be bound. This looks as follows.

<input fizz-buzz.bind="someValue" ref="input">
export class App {
  private someValue: number = 10;
  private input: HTMLInputElement;

  public attached(): void {
    console.log(this.input.fizzBuzz); // 10
  }
}

Extending the attribute mapping

The attribute mapping can be extended by registering new mappings with the IAttrMapper. The IAttrMapper provides two methods for this purpose. The .useGlobalMapping method registers mappings applicable for all elements, whereas the .useMapping method registers mapping for individual elements.

To this end, we can grab the IAttrMapper instance while bootstrapping the app and register the mappings (there is no restriction, however, on when or where those mappings are registered). An example might look as follows.

import {
  AppTask,
  Aurelia,
  IAttrMapper,
} from '@aurelia/runtime-html';

const au = new Aurelia();
au.register(
  AppTask.creating(IAttrMapper, (attrMapper) => {
    attrMapper.useMapping({
      'MY-CE': {
        'fizz-buzz': 'FizzBuzz',
      },
      INPUT: {
        'fizz-buzz': 'fizzbuzz',
      },
    });
    attrMapper.useGlobalMapping({
      'foo-bar': 'FooBar',
    });
  })
);

In the example above, we are registering a global mapping for foo-bar attribute to FooBar property, which will apply to all elements. We are also registering mappings for individual elements. Note that the key of the object is the nodeName of the element; thus, for an element, it needs to be the element name in upper case. In the example above, we map the fizz-buzz attribute differently for <input> and <my-ce> elements.

With this custom mapping registered, we can expect the following to work.

<input fizz-buzz.bind="42" foo-bar.bind="43" ref="input">
<my-ce fizz-buzz.bind="44" foo-bar.bind="45" ref="myCe"></my-ce>
export class App {
  private input: HTMLInputElement;
  private myCe: HTMLElement;

  public attached(): void {
    console.log(this.input.fizzbuzz); // 42
    console.log(this.input.FooBar); // 43
    console.log(this.myCe.FizzBuzz); // 44
    console.log(this.myCe.FooBar); // 45
  }
}

Use two-way binding for attribute

In addition to registering custom mappings, we can teach the attribute mapper when using two-way binding for an attribute. To this end, we can use the .useTwoWay method of the IAttrMapper. The .useTwoWay method accepts a predicate function determining whether the attribute should be bound in two-way mode. The predicate function receives the attribute name and the element name as parameters. If the predicate function returns true, then the attribute is bound in two-way mode, otherwise it is bound in to-view mode.

An example looks as follows.


import {
  AppTask,
  Aurelia,
  IAttrMapper,
} from '@aurelia/runtime-html';

const au = new Aurelia();
au.register(
  AppTask.creating(IAttrMapper, (attrMapper) => {
    // code omitted for brevity
    attrMapper.useTwoWay(
      (el, attr) => el.tagName === 'MY-CE' && attr == 'fizz-buzz'
    );
  })
);

In this example, we are instructing the attribute mapper to use two-way binding for fizz-buzz attribute of <my-ce> element. This means that the following will work.

<my-ce
    ref="myCe"
    foo-bar.bind="myCeFooBar"
    fizz-buzz.bind="myCeFizzBuzz"></my-ce>

myCeFizzBuzz: ${myCeFizzBuzz} myCeFooBar: ${myCeFooBar}
export class MyApp {
  private myCeFooBar: any = 'fizz';
  private myCeFizzBuzz: any = '2424';
  private myCe: HTMLElement & { FooBar?: string; FizzBuzz?: string };
  public attached() {
    setInterval(() => {
      // This change will trigger a change for the myCeFizzBuzz property
      this.myCe.FizzBuzz = Math.ceil(Math.random() * 10_000).toString();

      // This change won't trigger a change for the myCeFooBar property
      this.myCe.FooBar = Math.ceil(Math.random() * 10_000).toString();
    }, 1000);
  }
}

Live example

A similar example can be seen in action below.

Last updated