Creating Directives page

Learn how to create Directives in Angular that can change the appearance or behavior of DOM Elements.

Problem

While filling out the phone number field, you might have noticed that users can type in both letters and numbers. This is a problem because we do not want users entering letters in the phone number field.

In order to fix this, we will create an Attribute Directive that will change the behavior of the Phone Input Field, and will ensure that only numbers can be entered in the field.

<input
  name="phone"
  type="text"
  pmoOnlyNumbers
  formControlName="phone"
/>

What you need to know

  • What are Directives?
  • What is ElementRef?
  • What are HostListeners?
  • How to create a Directive

What are Directives?

Directives are classes that tell Angular to change the appearance or behavior of DOM Elements. Angular comes with a set of Built-in Directives, and they consist of three types:

  • Components
  • Structural Directives
  • Attribute Directives

Components

In this training, we previously talked about Components. Components are a type of Directive. Components use the @Component decorator function along with a template, style, and other logic needed for the view. This was previously discussed in detail here. The official Angular documentation has more information on this as well.

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

@Component({
  selector: 'app-button',
  template: `<button>Click me!</button>`
})
export class AppButtonComponent {}

Structural Directives

Structural Directives are types of Directives that are used to change HTML DOM layout by adding, removing or manipulating Elements. Learn more about structural directives.

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

@Component({
  selector: 'app-message',
  template: `
    <p *ngIf="showMessage">
      Conditional message!
    </p>
  `
})
export class AppMessageComponent {
  showMessage: boolean = true;

  // You can toggle this value to show or hide the message
  toggleMessage(): void {
    this.showMessage = !this.showMessage;
  }
}

Attribute Directives

Attribute Directives are a type of directive that are mainly used to listen or change the behavior or appearance of DOM Elements, Attributes and Components. Learn more about attribute directives.

Here’s a small example of building a directive (we will dig into this more in a section below):

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

@Directive({
  selector: '[appColor]'
})
export class AppColorDirective {
  @Input('appColor') set color(color: string) {
    this.el.nativeElement.style.color = color;
  }

  constructor(private el: ElementRef) {}
}

ElementRef

ElementRef (Element Reference) is a wrapper around a native DOM Element inside of a View. The ElementRef class contains a property nativeElement, which references the underlying DOM object that we can use to manipulate the DOM. Read more.

When creating a Directive, we use ElementRef to gain reference to the native HTML Element, which the Directive will be used on, and perform any manipulation we need to.

import { Directive, ElementRef, Renderer2, OnInit } from '@angular/core';

@Directive({
  selector: '[appHighlight]'
})
export class HighlightDirective implements OnInit {

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

  ngOnInit() {
    this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', 'yellow');
  }
}

Here’s how we would use this directive in a component:

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

  selector: 'app-button',
  template: `
    <p appHighlight>
      This paragraph will be highlighted in yellow.
    </p>
  `
})
export class AppButtonComponent {}

HostListener

HostListener is a function decorator that allows you to listen and handle DOM events from the host element. Some examples of events include keyboard and mouse events. Read more.

When creating a Directive, we use the @HostListener decorator in the Directive Class to listen for host events. Based on the event, we can perform any action needed.

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

@Directive({
  selector: '[appClickTracker]'
})
export class ClickTrackerDirective {

  @HostListener('click', ['$event'])
  onClick(event: MouseEvent) {
    console.info('Element clicked:', event);
  }

  @HostListener('mouseenter')
  onMouseEnter() {
    console.info('Mouse entered');
  }

  @HostListener('mouseleave')
  onMouseLeave() {
    console.info('Mouse left');
  }
}

Here’s how we would use this directive in a component:

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

  selector: 'app-button',
  template: `
    <div appClickTracker>
      Click or hover over this div.
    </div>
  `
})
export class AppButtonComponent {}

How to Generate a Directive via the CLI

ng generate directive onlyNumbers

This will generate the directive file: only-numbers.directive.ts and the spec file: only-numbers.directive.spec.ts

How to Build a Directive

As we have discussed above, Directives are very useful tools in Angular that can help improve your web application. The example below shows an Attribute Directive that would allow a user to enter only letters in an input field.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<my-app></my-app>
<script type="typescript">
// app.js
const { Component, VERSION, Directive, ElementRef, HostListener } = ng.core;

@Directive({
  selector: '[pmoOnlyLetters]',
})
class OnlyLettersDirective {
  allowedKeys: string[] = ['Backspace', 'ArrowLeft', 'ArrowRight'];
  regExp: RegExp = new RegExp(/^[A-Za-z]*$/g);

  constructor(private elementRef: ElementRef) {}

  @HostListener('keydown', ['$event'])
  onKeyDown(keyboardEvent: KeyboardEvent) {
    if (this.allowedKeys.indexOf(keyboardEvent.key) !== -1) {
      return;
    }
    const inputNativeElementValue = this.elementRef.nativeElement.value;
    const next = `${inputNativeElementValue}${keyboardEvent.key}`;
    if (next && !next.match(this.regExp)) {
      keyboardEvent.preventDefault();
    }
  }
}

@Component({
  selector: 'my-app',
  template: `
    <h2>Allow Only Letters Directive</h2>
    <input name="phone" type="text" pmoOnlyLetters>
  `
})
class AppComponent {
  constructor() {
  }
}

// main.js
const { BrowserModule } = ng.platformBrowser;
const { NgModule } = ng.core;
const { CommonModule } = ng.common;

