Attribute Directives

An Attribute directive changes the appearance or behavior of a DOM element.

In this chapter we will

Try the live example.

Directives overview

There are three kinds of directives in Angular:

  1. Components
  2. Structural directives
  3. Attribute directives

A Component is really a directive with a template. It's the most common of the three directives and we tend to write lots of them as we build applications.

Structural directives can change the DOM layout by adding and removing DOM elements. NgFor and NgIf are two familiar examples.

An Attribute directive can change the appearance or behavior of an element. The built-in NgStyle directive, for example, can change several element styles at the same time.

We are going to write our own attribute directive to set an element's background color when the user hovers over that element.

We don't need any directive to simply set the background color. We can set it with the special Style Binding like this:

<p [style.background]="'lime'">I am green with envy!</p>

That wouldn't be nearly as much fun as creating our own directive.

Besides, we're not just setting the color; we'll be changing the color in response to a user action, a mouse hover.

Build a simple attribute directive

An attribute directive minimally requires building a controller class annotated with @Directive, which specifies the selector identifying the attribute associated with the directive. The controller class implements the desired directive behavior.

Let's build a small illustrative example together.

Our first draft

Create a new project folder (attribute-directives) and follow the steps in the QuickStart.

Alternatively, begin with a download of the QuickStart source.

Create the following source file in the indicated folder with the given code:

app/highlight.directive.ts

import { Directive, ElementRef, Input, Renderer } from '@angular/core';

@Directive({ selector: '[myHighlight]' })
export class HighlightDirective {
    constructor(el: ElementRef, renderer: Renderer) {
       renderer.setElementStyle(el.nativeElement, 'backgroundColor', 'yellow');
    }
}

We begin by importing some symbols from the Angular core. We need the Directive symbol for the @Directive decorator. We need the ElementRef to inject into the directive's constructor so we can access the DOM element. We also need Renderer so we can change the DOM element's style. We don't need Input immediately but we will need it later in the chapter.

Then we define the directive metadata in a configuration object passed as an argument to the @Directive decorator function.

@Directive requires a CSS selector to identify the HTML in the template that is associated with our directive. The CSS selector for an attribute is the attribute name in square brackets. Our directive's selector is [myHighlight]. Angular will locate all elements in the template that have an attribute named myHighlight.

Why not call it "highlight"?

highlight is a nicer name than myHighlight and, technically, it would work if we called it that.

However, we recommend picking a selector name with a prefix to ensure that it cannot conflict with any standard HTML attribute, now or in the future. There is also less risk of colliding with a third-party directive name when we give ours a prefix.

We do not prefix our highlight directive name with ng. That prefix belongs to Angular.

We need a prefix of our own, preferably short, and my will do for now.

After the @Directive metadata comes the directive's controller class, which contains the logic for the directive. We export `HighlightDirective` to make it accessible to other components.

Angular creates a new instance of the directive's controller class for each matching element, injecting an Angular ElementRef and Renderer into the constructor. ElementRef is a service that grants us direct access to the DOM element through its nativeElement property and with Renderer we can set the element style.

Apply the attribute directive

The AppComponent in this sample is a test harness for our HighlightDirective. Let's give it a new template that applies the directive as an attribute to a paragraph (p) element. In Angular terms, the <p> element will be the attribute host.

We'll put the template in its own app.component.htmlfile that looks like this:

app/app.component.html

<h1>My First Attribute Directive</h1>
<p myHighlight>Highlight me!</p>

A separate template file is clearly overkill for a 2-line template. Hang in there; we're going to expand it later. Meanwhile, we'll revise the AppComponent to reference this template.

app/app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  templateUrl: 'app/app.component.html'
})

export class AppComponent { }

We'll add an import statement to fetch the 'Highlight' directive and, added that class to the declarations NgModule metadata so that Angular will recognize our directive when it encounters myHighlight in the template.

app/app.module.ts

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

import { AppComponent } from './app.component';
import { HighlightDirective } from './highlight.directive';

