Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Dan Wilson's 2011 (North Carolina) with: Nick James Wilson
Ben Nadel at Dan Wilson's 2011 (North Carolina) with: Nick James Wilson

Injecting HTML With The BrowserDomAdapter In AngularJS 2 Beta 9

By Ben Nadel on

One of the big (and mostly undocumented) shifts in Angular 2 is learning how to think about the DOM (Document Object Model) as an abstracted API. No longer can we be satisfied with just rendering in the browser - now our code needs to be compatible with WebWorkers, servers, and any other context for which people will write adapters (ex, NativeScript). This freedom, however, comes with a cost of complexity. And, relatively simple operations, like copying HTML, become, well, complex. Ideally, I'd like to be able to copy the innerHTML of a component in a platform-agnostic way; but, ultimately, the only way that I could figure out how to do it - without using native browser APIs - was to use the BrowserDomAdapter service.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

Most of the time, in an AngularJS application, you only need to represent content once. But, in some use-cases, you need to take content and clone it. And, not cloned as a component instance but rather as a static copy of some portion of the component tree.

Take, for example, a drag-and-drop feature where the draggable item is represented as a "ghost" under the user's cursor. This ghost element is not an active component (meaning, it doesn't have interpolation bindings or host-event bindings), it's just a copy of the target element at the time the drag-operation was initiated.

Or, as an even simpler example, duplicating the rendering of a selected option in some sort of select-input control. In that case, one of the options needs to be rendered twice: once in the list of options and a second time in the representation of the selected option.

To explore this, I wanted to try to create a simple select control in which the user can provide an arbitrary list of select items. Then, when the user goes to select one of the items, the content of the selected item is duplicated into the "selected item" representation of the control.

This demo requires two components: the SelectList and the SelectItem. As much as I could, I tried to keep all of the platform-specific assumptions in one place. So, for example, the SelectItem makes no assumptions about the platform. It only knows about its own host element wrapper (elementRef), which it passes up to the SelectList upon request.