@NgModule({
  imports: [ BrowserModule, CommonModule],
  declarations: [AppComponent, OnlyLettersDirective],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


Technical requirements

  1. Use an onlyNumber Directive in src/app/order/order.component.html in the phone number input field. Using the Directive should look like this:
<input
  name="phone"
  type="text"
  pmoOnlyNumbers
  formControlName="phone"
/>
  1. Generate and implement the onlyNumber Directive.

The Directive will be used to listen for the event on the input field. This Directive will be used in our order form phone number field.

Hint: Use regex regExp: RegExp = new RegExp(/^[0-9]*$/g) to test if the input value contains any letters.

Setup

✏️ Update src/app/order/order.component.html file to use the Directive we will create:

<div class="order-form">
  <ng-container *ngIf="orderComplete; else showOrderForm"></ng-container>
  <ng-template #showOrderForm>
    <h2>Order here</h2>
    <form
      *ngIf="orderForm && restaurant"
      [formGroup]="orderForm"
      (ngSubmit)="onSubmit()"
    >
      <tabset>
        <tab heading="Lunch Menu" *ngIf="restaurant.menu.lunch">
          <ul class="list-group">
            <pmo-menu-items
              [items]="restaurant.menu.lunch"
              formControlName="items"
            ></pmo-menu-items>
          </ul>
        </tab>
        <tab heading="Dinner Menu" *ngIf="restaurant.menu.dinner">
          <ul class="list-group">
            <pmo-menu-items
              [items]="restaurant.menu.dinner"
              formControlName="items"
            ></pmo-menu-items>
          </ul>
        </tab>
      </tabset>
      <div class="form-group">
        <label class="control-label">Name:</label>
        <input name="name" type="text" formControlName="name" />
        <p>Please enter your name.</p>
      </div>
      <div class="form-group">
        <label class="control-label">Address:</label>
        <input name="address" type="text" formControlName="address" />
        <p class="help-text">Please enter your address.</p>
      </div>
      <div class="form-group">
        <label class="control-label">Phone:</label>
        <input
          name="phone"
          type="text"
          pmoOnlyNumbers
          formControlName="phone"
        />
        <p class="help-text">Please enter your phone number.</p>
      </div>
      <div class="submit">
        <h4>Total: ${{ orderTotal }}</h4>
        <div class="loading" *ngIf="orderProcessing"></div>
        <button
          type="submit"
          [disabled]="!orderForm.valid || orderProcessing"
          class="btn"
        >
          Place My Order!
        </button>
      </div>
    </form>
  </ng-template>
</div>

✏️ Run the following to generate the Directive and the directive’s tests:

ng g directive onlyNumbers

How to verify your solution is correct

✏️ Update the spec file src/app/only-numbers.directive.spec.ts to be:

import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { OnlyNumbersDirective } from './only-numbers.directive';

const simulateTyping = <T>(
  debugElement: DebugElement,
  fixture: ComponentFixture<T>,
  value: string
) => {
  let buildString = '';
  for (const singleValue of value) {
    const keydownEvent = new KeyboardEvent('keydown', {
      key: singleValue,
      cancelable: true,
    });
    debugElement.nativeElement.dispatchEvent(keydownEvent);
    if (!keydownEvent.defaultPrevented) {
      buildString = `${buildString}${singleValue}`;
    }
  }
  debugElement.nativeElement.value = buildString;
  debugElement.nativeElement.dispatchEvent(new Event('input'));
  fixture.detectChanges();
};

@Component({
  template: `<input name="phone" type="text" pmoOnlyNumbers />`,
})
class TestInputComponent {}

describe('OnlyNumbersDirective', () => {
  let debugElement: DebugElement;
  let fixture: ComponentFixture<TestInputComponent>;

  beforeEach(() => {
    fixture = TestBed.configureTestingModule({
      declarations: [OnlyNumbersDirective, TestInputComponent],
      imports: [FormsModule],
      providers: [],
    }).createComponent(TestInputComponent);
    fixture.detectChanges();
    debugElement = fixture.debugElement.query(By.css('input'));
  });

  it('should create an instance', () => {
    const directive = new OnlyNumbersDirective(debugElement);
    expect(directive).toBeTruthy();
  });

  it('should contain only text 329053', () => {
    const inputString = '32T90V53CFACR';
    simulateTyping(debugElement, fixture, inputString);
    expect(debugElement.nativeElement.value).toEqual('329053');
  });

  it('should contain only text 200', () => {
    const inputString = 'THisIsA200LETTERWORD';
    simulateTyping(debugElement, fixture, inputString);
    expect(debugElement.nativeElement.value).toEqual('200');
  });

  it('should be an empty string', () => {
    const inputString = 'STRING OF LETTER NO NUMBER';
    simulateTyping(debugElement, fixture, inputString);
    expect(debugElement.nativeElement.value).toBe('');
  });
});

If you’ve implemented the solution correctly, when you run npm run test all tests will pass!

Solution

Click to see the solution ✏️ Update src/app/only-numbers.directive.ts to:

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

@Directive({
  selector: '[pmoOnlyNumbers]',
})
export class OnlyNumbersDirective {
  private allowedKeys: string[] = ['Backspace', 'ArrowLeft', 'ArrowRight'];
  private regExp: RegExp = new RegExp(/^[0-9]*$/g);

  constructor(private elementRef: ElementRef) {}

  @HostListener('keydown', ['$event'])
  onKeyDown(keyboardEvent: KeyboardEvent) {
    if (this.allowedKeys.indexOf(keyboardEvent.key) !== -1) {
      return;
    }
    const inputNativeElementValue = this.elementRef.nativeElement.value;
    const next = `${inputNativeElementValue}${keyboardEvent.key}`;
    if (next && !next.match(this.regExp)) {
      keyboardEvent.preventDefault();
    }
  }
}