23 March 2017 The Force of the Shadow DOM

AngularJS had Transclusion and now Angular 2 has the force of the Shadow DOM.

Problem

The problem we will be looking at is, how to inject HTML markup into a component we have created.

<accordian>
  <panel>
    <div title>Panel Title</div>
    <div content>
      My first test content section.
    </div>
  </panel>
  <panel>
    <div title>Panel Title 2</div>
    <div content>
      My second test content section.
    </div>
  </panel>
</accordian>

So that it looks like the bootstrap accordian.


AngularJS Solution

In AngularJS we used something fancy called Transclusion. It isn’t actually all that fancy, it is basically just moving html from one location and injecting into another.

This was done via adding the ng-tranclude directive to your directives template where you wish the HTML to be injected. You would also add the property transclude: true to the directive properties.

Below is an example of an accordian directive. We use transclusion to display HTML markup into our directive.

<!-- index.html -->
<html ng-app="AccordianDemo">

  <head>
    <meta charset="utf-8" />
    <link data-require="bootstrap-css@*" data-semver="3.3.1" rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css" />
    <link rel="stylesheet" href="style.css" />
    <script data-require="angular.js@1.4.x" src="https://code.angularjs.org/1.4.12/angular.js" data-semver="1.4.9"></script>
    <script src="app.js"></script>
  </head>

  <body ng-controller="MainCtrl">
    <my-accordian>
      <my-panel header="Panel Title">
        My first test content section.
      </my-panel>
      <my-panel header="Panel Title 2">
        My second test content section.
      </my-panel>
    </my-accordian>
  </body>

</html>


// accordian directive
var app = angular.module('AccordianDemo', []);

app.controller('MainCtrl', function($scope) {
});

angular.module('AccordianDemo')
  .directive('myAccordian', function() {
    return {
      restrict: 'E',
      template: '<div class="panel-group" ng-transclude></div>',
      transclude: true,
      controller: function() {
        var panels = [];

        this.addPanel = function(panel) {
          panels.push(panel);
          panels[0].visible = true;
        };

        this.closeAll = function(panel) {
          for(var i=0; i < panels.length; i++) {
            panels[i].visible = false;
          }
          panel.visible = true;
        }
      },
      link: function(scope, element, attrs, ctrl, transclude) { }
    };
});

angular.module('AccordianDemo')
  .directive('myPanel', function() {
    return {
      restrict: 'E',
      template: `
        <div class="panel panel-default">
          <div class="panel-heading">
            <h4 class="panel-title">
              <a ng-click="closeOthers()">
                
              </a>
            </h4>
          </div>
          <div class="panel-collapse collapse in" ng-show="visible">
            <div class="panel-body" ng-transclude>
            </div>
          </div>
        </div>`,
      scope: {
        header: '@'
      },
      require: '^myAccordian',
      transclude: true,
      link: function(scope, element, attrs, ctrl, transclude) {
        scope.visible = false;

        scope.closeOthers = function() {
          ctrl.closeAll(scope);
          scope.visible = true;
        };

        ctrl.addPanel(scope);
      }
    };
});

A few downfalls of Transclusion were:

  • It seems magical, even making up a new word to name itself.
  • The API documentation is hard to follow and understand.
  • You have to perform tricks to tranclude into multiple locations. Like above where we passed the panel header into the directive as an attribute instead of additional markup.


Angular 2 Solution

Where did Transclusion go? Well in Angular 2 we no longer use transclusion, we use something way more powerful called Shadow DOM. Shadow DOM is a new HTML feature supported in new browsers including Chrome 35+.

So what is Shadow DOM? Shadow DOM allows you to build true HTML components by separating the content from the presentation. There have been two new nodes introduced to the DOM, the Shadow Root and Shadow Host. Also, two new HTML elements and standards are used. The <template> and <content> elements. Any style defined inside the <template> tags are private to that template and do not affect the rest of the DOM. Markup or text (dynamic or not) defined inside the root element is passed to the host element or <template> and placed between the <content> tags. An example is if you define a root element for example

<div id="#Dustin">Hi</div>

and a host element

<template id="#Dustinhost">
  <style>
    span {
      font-family: 'Arial';
      font-weight: bold;
    }
  </style>

  <content></content>
  <span>World</span>