@NgModule({
  imports: [ BrowserModule ],
  declarations: [
    AppComponent,
    HighlightDirective
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

We run the app and see that our directive highlights the paragraph text.

First Highlight

Your directive isn't working?

Did you remember to add the directive to the the declarations attribute of @NgModule? It is easy to forget!

Open the console in the browser tools and look for an error like this:

EXCEPTION: Template parse errors:
  Can't bind to 'myHighlight' since it isn't a known property of 'p'.

Angular detects that we're trying to bind to something but it doesn't know what. We have to tell it by listing HighlightDirective in the declarations metadata array.

Let's recap what happened.

Angular found the myHighlight attribute on the <p> element. It created an instance of the HighlightDirective class, injecting a reference to the element into the constructor where we set the <p> element's background style to yellow.

Respond to user action

We are not satisfied to simply set an element color. Our directive should set the color in response to a user action. Specifically, we want to set the color when the user hovers over an element.

We'll need to

  1. detect when the user hovers into and out of the element,
  2. respond to those actions by setting and clearing the highlight color, respectively.

We apply the @HostListener decorator to methods which are called when an event is raised.

@HostListener('mouseenter') onMouseEnter() {
/* . . . */
}

@HostListener('mouseleave') onMouseLeave() {
/* . . . */
}

The @HostListener decorator refers to the DOM element that hosts our attribute directive, the <p> in our case.

We could have attached event listeners by manipulating the host DOM element directly, but there are at least three problems with such an approach:

  1. We have to write the listeners correctly.
  2. We must detach our listener when the directive is destroyed to avoid memory leaks.
  3. We'd be talking to DOM API directly which, we learned, is something to avoid.

Let's roll with the @HostListener decorator.

Now we implement the two mouse event handlers:

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight('yellow');
  }

  @HostListener('mouseleave') onMouseLeave() {
    this.highlight(null);
  }

  private highlight(color: string) {
    this.renderer.setElementStyle(this.el.nativeElement, 'backgroundColor', color);
  }

Notice that they delegate to a helper method that sets the color via a private local variable, el. We revise the constructor to capture the ElementRef.nativeElement in this variable.

constructor(private el: ElementRef, private renderer: Renderer) { }

Here's the updated directive:

app/highlight.directive.ts

import { Directive, ElementRef, HostListener, Input, Renderer } from '@angular/core';

@Directive({
  selector: '[myHighlight]'
})

export class HighlightDirective {
  constructor(private el: ElementRef, private renderer: Renderer) { }

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight('yellow');
  }

  @HostListener('mouseleave') onMouseLeave() {
    this.highlight(null);
  }

  private highlight(color: string) {
    this.renderer.setElementStyle(this.el.nativeElement, 'backgroundColor', color);
  }

}

We run the app and confirm that the background color appears as we move the mouse over the p and disappears as we move out.

Second Highlight

Configure the directive with binding

Currently the highlight color is hard-coded within the directive. That's inflexible. We should set the color externally with a binding like this:

<p [myHighlight]="color">Highlight me!</p>

We'll extend our directive class with a bindable input highlightColor property and use it when we highlight text.

Here is the final version of the class:

app/highlight.directive.ts (class)

export class HighlightDirective {
  private _defaultColor = 'red';

  constructor(private el: ElementRef, private renderer: Renderer) { }

  @Input('myHighlight') highlightColor: string;

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.highlightColor || this._defaultColor);
  }
  @HostListener('mouseleave') onMouseLeave() {
    this.highlight(null);
  }

  private highlight(color: string) {
    this.renderer.setElementStyle(this.el.nativeElement, 'backgroundColor', color);
  }
}

The new highlightColor property is called an input property because data flows from the binding expression into our directive. Notice the @Input() decorator applied to the property.

app/highlight.directive.ts (color)

@Input('myHighlight') highlightColor: string;

@Input adds metadata to the class that makes the highlightColor property available for property binding under the myHighlight alias. We must add this input metadata or Angular will reject the binding. See the appendix below to learn why.

@Input(alias)

The developer who uses this directive expects to bind to the attribute name, myHighlight. The directive property name is highlightColor. That's a disconnect.

We could resolve the discrepancy by renaming the property to myHighlight and define it as follows:

@Input() myHighlight: string;

Maybe we don't want that property name inside the directive perhaps because it doesn't express our intention well. We can alias the highlightColor property with the attribute name by passing myHighlight into the @Input decorator:

@Input('myHighlight') highlightColor: string;

Now that we're getting the highlight color as an input, we modify the onMouseEnter() method to use it instead of the hard-coded color name. We also define red as the default color to fallback on in case the user neglects to bind with a color.

@HostListener('mouseenter') onMouseEnter() {
  this.highlight(this.highlightColor || this._defaultColor);
}

Now we'll update our AppComponent template to let users pick the highlight color and bind their choice to our directive.

Here is the updated template:

<h1>My First Attribute Directive</h1>
<h4>Pick a highlight color</h4>
<div>
  <input type="radio" name="colors" (click)="color='lightgreen'">Green
  <input type="radio" name="colors" (click)="color='yellow'">Yellow
  <input type="radio" name="colors" (click)="color='cyan'">Cyan
</div>
<p [myHighlight]="color">Highlight me!</p>

Where is the templated color property?

The eagle-eyed may notice that the radio button click handlers in the template set a color property and we are binding that color to the directive. We should expect to find a color on the host AppComponent.

We never defined a color property for the host AppComponent! And yet this code works. Where is the template color value going?

Browser debugging reveals that Angular dynamically added a color property to the runtime instance of the AppComponent.

