Angular Elements

Angular Elements

Adding components to an application at runtime

The next explanations are based on this project. To get the most of this article it is recommended to not only read it, but to practice creating a similar project.

This is a demo of the application functionality:

Angular elements package, allows transforming angular components into custom elements. This transformation automatically maps the components view change detection and data binding with the corresponding HTML built-in equivalents.

Custom elements bootstrap themselves when are added to the DOM and are automatically destroyed when removed from the DOM. On the demo video you can see on logs that dynamic component is initialized and destroyed when tooltip is shown or hidden.

The component is transformed into a custom element, then is registered in the CustomElementRegistry and then we can use it when needed.

The mapping between the component and the custom element takes care of:

  • Input properties: Defines corresponding attributes in the custom element. Transforms the property names to attributes using dash separated lower case, because custom elements don't recognize case distinctions.

    E.g. @Input('myInputName') inputName => my-input-name

  • Output properties are dispatched as HTML custom events with the name of the custom event matching the output name.
    E.g. @Output('myEventName') myEvent => myEventName

Previously to create a dynamic component you need to take care of multiple steps that now are not necessary anymore with angular elements.

Angular advises against using the component selector as the custom element tag name.

To add the package to your project run the command:

npm install @angular/elements --save

The AppComponent imports ProductComponent and instantiates it in the template:

// app.component.ts
import { Component } from '@angular/core';
import { ProductComponent } from './product/product.component';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  imports: [CommonModule, ProductComponent],
})
export class AppComponent {}
<!-- app.component.html -->
<app-product></app-product>

ProductComponent shows an image of the product and the real time price. It adds the DynamicPriceComponent to the CustomElementsRegistry and passes an instance of it to the MatTooltipTemplateDirective.

// product.component.ts
import {
  Component,
  EnvironmentInjector,
  OnDestroy,
  OnInit,
} from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { matTooltipComponentText } from '../constants/constants';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
  NgElement,
  WithProperties,
  createCustomElement,
} from '@angular/elements';
import { DynamicPriceComponent } from './components/dynamic-price/dynamic-price.component';
import { BehaviorSubject, Subject, interval, noop, takeUntil, tap } from 'rxjs';
import { MatTooltipTemplateDirective } from '../directives/mat-tooltip-template.directive';

@Component({
  selector: 'app-product',
  standalone: true,
  templateUrl: './product.component.html',
  styleUrls: ['./product.component.scss'],
  imports: [
    CommonModule,
    FormsModule,
    // We input MatTooltipModule 
    MatTooltipModule,
    // We input MatTooltipTemplateDirective 
    MatTooltipTemplateDirective,
  ],
})
export class ProductComponent implements OnInit, OnDestroy {
  matTooltipComponentText = matTooltipComponentText;

  // We define price as a BehaviorSubject to get a reactive behavior
  // and see real time changes on template.
  price = new BehaviorSubject<number>(20);

  private _destroyed = new Subject<void>();

  constructor(private injector: EnvironmentInjector) {
    // Here we add the DyanicPriceComponent to the CustomElementRegistry
    // using the tag name 'dynamic-price-element'
    this.defineCustomElement();
  }

  ngOnInit(): void {
    // We update the price every second to see price changes on real time
    interval(1000)
      .pipe(
        tap(() => this.makeOffer()),
        takeUntil(this._destroyed)
      )
      .subscribe(noop);
  }

  ngOnDestroy(): void {
    this._destroyed.next();
    this._destroyed.complete();
  }

  private defineCustomElement() {
    const dynamicPriceElement = createCustomElement(DynamicPriceComponent, {
      injector: this.injector,
    });
    // We check if component has been added previously to the registry
    if (!customElements.get('dynamic-price-element')) {
      customElements.define('dynamic-price-element', dynamicPriceElement);
    }
  }

  createCustomElement() {
    const dynamicPriceEl: NgElement & WithProperties<DynamicPriceComponent> =
      document.createElement('dynamic-price-element') as any;

    // We assign input properties of 'DynamicPriceComponent'
    dynamicPriceEl.dynamicPrice$ = this.price;
    return dynamicPriceEl;
  }