</template>`

This will display Hi World with the span tags in Arial and Bold. Also only the span tags within the <template> tags are affected by the style. Any span tags outside of the <template> tags will not use the font-family: 'Arial' or font-weight: bold unless specified elsewhere in the css.

So now knowing what Shadow DOM is, how do we use it in Angular 2? Below is an example of an Accordian component in Angular 2.

<!-- index.html -->
<html>

  <head>
    <base href="." />
    <link data-require="bootstrap-css@*" data-semver="3.3.1" rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css" />
    <script src="https://unpkg.com/core-js@2.4.1/client/shim.min.js"></script>
    <script src="https://unpkg.com/zone.js/dist/zone.js"></script>
    <script src="https://unpkg.com/zone.js/dist/long-stack-trace-zone.js"></script>
    <script src="https://unpkg.com/reflect-metadata@0.1.3/Reflect.js"></script>
    <script src="https://unpkg.com/systemjs@0.19.31/dist/system.js"></script>
    <script src="config.js"></script>
    <script>
    System.import('app')
      .catch(console.error.bind(console));
  </script>
  </head>

  <body>
    <my-app>Loading...
    </my-app>
  </body>

</html>


// app.ts
import {Component, NgModule } from '@angular/core'
import {BrowserModule} from '@angular/platform-browser'
import {MyAccordian} from './my-accordian.ts'
import {MyPanel} from './my-panel.ts'

@Component({
  selector: 'my-app',
  template: `
    <my-accordian>
      <my-panel>
        <div title>Panel Title</div>
        <div content>My first test content section.</div>
      </my-panel>
      <my-panel>
        <div title>Panel Title 2</div>
        <div content>My second test content section.</div>
      </my-panel>
    </my-accordian>
  `
})
export class App {
  constructor() {}
}

@NgModule({
  imports: [ BrowserModule ],
  declarations: [ App, MyAccordian, MyPanel ],
  bootstrap: [ App ]
})
export class AppModule {}


// my-accordian.ts
import {Component, HostBinding } from '@angular/core'
import {MyPanel} from './my-panel.ts'

@Component({
  selector: 'my-accordian',
  template: '<ng-content></ng-content>'
})
export class MyAccordian {
  @HostBinding('class.panel-group') css = true;
  private _panels: Array<MyPanel> = [];

  constructor() { }

  add(panel: MyPanel):void {
    if(this._panels.length > 0) {
      panel.close();
    }

    this._panels.push(panel);
  }

  closeOthers(panel: MyPanel):void {
    for(var p of this._panels) {
      if(p != panel) {
        p.close();
      }
    }
  }
}


// my-panel.ts
import {Component} from '@angular/core'
import {MyAccordian} from './my-accordian.ts'

@Component({
  selector: 'my-panel',
  template: `
    <div class="panel panel-default">
      <div class="panel-heading">
        <h4 class="panel-title">
        <a (click)="toggle(); ">
          <ng-content select="[title]"></ng-content>
        </a>
      </h4>
      </div>
      <div class="panel-collapse collapse" [ngClass]="{in: active}">
        <div class="panel-body">
          <ng-content select="[content]"></ng-content>
        </div>
      </div>
    </div>
  `
})
export class MyPanel {
  private _active:boolean = true;

  constructor(private _accordian: MyAccordian) {
    _accordian.add(this);
  }

  get active():boolean {
    return this._active;
  }

  toggle():void {
    this._active = !this._active;
    this._accordian.closeOthers(this);
  }

  close():void {
    this._active = false;
  }
}

Please ignore the fact it looks like there are more files and code. This is not due to Shadow DOM, but because of the setup files needed for Angular 2 and the fact it is broken out into more files.

The first thing you might be curious about is, why we are using <ng-content> instead of just the <content> tags mentioned above. The answer is because very few browsers currently support real Shadow DOM. Angular 2 has provided a work around with the <ng-content> component. There is a property encapsulation you can provide @Component({}) to tell your Component how you want to use <ng-content>. There are three different values you can provide the encapsulation property.

  • ViewEncapsulation.None - This takes away Shadow DOM and provides no encapsulation. Meaning you will still get the same value as ng-transclude provided. The HTML markup will be injected to the correct location, but there will be no encapsulation of the styling, as Shadow DOM provides.
  • ViewEncapsulation.Emulated - This is the default. It provides the HTML injection but does not use true Shadow DOM. However it does provide some style encapsulation emulation. This inserts style in the main header. To provide the encapsulation it adds attributes, unique identifiers, to the style’s name and therefore makes it unique to each component. This provides basically what real Shadow DOM provides, but is a work around for browsers not up to standards. One downfall here is styling a parent component does not share anything with it’s child component, therefore you will need duplicate styling across components.
  • ViewEncapsulation.Native - This uses real Shadow DOM.

Hopefully this helps in understanding what Angular 2 is doing when injecting HTML markup into a component and how it is displayed.

Dustin Kocher · @DustinKocher · Senior Software Engineer