Tuesday, 26 September, 2017 UTC


Summary

We covered how to use Web Components in Vue apps, so it’s only fitting that we also go over using custom elements as part of Angular projects. After all, both Angular and Vue.js have seamless support for custom elements.
The content of this post is targeted at Custom Elements v1 and Angular 2+.
Simple Custom Element
Let’s start with a simple custom element. For this example, we’ll place our component in a components folder at the root of our Angular project.
If you're using a custom element that's published as a package on npm, then its even easier and you'd simply install it into your project using npm or Yarn.
Below is our simple custom element; a counter component that’s very similar to the one we built in this post and that we’ve expanded on in this post to explain styling of custom elements. The main additions here, which you’ll see highlighted in the snippet, are custom events that are dispatched when trying to go beyond the minimum or maximum values:
/components/fancy-counter.js
(function() { const template = document.createElement('template'); template.innerHTML = ` <style> :host { all: initial; display: block; contain: content; text-align: center; background: linear-gradient(to left, hotpink, transparent); max-width: 500px; margin: 0 auto; border-radius: 8px; transition: transform .2s ease-out; } :host([hidden]) { display: none; } button, span { font-size: 3rem; font-family: monospace; padding: 0 .5rem; } button { cursor: pointer; background: pink; color: black; border: 0; border-radius: 6px; box-shadow: 0 0 5px rgba(173, 61, 85, .5); } button:active { background: #ad3d55; color: white; } </style> <div> <button type="button" increment>+</button> <span></span> <button type="button" decrement>-</button> </div> `; class FancyCounter extends HTMLElement { constructor() { super(); this.increment = this.increment.bind(this); this.decrement = this.decrement.bind(this); this.attachShadow({ mode: 'open' }); this.shadowRoot.appendChild(template.content.cloneNode(true)); this.incrementBtn = this.shadowRoot.querySelector('[increment]'); this.decrementBtn = this.shadowRoot.querySelector('[decrement]'); this.displayVal = this.shadowRoot.querySelector('span'); this.maxReached = new CustomEvent('maxReached'); this.minReached = new CustomEvent('minReached'); } connectedCallback() { this.incrementBtn.addEventListener('click', this.increment); this.decrementBtn.addEventListener('click', this.decrement); if (!this.hasAttribute('value')) { this.setAttribute('value', 1); } } increment() { const step = +this.step || 1; const newValue = +this.value + step; if (this.max) { if (newValue > +this.max) { this.value = +this.max; this.dispatchEvent(this.maxReached); } else { this.value = +newValue; } } else { this.value = +newValue; } } decrement() { const step = +this.step || 1; const newValue = +this.value - step; if (this.min) { if (newValue < +this.min) { this.value = +this.min; this.dispatchEvent(this.minReached); } else { this.value = +newValue; } } else { this.value = +newValue; } } static get observedAttributes() { return ['value']; } attributeChangedCallback(name, oldValue, newValue) { this.displayVal.innerText = this.value; } get value() { return this.getAttribute('value'); } get step() { return this.getAttribute('step'); } get min() { return this.getAttribute('min'); } get max() { return this.getAttribute('max'); } set value(newValue) { this.setAttribute('value', newValue); } set step(newValue) { this.setAttribute('step', newValue); } set min(newValue) { this.setAttribute('min', newValue); } set max(newValue) { this.setAttribute('max', newValue); } disconnectedCallback() { this.incrementBtn.removeEventListener('click', this.increment); this.decrementBtn.removeEventListener('click', this.decrement); } } window.customElements.define('fancy-counter', FancyCounter); })();
This element is used like this:
<fancy-counter min="2" value="5" max="30" step="3"></fancy-counter> 
Polyfills
For your custom element to work on all browsers, you’ll have to include the polyfills for it. The simplest way is to include webcomponentsjs into your project:
$ yarn add @webcomponents/webcomponentsjs # or, using npm: $ npm install @webcomponents/webcomponentsjs
Then, you can simply add the desired set of polyfills to Angular’s polyfills.ts file. Here we’ll use the webcomponents-sd-ce.js set of polyfills, which polyfills custom elements and shadow DOM:
polyfills.ts
// ... /**************************************** * Zone JS is required by Angular itself. */ import 'zone.js/dist/zone'; // Included with Angular CLI. import '@webcomponents/webcomponentsjs/webcomponents-sd-ce.js'; // ...
For production, you'll probably also want to transpile your custom element's code and use the transpiled version of your element instead. Refer to this post for a more in-depth discussion about polyfilling and transpiling for Web Components.
Over to Angular
The only Angular-specific step we need to take to make Angular play well with Web Components is to add the CUSTOM_ELEMENTS_SCHEMA to our app module. Here you’ll see that we’re also importing our custom element:
app.module.ts
import { BrowserModule } from '@angular/platform-browser'; import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormsModule } from '@angular/forms'; import '../../components/fancy-counter'; import { AppComponent } from './app.component'; @NgModule({ declarations: [AppComponent], schemas: [ CUSTOM_ELEMENTS_SCHEMA ], imports: [BrowserModule, FormsModule], providers: [], bootstrap: [AppComponent] }) export class AppModule {}
With this in place, we can now use our custom element as if it was just a native element. Data binding and event handlers will just work right out of the box.
Usage
Here’s how we would use our fancy-counter element as part of a simple Angular app. First, our component template:
app.components.html
<label for="min">Min value:</label> <input id="min" type="number" [(ngModel)]="settings.min"> <label for="max">Max value:</label> <input id="max" type="number" [(ngModel)]="settings.max"> <label for="step">Step value:</label> <input id="step" type="number" [(ngModel)]="settings.step"> <fancy-counter [min]="settings.min" [max]="settings.max" [step]="settings.step" (maxReached)="handleMaxReached()" (minReached)="handleMinReached()"> </fancy-counter> <p *ngIf="msg && msg.length"> {{ msg }} </p>
We’ve bound the min, max and step properties of our custom element to dynamic values in our Angular component. We’re also binding the custom events from our fancy-counter to methods in our Angular component. Here’s what our app component class looks like:
app.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { msg: string; settings = { min: 0, max: 10, step: 1 }; handleMaxReached() { this.showMsg("Hold your horses! You're trying to go too high!"); } handleMinReached() { this.showMsg("Can't go any lower!"); } private showMsg(message: string) { this.msg = message; setTimeout(() => { this.msg = null; }, 2000); } }
A message will appear for 2 seconds when the user tries to go over or under the set limits.
🍰 And there you have it! An easy and seamless way to integrate Web Components in Angular apps. Check out this presentation from @rob_dodson and @stephenfluin if you're interested in learning even more about the topic.