In a recent project I was asked to integrate a 3rd party web components library (using Polymer) into an existing Angular host single page application (SPA). Simply embedding Web Components in Angular templates of course worked right out of the box, however achieving two-way communication between Angular and Polymer required a little more work.

Now it is true that generally speaking Angular and Web Components are competitive frameworks (just as React and Angular are), yet that still doesn’t mean we can’t use them together.

In this post I’ll show you how to bridge communication between an Angular 1 host application and child web components. Specifically I will show you how to achieve:

  • One-way data binding (Angular host → Web Component)
  • Two-way data binding (Web Component → Angular host)
  • Invoking callbacks (Web Component → Angular host)

Web Components – a Brief Overview

In a nutshell, Web Components are a set of standard features the W3C is currently working on, to allow the creation of reusable components in web applications, promoting the component model – encapsulation, interoperability and reusability. These features include:

  • Custom Elements – APIs for defining proprietary/custom HTML elements with logic, attributes and properties
  • Shadow DOM Support – a browser’s ability to render a subtree of DOM elements, which are otherwise hidden from the normal DOM.
  • HTML Imports – methods for declaring and importing HTML modules/documents into other documents
  • HTML Templates – A new element allowing documents to insert other HTML snippets

With web components, the Shadow DOM is the framework, while HTML is the template system.

Since Web Components are not just here yet, and while the W3C is slowly (since 2011) working on agreeing upon and formalizing the standards, Google has in the meantime provided Polymer – a lightweight polyfill library for creating custom web components today.

In this post I’ll thus use the terms “Web Components” and Polymer interchangeably.

A Simple Web Component

Let’s create a simple stock-item web component which we’ll use throughout this post:

<link rel="import" href="/bower_components/polymer/polymer.html">

<dom-module id="stock-item">
   <template>
       <style is="custom-style">
           .item-container {
               padding: 20px;
               border: 1px solid #b8b8b8;
           }
           .image-thumb {
               height: 150px;
               width: auto;
           }
       </style>

       <div class="item-container">
           <h3>Type: <span>{{type}}</span></h3>
           <h4>Model: <span>{{model}}</span></h4>
           <h4>Serial: <span>{{serial}}</span></h4>

           <template is="dom-if" if="{{imageUrl}}">
               <img class="image-thumb" src="{{imageUrl}}">
           </template>
       </div>
   </template>

   <script>
       Polymer({
           is: 'stock-item',
           properties: {
               type: String,
               model: String,
               serial: String,
               imageUrl: String,
           }
       });
   </script>

</dom-module>
The code is pretty much self explanatory. We’re declaring a simple module containing a web component, definition of its data attributes, scoped custom styling, and an HTML template conditional a conditional template block.

One Way Data Binding

Now let’s incorporate our web component in our Angular application. We’re going to use it to render a list of stock items from our Angular scope (controller):
<stock-item ng-repeat="item in ctrl.stockItems" 
            type="{{item.type}}" model="{{item.model}}"
            serial="{{item.serial}}" image-url="{{item.imageUrl}}"
></stock-item>
As far as Angular is concerned there is nothing special here, and this actually works out of the box. Now, every time our stockItems model changes (in our Angular scope) it will be automatically reflected in the item-container web component.

Note, that if we were to do the opposite- include Angular templates within web components then we’d have a problem – clashing  expression interpolation symbols (since both Polymer and Angular use {{ …  }} for expression interpolation). In that case a possible solution would be using Angular’s $interpolateProvider to define different the start and end symbols (e.g. $[ … ]$ ).

Two Way Data Binding

For two-way binding to work (that is, allow model changes in the web component to propagate up to the Angular model) we’ll need to do two things:

First we tell the web component to reflect property changes up to its attributes, for example:

...
Polymer({
   is: 'stock-item',
   properties: {
       type: {
           type:String,
           reflectToAttribute: true
       },
       model: {
           type:String,
           reflectToAttribute: true
       },
...
And secondly, on Angular side we need to intercept the wec component’s attribute changes and update the corresponding controller model accordingly. For that magic to happen we can use a custom directive (such as this one) to watch for attribute changes and apply them back into the original Angular scope model (note below the bind-polymer attribute):
<stock-item ng-repeat="item in ctrl.stockItems" 
            type="{{item.type}}" model="{{item.model}}"
            serial="{{item.serial}}" image-url="{{item.imageUrl}}"
            bind-polymer
></stock-item>
We now have two-way binding working, Angular → Polymer → Angular.

Invoking Callbacks

Invoking custom host methods (callbacks) from the hosted element is an essential communication flow pattern. In fact, in most cases this is the preferred way of letting a child component interact with its host component, as it promotes modularity, reusability and data immutability.

Web components can raise custom events by calling fire() along with named arguments. Let’s amend our web stock-item web component to include a delete button and click handler which trigger a deleteItem event:

<template>
…
<button on-click="deleteItem">DELETE ITEM</button>
...
</template>

<script>
Polymer({
   is: 'stock-item',
   properties: {
	...
   },
   deleteItem: function() {
       this.fire('deleteItem', { serial: this.serial });
   }
});
</script>
On the Angular host side we now need to attach an event handler:
<stock-item ng-repeat="item in ctrl.stockItems" 
            type="{{item.type}}" model="{{item.model}}"
           serial="{{item.serial}}" image-url="{{item.imageUrl}}"
           on-delete-item="ctrl.deleteItem(serial)"
           bind-polymer
           bind-polymer-events
></stock-item>
Note the bind-polymer-events attribute – this is a custom, home-made Angular directive that automatically attaches event handlers to all on-xxx events, and invokes the corresponding Angular callback scope function, passing in the respective named arguments.
angular
   .module('appModule')
   .directive('bindPolymerEvents', ['$parse', function ($parse) {
       'use strict';

       return {
           restrict: 'A',
           scope: false,

           link: function (scope, element, attrs) {
               for (let prop in attrs) {
                   const dash_prop = prop.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
                   if (dash_prop.startsWith('on-')) {
                       const callback = $parse(attrs[prop]);
                       const eventName = attrs.$normalize(dash_prop.substr(3));

                       // create and bind an event handler
                       const eventHandler = event => {
                           scope.$apply(() => {
                               callback(scope, event.detail);
                           });
                       };
                       element.bind(eventName, eventHandler);

                       // create an event handler removal function and call it when the scope dies
                       const unbindEventHandler = function () {
                           element.unbind(eventName, eventHandler)
                       };
                       scope.$on('$destroy', unbindEventHandler);
                   }
               }
           }
       };
   }]);
Now we have most of the plumbing laid out and all we need to do is implement the custom event handler in our Angular controller:

function AppController($scope) {
   $scope.ctrl = this;
 
   this.stockItems = [...];
   
   this.deleteItem = function(serial) {
       this.stockItems = this.stockItems.filter( item => item.serial !== serial );
   }
}
Hope I didn’t get you out of your element ;-)

-Zacky