Creating Reusable Custom Elements in Angular 6+

I first introduced myself to web components when I was working on a Polymer project. You can read about polymer and how to integrate it with ASP.NET Core here in my three-part blog series.

However, this post is very straightforward. I'm just going to create a reusable Angular component and use it in a different eco-system; say for example in a normal static HTML web app. To do that we must register the component as a custom element.

Walk with me

Setting up an Angular project


Install the very latest version of Angular CLI with the following command,

npm install -g @angular/cli@latest

Once installed; do an ng -version on the terminal to see if the CLI version is 6 or above.

Create a new Angular app with the following command,

ng new NgElement

The following command installs all the packages required to create custom elements in Angular,

ng add @angular/elements

The command also configures polyfills required to render the element in all major browsers.

Creating an Angular component


Following is the code snippet for a stand-alone subscriber/newsletter component made with Angular,

import {  
  Input,
  Component,
  ViewEncapsulation,
  EventEmitter,
  Output,
  OnInit
} from '@angular/core';

import {  
  FormArray,
  FormBuilder,
  FormGroup,
  FormControl,
  Validators,
  AbstractControl
} from '@angular/forms';

@Component({
  selector: 'app-subscriber',
  template: `
  <form [formGroup]="newsletterForm" (submit)="onSubmit()">
    <div class="container">
      <label>{{title}}</label>
      <input class="email" formControlName="email" id="email" type="email" placeholder="Your email"/>
      <div *ngIf="(email.dirty || email.touched) && email.invalid">
          <span *ngIf="email.errors.required">
            <i class="error">email address is required.</i>
          </span>
          <span *ngIf="email.errors.email">
            <i class="error">not a valid email addreess.</i>
          </span>
      </div>
      <button class="button" type="submit">Subscribe!</button>
      </div>
  </form>`,
  styles: [
    `
  .container{
    background:#9c88ff;
    width:400px;
    height:200px;
    display:flex;
    flex-direction: column;
    justify-content:center;
    align-items:center;
    border-radius:10px;
    box-shadow: 0 5px 5px 0px #8c7ae6;
  }
  label{
    margin: 10px;
    font-family: 'Satisfy', cursive;
    color: #fff;
    font-size: 28px;
  }
  .email{
    display: block;
    width: 292px;
    height: 30px;
    font-size: 18px;
    border-radius: 5px;
    border: 0px;
    padding: 4px;
    text-align: center;
    margin:5px;
    font-family: 'Satisfy', cursive
  }
  .button{
    width: 300px;
    height: 38px;
    background: #4cd137;
    font-size: 18px;
    border: 0px;
    border-radius: 5px;
    color: #fff;
    margin:5px;
    font-family: 'Satisfy', cursive;
  }
  .error{
    color: #fff;
  }
  `
  ],
  encapsulation: ViewEncapsulation.Native
})
export class SubscriberComponent implements OnInit {  
  @Input() title = 'default label';
  @Output() subscribe = new EventEmitter<string>();

  newsletterForm: FormGroup;

  get email() {
    return this.newsletterForm.get('email');
  }

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.newsletterForm = new FormGroup(
      {
        email: new FormControl('', {
          validators: [Validators.required, Validators.email]
        })
      },
      { updateOn: 'submit' }
    );
  }

  onSubmit() {
    if (this.newsletterForm.valid) {
      this.subscribe.emit(this.email.value);
    }
  }
}

Things to notice,

  • To ship this as an Angular element, the view encapsulation of the component should be set to native. encapsulation: ViewEncapsulation.Native

  • The component takes an @Input i.e. title. The title of the component can be passed from the parent component/dom within an attribute.

  • @Output subscribe emits the entered email address to its parent component/dom when the newsletter form is submitted.

Registering as a custom element


The following snippet is from the app.module.ts file,

import { BrowserModule } from '@angular/platform-browser';  
import { NgModule, Injector } from '@angular/core';

import { AppComponent } from './app.component';  
import { SubscriberComponent } from './subscriber/subscriber.component';  
import { createCustomElement } from '@angular/elements';  
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  declarations: [AppComponent, SubscriberComponent],
  imports: [BrowserModule, ReactiveFormsModule],
  providers: [],
  entryComponents: [SubscriberComponent]
})
export class AppModule {  
  constructor(private injector: Injector) {
    const subscriber = createCustomElement(SubscriberComponent, {
      injector
    });
    customElements.define('app-subscriber', subscriber);
  }

  ngDoBootstrap() {}
}

Things to notice

  • entryComponent array defines the set of components you want to ship as custom elements.

  • Notice there is no bootstrap array bootstrap: [AppComponent]; we will self-bootstrap the AppModule, hence we have ngDoBootstrap()

  • createCustomElement turns an Angular component into a custom element and we delegate the root injector into it.

  • customElements.define method registers the custom element with a custom tag e.g app-subscriber

Rendering the custom element


Inside Angular

You can render the component inside your index.html with the following code,

<app-subscriber title="Newsletter!"></app-subscriber>  
<script>  
    const subscriber = document.querySelector('app-subscriber');
    subscriber.addEventListener('subscribe', (event) => {
      console.log(`${event.detail}`);
    })
</script>  

To grab the emitted value from the subscribe, we attached a vanilla js event listener with the element. Once we have some value popup in the subscribe we can read it from the event.detail property.

Inside other frameworks

Out of the Angular app to render the element, we can run the following build command to have published script of the element,

ng build --prod

It will emit the published script inside the dist folder of your Angular app.

With other frameworks, (for me, it's a static HTML app) you can just add the generated scripts inside the <body> tag as follows,

<!DOCTYPE html>  
<html lang="en">

<head>  
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        #output {
            margin-top: 50px;
        }
    </style>

</head>

<body>  
    <app-subscriber title="Newsletter!"></app-subscriber>
    <div id="output">
        <p>Output:</p>
    </div>

    <script src="/NgElement/polyfills.c72d3210425a88b28b6d.js"></script>
    <script src="/NgElement/runtime.6afe30102d8fe7337431.js"></script>
    <script src="/NgElement/scripts.69c39fe5fecacc5138f1.js"></script>
    <script src="/NgElement/main.954d51cb4b7f27e2e6f6.js"></script>

    <script>
        const subscriber = document.querySelector('app-subscriber');
        subscriber.addEventListener('action', (event) => {
            var content = document.createTextNode(event.detail);
            var output = document.getElementById("output");
            var br = document.createElement("br");
            output.appendChild(content);
            output.appendChild(br);
        })
    </script>
</body>

</html>  

For a local development server, you can use the http-server package from npm. To install the package, run the following command,

npm i -g http-server.

You can initiate the server within your static HTML folder just by typing http-serverfrom the console.

Running it will give you the following output

Repository

https://github.com/fiyazbinhasan/Angular-Elements

Stackblitz