DEV Community

Ihor Filippov
Ihor Filippov

Posted on • Edited on

Working with directives in Angular

It this article, I want to talk about, not API, but concrete business cases and the ways in which you can effectively use the capabilities of the angular directives.

There are two kinds of directives, you can use in development with angular.

HINT: Somebody also says that Components are directives too. This is true from a formal point of view, but not from a practical.

You can get detailed information about directives API from the docs above.

I assume that your already have @angular/cli installed.

We will start from scratch. First create new project:

ng new working-with-directives
cd working-with-directives
Enter fullscreen mode Exit fullscreen mode

Structural directives

Let's start, from the structural directives.

We will create our own directive, which will handle a various states of the user (Anonymous, User, Admin) and show content appropriate to the state.

First, we have to create a service which will provide an observable with different user states and capabilities to change them.

Create two files. One for model with user statuses. Another for service

app/models/user-status.enum.ts

export enum UserStatus {
  ANONYMOUS = "ANONYMOUS",
  USER = "USER",
  ADMIN = "ADMIN",
}
Enter fullscreen mode Exit fullscreen mode

app/services/user-status.service.ts

import { BehaviorSubject, Observable } from "rxjs";
import { UserStatus } from "src/app/models/user-status.enum";

export class UserStatusService {

  private userStatusSource: BehaviorSubject<UserStatus> = new BehaviorSubject(null);
  userStatus$: Observable<UserStatus> = this.userStatusSource.asObservable();