The SelectList, on the other hand, is where all of the platform assumptions are made. It provides and consumes the BrowserDomAdapter and it understand the concept of innerHTML. But, even so, it still tries to interact with the innerHTML through the API of the BrowserDomAdapter instead of going directly to the native browser API. As least, with this approach, the BrowserDomAdapter service can still be mocked out or implemented in other contexts.

  • <!doctype html>
  • <html>
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Injecting HTML With The BrowserDomAdapter In AngularJS 2 Beta 9
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body>
  •  
  • <h1>
  • Injecting HTML With The BrowserDomAdapter In AngularJS 2 Beta 9
  • </h1>
  •  
  • <my-app>
  • Loading...
  • </my-app>
  •  
  • <!-- Load demo scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/9/es6-shim.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/9/Rx.umd.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/9/angular2-polyfills.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/9/angular2-all.umd.js"></script>
  • <!-- AlmondJS - minimal implementation of RequireJS. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/9/almond.js"></script>
  • <script type="text/javascript">
  •  
  • // Defer bootstrapping until all of the components have been declared.
  • // --
  • // NOTE: Not all components have to be required here since they will be
  • // implicitly required by other components.
  • requirejs(
  • [ /* Using require() for better readability. */ ],
  • function run() {
  •  
  • ng.platform.browser.bootstrap( require( "App" ) );
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I control the root of the application.
  • define(
  • "App",
  • function registerApp() {
  •  
  • // Define the App component metadata.
  • ng.core
  • .Component({
  • selector: "my-app",
  • directives: [
  • require( "SelectList" ),
  • require( "SelectItem" )
  • ],
  • template:
  • `
  • <select-list>
  • <select-item>As if!</select-item>
  • <select-item>Totes magotes!</select-item>
  • <select-item>So inappropes!</select-item>
  • <select-item>Much wow!</select-item>
  • </select-list>
  • `
  • })
  • .Class({
  • constructor: AppController
  • })
  • ;
  •  
  • return( AppController );
  •  
  •  
  • // I control the App component.
  • function AppController() {
  •  
  • // ... nothing to do there.
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide the SelectList component.
  • define(
  • "SelectList",
  • function registerSelectList() {
  •  
  • // Define the SelectList component metadata.
  • ng.core
  • .Component({
  • selector: "select-list",
  •  
  • // CAUTION: We are providing the BrowserDomAdapter to the
  • // component. At this point, we are making a very strong
  • // assertion about which environments this component can run in
  • // (which I think - but am not sure - means that this component
  • // can never be rendered on the server; or at least not without
  • // first mocking out the BrowserDomAdapter to work without a
  • // native HTML DOM implementation).
  • providers: [ ng.platform.browser.BrowserDomAdapter ],
  •  
  • // When an item is selected, we are going represent it in the
  • // component by injecting its HTML into the snapshot. To make
  • // this easier, let's get a live query to the snapshot element
  • // reference.
  • queries: {
  • "snapshotElementRef": new ng.core.ViewChild( "snapshot" )
  • },
  • template:
  • `
  • <div #snapshot class="snapshot">
  • <em class="default">Nothing selected</em>
  • </div>
  •  
  • <ng-content></ng-content>
  • `
  • })
  • .Class({
  • constructor: SelectListController
  • })
  • ;
  •  
  • SelectListController.parameters = [
  • new ng.core.Inject( ng.platform.browser.BrowserDomAdapter )
  • ];
  •  
  • return( SelectListController );
  •  
  •  
  • // I control the SelectList component.
  • function SelectListController( domAdapter ) {
  •  
  • var vm = this;
  •  
  • // Expose the public methods.
  • vm.selectItem = selectItem;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I use the given item as the selected item.
  • function selectItem( item ) {
  •  
  • // When an item in the list wants to be the selected item, we
  • // are going to represent it in the component by projecting
  • // its HTML content into the snapshot container. In order to do
  • // that, we need to get elementRef of the item.
  • var itemElementRef = item.getElementRef();
  •  
  • // At this point, we have to reach down past the generic DOM
  • // abstraction and make many assumptions that we are working with
  • // the BrowserDomAdapter, which makes it possible to actually grab
  • // the innerHTML content of one element and inject it into another
  • // element. Not only does this require the BrowserDomAdapter, it
  • // also requires us to reach into the ElementRef instances and
  • // consume the underlying nativeElement.
  • // --
  • // NOTE: I am really not sure that this is good. I hate the fact
  • // that we have to start assuming things about the DOM. If anyone
  • // knows of a generic way to do this, please share!
  • domAdapter.setInnerHTML(
  • vm.snapshotElementRef.nativeElement,
  • domAdapter.getInnerHTML( itemElementRef.nativeElement )
  • );
  •  
  • // ASIDE: At this point, you may be wondering, if we're already
  • // making assumptions about the DOM, why not just use the native
  • // .innerHTML property and skip the BrowserDomAdpater altogether?
  • // Well, if we go through the BrowserDomAdpater, we still have
  • // option to *mock out* the BrowserDomAdapter. If we start
  • // reaching into the native properties, we lose the ability to
  • // mock out or override the adapter in other rendering contexts.
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide the SelectItem component.
  • define(
  • "SelectItem",
  • function registerSelectItem() {
  •  
  • // Define the SelectItem component metadata.
  • ng.core
  • .Component({
  • selector: "select-item",
  • host: {
  • "(click)": "selectItem()"
  • },
  • template:
  • `
  • <ng-content></ng-content>
  • `
  • })
  • .Class({
  • constructor: SelectItemController
  • })
  • ;
  •  
  • // Here, were injecting the components host element reference and the
  • // SelectList component. The SelectList component will be provided by
  • // the hierarchy of parent components.
  • SelectItemController.parameters = [
  • new ng.core.Inject( ng.core.ElementRef ),
  • new ng.core.Inject( require( "SelectList" ) )
  • ];
  •  
  • return( SelectItemController );
  •  
  •  
  • // I control the SelectItem component. The item really only has one
  • // behavior and that is to tell the parent list when the item has been
  • // selected (ie, clicked) by the user.
  • function SelectItemController( elementRef, selectList ) {
  •  
  • var vm = this;
  •  
  • // Expose the public methods.
  • vm.getElementRef = getElementRef;
  • vm.selectItem = selectItem;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I return the element wrapper for this component instance.
  • function getElementRef() {
  •  
  • // NOTE: At this point, we still make no assumptions about the
  • // platform. The ElementRef is the Angular 2 wrapper around
  • // whatever the platform uses to represent its object model.
  • return( elementRef );
  •  
  • }
  •  
  •  
  • // I tell the parent list to use this item as the selected item.
  • function selectItem() {
  •  
  • selectList.selectItem( this );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, the SelectList is using the .getInnerHTML() and the .setInnerHTML() methods of the BrowserDomAdapter service rather than referencing the .innerHTML node properties directly. This way, those methods can be mocked out, or implemented, in other contexts. So, while this approach is more platform-specific, at least there is some safety to it.

And, when we run the above code and select one of the SelectItem elements, we can see that the item content is cloned successfully:


 
 
 

 
 Injecting innerHTML using the BrowserDomAdapter service in Angular 2 Beta 9. 
 
 
 

From what I have seen, the Angular team has yet to release any "best practice" guides around the platform-agnostic nature of the Angular 2 framework. As such, it feels very much like unchartered territory every time that I need to interact with the DOM in an imperative way instead of a declarative way. So, right now, my strategy for success is to just try and keep those operations - like copying innerHTML - behind some sort of an API that can be mocked or implemented in other contexts.




Reader Comments

According to Rob Wormald "Renderer" is what you want to use if you need to touch the DOM and be cross platform.
https://angular.io/docs/ts/latest/api/core/Renderer-class.html

Reply to this Comment

@Mika,

Right; but, I don't see any obvious way to inject HTML with the Renderer class. There is a .projectNodes(); but that moves nodes from place to another, it doesn't copy them. There's also a .invokeElementMethod() method, so I thought maybe I could call "clone" on the target node. But, alas, the invokeElementMethod() doesn't actually return its results; so, even if it is cloning nodes, it's not giving me access to them.

If you can figure it out, I would love to use something more generic.

Reply to this Comment

Good point. What I mainly wanted to point out is using this BrowserDomAdapter locks you out of service workers and cross platform AFAIK. Angular2 still has a lot of docs to fill and things to implement so it could be an unfinished feature.

Reply to this Comment

@Mika,

Totally, agreed - I feel like I have next to no understanding about how all the cross-platform stuff is supposed to work. What's ok, what's not. What's the "right" way to to do things. And, what is irrelevant? I really hope someone comes out with some sort of definitive "Universal JavaScript for Angular 2" kind of a write-up.

Reply to this Comment

I was digging through this and found an alternative in some test code. This works, both in a SPA and in Angular Universal (just tried it):

<div [innerHtml]="myHtml"></div>

There is no documentation as of yet.

Reply to this Comment

@Jhades,

Funny enough, I just used [innerHTML] in my dropdown menu demo, where I had to inject the selected option's HTML into the root of the menu:

www.bennadel.com/blog/3062-creating-an-html-dropdown-menu-component-in-angular-2-beta-11.htm

But, even with the [innerHTML] property, it feels unsafe. Meaning, can we really be sure it accepts a string? Consider that I have a component controller that does something like:

this.content = "hello!";

... and I wanted to render that. Sure, [innerHTML] works in a browser, but what about somewhere else, like server-side rendering or Native Script? Will those views be able to understand [innerHTML] in terms of HTML strings?

These are questions floating around in my head - I have ZERO answers :( I am extremely lost / confused when it comes to all the browser abstraction and the "universal JavaScript" stuff. So, mostly just thinking out load here.

Reply to this Comment

The decoupling of the renderer from the core has allowed for large companies like Telerik and Ionic to create some amazing 3rd party libraries for Angular 2. But it is starting to become more apparent that there is a cost to having all the abstraction layers in Angular 2 for the average developer. Trying to do something as "simple" as getting a reference to a native HTML DOM element seems to have a lot of complexity if you want to stay consistent with doing things the Angular 2 way... which as you pointed out, it is not evident on what that method actually is yet.

In just about every answer on StackOverflow on the topic of Angular 2, there is at least one comment along the lines of... "don't do this because it will tightly couple your app to the browser". I'm only now beginning to feel like this mentality may be becoming a bit too extreme. There is this sense that seems to be growing that discourages any complex DOM manipulation. But that is exactly where all the next great web applications are to be had!

Having portable code really would be nice... but until there is an elegant approach, it is only slowing down development time for me. So I say to hell with it. I'm going to use ElementRef all over the damn place. It is the simplest way to get to the DOM, so that's what I'm going to use.

Thanks for the article. It was the affirmation I needed. I'm not the only one unsure of how to create portable code after all. So I'm going to forget it entirely and leverage the framework in the areas that it does do well at. Cheers ^_^

~Peter

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.