Skip to content

A Guide to Custom Angular Attribute Directives

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

When working inside of Angular applications you may have noticed special attributes such as NgClass, NgStyle and NgModel. These are special attributes that you can add to elements and components that are known as attribute directives. In this article, I will cover how these attributes are created and show a couple of examples.

What are Attribute Directives?

Angular directives are special constructs that allow modification of HTML elements and components. Attribute directives are also applied through attributes, hence the name. There exist other types of directives such as structural directives as well, but we’re just going to focus on attribute directives.

If you’ve used Angular before then you have almost certainly used a couple of the attribute directives I mentioned earlier before. You are not limited to just the built-in directives though. Angular allows you to create your own!

Creating Attribute Directives

Directives can be created using code generation via the ng CLI tool.

ng generate directive <directive_name_here>

This will create a file to house your directive and also an accompanying test file as well. The contents of the directive are very barebones to start with.

Let’s take a look.

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

@Directive({
  selector: '[appExample]',
})
export class ExampleDirective {
  constructor() {}
}

You will see here that directives are created using a @Directive decorator. The selector in this case is the name of the attribute as it is intended to be used in your templates. The square brackets around the name make it an attribute selector, which is what we want for a custom attribute directive. I would also recommend that a prefix is always used for directive names to minimize the risk of conflicts. It should also go without saying to avoid using the ng prefix for custom directives to avoid confusion.

Now, let’s go over the lifecycle of a directive. The constructor is called with a reference to the ElementRef that the directive was bound to. You can do any initialization here if needed. This element reference is dependency injected, and will be available outside the constructor as well. You can also set up @HostListener handlers if you need to add functionality that runs in response to user interaction with the element or component, and @Input properties if you need to pass data to the directive.

Click Away Directive

One useful directive that doesn’t come standard is a click away directive. This is one that I have used before in my projects, and is very easy to understand. This directive uses host listeners to listen for user input, and determine whether the element that directive is attached to should be visible or not after the click event occurs.

@Directive({
  selector: '[appClickAway]',
})
export class ClickAwayDirective {
  @Output() onClickAway: EventEmitter<PointerEvent> = new EventEmitter();

  constructor(private elementRef: ElementRef) {}

  @HostListener('document:click', ['$event'])
  onClick(event: PointerEvent): void {
    if (!this.elementRef.nativeElement.contains(event.target)) {
      this.onClickAway.emit(event);
    }
  }
}

There are a few new things in this directive we’ll briefly go over. The first thing is the event emitter output onClickAway. A generic directive isn’t going to know how to handle click away behavior by itself as this will change based on your use case when using the directive. To solve this issue, we make the directive emit an event that the user of the directive can listen for.

The other part is the click handler. We use @HostListener to attach a click handler so we can run our click away logic whenever clicks are done. The one interesting thing about this directive is that it listens to all click events since we’ve specified ‘document’ in the first parameter. The reason for this is because we care about listening for clicking anything that isn’t the element or component that the directive is attached to.

If we didn’t do this, then the event handler would only fire when clicking on the component the directive is attached to, which defeats the purpose of a click away handler. Once we’ve determined the element was not clicked, we emit the aforementioned event.

Using this directive makes it trivial to implement click away functionality for both modals and context menus alike. If we have a custom dialog component we could hook it up like this:

<div class="shadow">
  <div class="dialog" appClickAway (onClickAway)="clickAway($event)">
    <h2>Dialog Box</h2>
    <p>This is a paragraph with content!</p>
  </div>
</div>

If you want to see this directive in action, then you can find it in our blog demos repo here.

Drag and Drop Directive

Another useful directive is one that assists with drag and drop operations. The following directive makes elements draggable, and executes a function with a reference to the location where the element was dragged to.

@Directive({
  selector: '[appDragDrop]',
})
export class DragDropDirective implements OnInit, OnDestroy {
  @Output() onDragDrop: EventEmitter<MouseEvent> = new EventEmitter();

  mouseDown$ = new Subject<MouseEvent>();
  mouseUp$ = new Subject<MouseEvent>();
  destroy$ = new Subject();

  constructor(private elementRef: ElementRef) {}

  ngOnInit(): void {
    this.mouseDown$
      .pipe(takeUntil(this.destroy$))
      .pipe(exhaustMap(() => this.mouseUp$.pipe(take(1))))
      .subscribe((event) => {
        if (
          event.target &&
          event.target instanceof Element &&
          !this.elementRef.nativeElement.contains(event.target)
        ) {
          this.onDragDrop.emit(event);
        }
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next(null);
    this.destroy$.complete();
  }

  @HostListener('mousedown', ['$event'])
  onMouseDown(event: MouseEvent): void {
    this.mouseDown$.next(event);
  }

  @HostListener('document:mouseup', ['$event'])
  onMouseUp(event: MouseEvent): void {
    this.mouseUp$.next(event);
  }
}

Just like the previous directive example an event emitter is used so the user of the directive can associate custom functionality with it. RxJs is also utilized for the drag and drop detection. This directive uses the exhaustMap function to create an observable that emits both after a mouse down, and finally a mouse up is done. With that observable, we can subscribe to it and call the drag and drop callback so long as the element that’s dragged on isn’t the component itself.

Note how the mouse down event is local to the component while the mouse up event is attached to the document. For mouse down, this is done since we only want the start of the dragging to be initiated from clicking the component itself. The mouse up must listen to the document since the dragging has to end on something that isn’t the component that we’re dragging.

Just like the previous directive, we simply need to reference the attribute and register an event handler.

<div class="drag-drop-demo" appDragDrop (onDragDrop)="drop($event)">
  Drag me over something!
</div>

Conclusion

In this article, we have learned how to write our own custom attribute directives and demonstrated a couple of practical examples of directives you might use or encounter in the real world. I hope you found this introduction to directives useful, and that it helps you with writing your own directives in the future! You can find the examples shown here in our blog demos repository if you want to use them yourself.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co