  private makeOffer() {
    const newPrice = this.price.value + this.generateRandomNumber(-10, 10);
    if (newPrice < 0) {
      this.price.next(this.generateRandomNumber(0, 10));
    }
    this.price.next(newPrice);
  }

  private generateRandomNumber(min: number, max: number) {
    return Math.floor(Math.random() * (max - min + 1) + min);
  }
}
<!-- product.component.html -->
<div class="product">
<!-- In this div we set the matTooltip value because is required. we will 
remove it in the 'MatToolipTemplateDirective'. We also ad the matToolipTemplate,
and we pass to it the 'templateGenerator' function that returns the instance
of the real time generated component -->
  <div
    class="product-img"
    matTooltip="{{ matTooltipComponentText }}"
    matToolipTemplate
    [templateGenerator]="createCustomElement.bind(this)"
  >
    <p class="product-title">Dog biscuits</p>
    <img src="assets/img/biscuit.jpeg" />
  </div>
  <div class="product-price">
    <p>Real time price: {{ price.asObservable() | async }} $</p>
  </div>
</div>

The MatTooltipTemplateDirective appends the dynamically created component to the tooltip, after removing textContent:

// mat-tooltip-template.directive.ts
import {
  AfterViewInit,
  Directive,
  ElementRef,
  Input,
  OnDestroy,
  OnInit,
  Renderer2,
  ViewChild,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { Subject, delay, fromEvent, noop, takeUntil, tap } from 'rxjs';

@Directive({
  selector: '[matToolipTemplate]',
  standalone: true,
})
export class MatTooltipTemplateDirective implements OnInit, OnDestroy {
  @Input() templateGenerator!: () => Node;

  private _destroyed = new Subject<void>();

  constructor(
    private el: ElementRef,
    private renderer: Renderer2,
    private matTooltip: MatTooltip
  ) {}

  ngOnInit(): void {
    // We need to be aware of when the tooltip is shown to add the
    // dynamically created component to it.
    this.listenToTooltipVisibility();
  }

  ngOnDestroy(): void {
    this._destroyed.next();
    this._destroyed.complete();
  }

  private listenToTooltipVisibility() {
    // When the mouse enter into the div that has the 'MatTooltipDirective'
    fromEvent(this.el.nativeElement, 'mouseenter')
      .pipe(
        // This delay is required to allow 'MatTooltipDirective' to show the 
        // tooltip first.
        delay(0),
        tap(() => {
          this.addTemplate();
        }),
        takeUntil(this._destroyed)
      )
      .subscribe(noop);
  }

  // We get the dynamically created component, we remove the 'MatTooltipDirective' 
  // required text. and append the dynamic component as child to the deepest 
  // div inside the tooltip. 
  private addTemplate() {
    const nodes =
      this.matTooltip._tooltipInstance?._tooltip.nativeElement.querySelectorAll(
        'div'
      );
    const mostDeep = nodes?.item(nodes.length - 1);
    if (!!mostDeep) {
      mostDeep.textContent = null;
      this.renderer.setStyle(mostDeep, 'max-width', 'unset');
      this.renderer.appendChild(mostDeep, this.templateGenerator());
    }
  }
}

The DynamicPriceComponent shows the real time price:

// dynamic-price.component.ts
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';

import { FormsModule } from '@angular/forms';
import { Observable, finalize, noop, tap } from 'rxjs';

@Component({
  selector: 'app-product',
  standalone: true,
  templateUrl: './dynamic-price.component.html',
  styleUrls: ['./dynamic-price.component.scss'],
  imports: [CommonModule],
})
export class DynamicPriceComponent implements OnInit, OnDestroy {
  @Input() dynamicPrice$!: Observable<number>;

  ngOnInit(): void {
    console.log('DYNAMIC PRICE ONINIT');
  }

  ngOnDestroy(): void {
    console.log('DYNAMIC PRICE ONDESTROY');
  }
}
<!-- dynamic-price.component.html -->
<div class="dynamic-price">
  <img src="assets/img/weim.jpeg" class="dynamic-price-img" />
  <p class="dynamic-price-title">
    Welcome, the real time price is {{ dynamicPrice$ | async }} $. We hope that
    you like it!
  </p>
</div>

Bibliography: