Matt Steele

Building Custom Elements That Work With AngularJS 1.x and Angular

08 Sep 2016

So Web Components! They've recently gotten a lot of love, and a lot of hate.

Let's talk about Custom Elements in particular. This is the technology that lets you drop <relative-time> on a page, and it renders into something usable and pretty. No framework required.

The Web Components proponents propose composing Web Components into existing frameworks, particularly as leaf nodes. So you'd write your Angular/React/whatever app that contained your business logic, and its templates would be made up of Custom Elements, standard HTML, and other Angular/React/whatever components.

I haven't seen this interop story in action (or documented) in many places. In particular, how do you build a Custom Element that'll work in both AngularJS (1.x) and Angular 2+ apps?

Note: I'll be using on the new "V1" version of the Custom Elements spec. Here's a good intro article, and here's a polyfill.

Building The Custom Element (View on GitHub)

Let's make a <countdown-timer> Custom Element.

You give it the number of seconds you want the timer to run, and it'll spit out an event (with a message) when the countdown ends.

We'll use a "one-way data flow" architecture - the element will accept its inputs via properties, and spit out DOM Events for its outputs. This is the architecture Angular uses, (and the recommended approach for modern Angular 1 apps.

Here's the element in all its dumb glory:

class CountdownTimer extends HTMLElement {
    connectedCallback() {
        const template = `
            <button class="countdown-start">Start the countdown</button>
            <span class="seconds-left"></span>
            `;
        this.innerHTML = template;

        // Useful references
        this.button = this.querySelector('.countdown-start');
        this.secondsDisplay = this.querySelector('.seconds-left');

        // Initialize
        this.button.addEventListener('click', () => this.handleClick());
    }

    handleClick() {
        this.updateTimer();
        this.button.disabled = true;
        this.button.innerHTML = 'YOU DID IT';
        this.updateTimer();
        const counter = window.setInterval(() => {
            this.seconds--;
            this.updateTimer();
            if (this.seconds === 0) {
                window.clearInterval(counter);
                console.info('BOOM');
            }
        }, 1000);
    }

    updateTimer() {
        this.secondsDisplay.innerHTML = this.seconds;
    }

}

window.customElements.define('countdown-timer', CountdownTimer);

Not much to it - initialize stuff in the connectedCallback hook, and then add your functionality.

Angular (View Demo)

Consuming this in Angular is pretty straightforward: you use the [prop]="value" syntax to bind to a property, and the (event)="handler()" syntax to bind to events.

A component that uses it might have a template that looks like:

    <p>
      <label>How long to count down? 
        <input [(ngModel)]="secondsLeft" type="number">
      </label>
    </p>
    <countdown-timer 
      [seconds]="secondsLeft" 
      (countdown-ended)="handleCountdownEnded($event.detail)">
    </countdown-timer>

One thing to watch out for, if you get an error like this:

Can't bind to 'seconds' since it isn't a known property of 'countdown-timer'.
1. If 'countdown-timer' is an Angular component and it has 'seconds' input, then verify that it is part of this module.
2. If 'countdown-timer' is a Web Component then add "CUSTOM_ELEMENTS_SCHEMA" to the '@NgModule.schema' of this component to suppress this message.

You'll need to add CUSTOM_ELEMENTS_SCHEMA to your @NgModule declaration, via:

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  // module boilerplate
    schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
})

AngularJS (1.x) (View Demo)

Out of the box, our Custom Element won't play nicely with AngularJS. There are two problems:

  • Its templating system binds to an element's attributes, while our component updates the element's properties
  • AngularJS isn't aware of the events your Custom Element fires, and doesn't hook into the normal & callback bindings

So! We can fix this in a couple different ways:

Option 1: Change the Custom Element

On the input side, we can add some code to our Custom Element that looks for attribute changes. There are lifecycle hooks in the spec for this:

static get observedAttributes() {
  return ['seconds'];
}

attributeChangedCallback(name, oldVal, newVal) {
  if (name === 'seconds') {
    this.seconds = newVal;
  }
}

And then bind to the attribute in our template (note the use of ng-attr to prevent the element from seeing the raw {{expression}}):

<countdown-timer ng-attr-seconds="{{$ctrl.secondsLeft}}"></countdown-timer>

On the output side, we can bind to the event manually in our controller, and wrap it in $scope.$apply() to make sure a digest runs:

$element.on('countdownEnded', (e) => {
  // If we don't do a digest, this doesn't get picked up immediately
  $scope.$apply(() => {
    this.message = e.detail.message;
  });
});

But this has limitations:

  • Binding to attributes means you're limited to passing String expressions to your Custom Element
  • You lose encapsulation by binding to the Custom Element's events in your Angular controller. If you wanted to write idiomatic AngularJS, you'd have to create a wrapper component that provided an on-countdown-ended attribute, and run a digest manually. But now you're writing wrapper components for every Custom Element you import, and we were trying to get away from that!
  • Plus, you're modifying your supposedly framework-agnostic Custom Element to satisfy a particular framework

So yeah, that sucks. Hope there's a better way!

Option 2: Use a Glue Directive

Should've read ahead in my post, I guess.

Rob Dodson wrote a set of directives to help Custom Elements interop with Angular 1. It's labelled for use with the Polymer project, but it'll work for any Custom Element, including ours.

Since we're using a one-way data flow, we can add the ce-one-way directive to our AngularJS template:

<countdown-timer ce-one-way
  seconds="$ctrl.secondsLeft" 
  on-countdown-ended="$ctrl.countdownEnded()"
></countdown-timer>

We did have to make one tweak to our Custom Element to make this work: we renamed the Event to countdown-ended, to match the naming pattern library expects. Could be worse, I suppose.

Framework Migration, and Framework Independence

At work, we maintain an enterprise component library, like many large orgs do. It's currently built as a set of Angular 1 directives. As we begin to investigate Angular, we want to make the migration process smooth as butter.

We could rebuild the component library as a set of Angular components, and AngularJS interoperability could come through the ngUpgrade module, probably.

But this begs the question: what happens when we ditch Angular? Our components would again be dependent on a single framework, and they'd have to be rewritten once again.

Building your company's component library on Custom Elements solves multiple problems.

Short-term, it helps you migrate from AngularJS 1.x to Angular, since both applications can use the same component library.

Long-term, it helps isolate your company's component library from the Sturm und Drang of front-end frameworks. So long as a framework interacts with the DOM (and they all do), they can use your components.

So you can support that weird Ember team, the stodgy server-rendered JSP folks, and even the framework-less static page hipsters.

So yeah, Custom Elements are awesome and you should give them a looskie. As Dion Almaer noted: How would the component landscape look if we weren’t all rebuilding our own houses?