This is convenient behavior but it is also implicit behavior that could be confusing. While it's cool that this technique works, we recommend adding the color property to the AppComponent.

Here is our second version of the directive in action.

Highlight v.2

Bind to a second property

Our directive only has a single, customizable property. What if we had two properties?

Let's allow the template developer to set the default color, the color that prevails until the user picks a highlight color. We'll add a second input property to HighlightDirective called defaultColor:

@Input() set defaultColor(colorName: string){
  this._defaultColor = colorName || this._defaultColor;
}

The defaultColor property has a setter that overrides the hard-coded default color, "red". We don't need a getter.

How do we bind to it? We already "burned" the myHighlight attribute name as a binding target.

Remember that a component is a directive too. We can add as many component property bindings as we need by stringing them along in the template as in this example that sets the a, b, c properties to the string literals 'a', 'b', and 'c'.

<my-component [a]="'a'" [b]="'b'" [c]="'c'"><my-component>

We do the same thing with an attribute directive.

<p [myHighlight]="color" [defaultColor]="'violet'">
  Highlight me too!
</p>

Here we're binding the user's color choice to the myHighlight attribute as we did before. We're also binding the literal string, 'violet', to the defaultColor.

Here is the final version of the directive in action.

Final Highlight

Summary

We now know how to

The final source:

app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  templateUrl: 'app/app.component.html'
})

export class AppComponent { }
app.component.html
<h1>My First Attribute Directive</h1>
<h4>Pick a highlight color</h4>
<div>
  <input type="radio" name="colors" (click)="color='lightgreen'">Green
  <input type="radio" name="colors" (click)="color='yellow'">Yellow
  <input type="radio" name="colors" (click)="color='cyan'">Cyan
</div>
<p [myHighlight]="color">Highlight me!</p>

<p [myHighlight]="color" [defaultColor]="'violet'">
  Highlight me too!
</p>
highlight.directive.ts
import { Directive, ElementRef, HostListener, Input, Renderer } from '@angular/core';

@Directive({
  selector: '[myHighlight]'
})
export class HighlightDirective {
  private _defaultColor = 'red';

  constructor(private el: ElementRef, private renderer: Renderer) { }

  @Input() set defaultColor(colorName: string){
    this._defaultColor = colorName || this._defaultColor;
  }

  @Input('myHighlight') highlightColor: string;

  @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.highlightColor || this._defaultColor);
  }
  @HostListener('mouseleave') onMouseLeave() {
    this.highlight(null);
  }

  private highlight(color: string) {
    this.renderer.setElementStyle(this.el.nativeElement, 'backgroundColor', color);
  }
}
app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { HighlightDirective } from './highlight.directive';

@NgModule({
  imports: [ BrowserModule ],
  declarations: [
    AppComponent,
    HighlightDirective
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }
main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';

platformBrowserDynamic().bootstrapModule(AppModule);
index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Attribute Directives</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="styles.css">

    <!-- Polyfill(s) for older browsers -->
    <script src="node_modules/core-js/client/shim.min.js"></script>

    <script src="node_modules/zone.js/dist/zone.js"></script>
    <script src="node_modules/reflect-metadata/Reflect.js"></script>
    <script src="node_modules/systemjs/dist/system.src.js"></script>

    <script src="systemjs.config.js"></script>
    <script>
      System.import('app').catch(function(err){ console.error(err); });
    </script>
  </head>
  <body>
    <my-app>loading...</my-app>
  </body>
</html>

Appendix: Input properties

Earlier we declared the highlightColor property to be an input property of our HighlightDirective

We've seen properties in bindings before. We never had to declare them as anything. Why now?

Angular makes a subtle but important distinction between binding sources and targets.

In all previous bindings, the directive or component property was a binding source. A property is a source if it appears in the template expression to the right of the equals (=).

A property is a target when it appears in square brackets ([ ]) to the left of the equals (=) ... as it is does when we bind to the myHighlight property of the HighlightDirective,

<p [myHighlight]="color">Highlight me!</p>

The 'color' in [myHighlight]="color" is a binding source. A source property doesn't require a declaration.

The 'myHighlight' in [myHighlight]="color" is a binding target. We must declare it as an input property. Angular rejects the binding with a clear error if we don't.

Angular treats a target property differently for a good reason. A component or directive in target position needs protection.

Imagine that our HighlightDirective did truly wonderous things. We graciously made a gift of it to the world.

To our surprise, some people — perhaps naively — started binding to every property of our directive. Not just the one or two properties we expected them to target. Every property. That could really mess up our directive in ways we didn't anticipate and have no desire to support.

The input declaration ensures that consumers of our directive can only bind to the properties of our public API ... nothing else.

doc_Angular
2016-10-06 09:46:17
Comments
Leave a Comment

Please login to continue.