  changeUserStatus(status): void {
    this.userStatusSource.next(status);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, when we have a model and a service, we can implement our directive
app/directives/user-status/user-status.directive.ts

import { Directive, Input, EmbeddedViewRef, OnInit, OnDestroy, ViewContainerRef, TemplateRef } from "@angular/core";
import { UserStatusService } from "src/app/services/user-status.service";
import { Subject } from "rxjs";
import { takeUntil, map } from "rxjs/operators";
import { UserStatus } from "src/app/models/user-status.enum";

@Directive({
  selector: "[userStatus]"
})
export class UserStatusDirective implements OnInit, OnDestroy {
  // input has the same name as directive selector.
  // thanks to this we can write in this way - *userStatus="status"
  // structural directives are always used with asterisk *
  @Input("userStatus") status: string;

  private isDestroyed$: Subject<void> = new Subject();

  constructor(
    private userStatusService: UserStatusService, // service which holds state of user status
    private viewContainer: ViewContainerRef, // container where our dynamically create view can be attached or not :)
    private templateRef: TemplateRef<any>, // When we set directive on DOM element, angular wraps it with the ng-template tag under the hood
  ) { }

  ngOnInit(): void {
    this.userStatusService.userStatus$
      .pipe(
        map((userStatus: UserStatus) => userStatus === this.status),
        takeUntil(this.isDestroyed$)
      )
      .subscribe((isPermitted: boolean) => {
        if (isPermitted) {
          this.viewContainer.createEmbeddedView(this.templateRef);
        } else {
          this.viewContainer.remove();
        }
      });
  }

  ngOnDestroy() {
    this.isDestroyed$.next();
    this.isDestroyed$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

So, what are we doing here? In the ngOnInit lifecycle hook we are subscribing to user status state. Then, we compare current user status from service with user status transmitted via input. If they are equal we show current DOM element. If not - remove it from the DOM.

Let's check, is everything works as expected. But, before update app/app.component.ts file

import { Component } from '@angular/core';
import { UserStatusService } from "src/app/services/user-status.service";
import { UserStatus } from "src/app/models/user-status.enum";

@Component({
  selector: 'app-root',
  template: `
    <section>
      <h1>Structural directives</h1>
      <div *userStatus="UserStatus.ANONYMOUS">I am anonymous user</div>
      <div *userStatus="UserStatus.USER">I am common user</div>
      <div *userStatus="UserStatus.ADMIN">I am admin user</div>
      <hr/>
      <div>
        <button (click)="changeUserStatus(UserStatus.ANONYMOUS)">Anonymous</button>
      </div>
      <div>
        <button (click)="changeUserStatus(UserStatus.USER)">User</button>
      </div>
      <div>
        <button (click)="changeUserStatus(UserStatus.ADMIN)">Admin</button>
      </div>
    </section>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  UserStatus = UserStatus;

  constructor(private userStatusService: UserStatusService) {}

  changeUserStatus(status: UserStatus): void {
    this.userStatusService.changeUserStatus(status);
  }
}
Enter fullscreen mode Exit fullscreen mode

Run, the following CLI command. And open your browser at localhost:4200

npm start
Enter fullscreen mode Exit fullscreen mode

Now, if we click button we will see an appropriate dom element on the screen. Everything looks good, but there is one problem. If we click twice on the same button, we will see that DOM element will be duplicated. This situation happens because directive does not know anything about attached view to the container. So, we have to fix it.

If we look at view_container_ref.d.ts, we will find there a length getter. It is excatly what we are need!

export declare abstract class ViewContainerRef {
    ....
  /**
  * Reports how many views are currently attached to this container.
  * @returns The number of views.
  */
  abstract readonly length: number;
Enter fullscreen mode Exit fullscreen mode

app/directives/user-status/user-status.directive.ts

  ...
ngOnInit(): void {
    this.userStatusService.userStatus$
      .pipe(
        map((userStatus: UserStatus) => userStatus === this.status),
        takeUntil(this.isDestroyed$)
      )
      .subscribe((isPermitted: boolean) => {
        if (this.viewContainer.length) {
          this.viewContainer.remove();
        }

        if (isPermitted) {
          this.viewContainer.createEmbeddedView(this.templateRef);
        } else {
          this.viewContainer.remove();
        }
      });
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Now, if our container have views attached to it, they will be removed. And only after this the main directive's logic happens. In this way we protect ourselves from unwanted duplicates in DOM. The only problem of this approach is perfomance. Anytime service produces a new value our DOM element will be removed and then inserted again. This approach is suited for cases when you work with one or several elements, but not with the big list of items.

Attribute directives

Unlike structural directives, attribute directive do not change the DOM by adding or removing DOM elements. What attribute directive does, it changes the appearance or behavior of DOM element.

In my example, we will create a directive which will help our DOM elements to handle a very specific case, when user press ctrl + enter buttons.

Let's create a new file
app/directives/ctrl-enter/ctrl-enter.directive.ts

import { Directive, Output, EventEmitter, HostListener, ElementRef } from "@angular/core";

@Directive({
  selector: "[ctrlEnter]"
})
export class CtrlEnterDirective {
  @Output() onCtrlEnter: EventEmitter<string> = new EventEmitter();

  constructor(private element: ElementRef) {}

  @HostListener("keydown", ["$event"]) onKeyDown(event) {
    if ((event.keyCode === 10 || event.keyCode === 13) && event.ctrlKey) {
            this.onCtrlEnter.emit(this.element.nativeElement.value);
        }
  }
}
Enter fullscreen mode Exit fullscreen mode

The logic is very simple. We are listening to the keydown events of the DOM element and check if these events are about ctrl and enter keys. After that, we emit the value.
This logic is much simplier than in our structural directive, but it shows how you can effectively ignore code duplication in your project.

By the way, if you have only one input at the page and you do not want to import ReactiveFormsModule or FormsModule, you can deal with it in the same fashion, with attribute directive.

Let's test our new directive and update the
app/app.component.ts

import { Component } from '@angular/core';
import { UserStatusService } from "src/app/services/user-status.service";
import { UserStatus } from "src/app/models/user-status.enum";

@Component({
  selector: 'app-root',
  template: `
    <section>
      <h1>Attribute directives</h1>
      <input type="text" ctrlEnter (onCtrlEnter)="handleCtrlEnterEvent($event)">
    </section>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  UserStatus = UserStatus;

  constructor(private userStatusService: UserStatusService) {}

  handleCtrlEnterEvent(event): void {
    console.log(event);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, if you focus on input, type something and press ctrl + enter, you should see a input value in the console.

I hope this article was useful for you.

P.S. Source code can be found at github .

Top comments (0)