Angular Custom Attribute Directives

Implement attribute directives with @Directive, @HostBinding and @HostListener. Use Renderer2, ElementRef and NativeElement to change the appearance and behavior of DOM elements.

Updated Feb 2018, Angular version 5.2.1

Creating a Directive

Let's start with a simple directive to change a regular button into a toggle button.

First, we add the appToggle attribute to a button element.

toggle-button.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-toggle-button',
  template: `
    <header>Toggle the button below.</header>
    <p>
      <button appToggle>Click me!</button>
    </p>
  `,
  styles: [`
    button.selected {
      color: White;
      background: SeaGreen ;
    }
  `]

})
export class ToggleButtonComponent {
}

Then specify [appToggle] as the selector in the directive. As you can see, attribute directives are created by adding the @Directive decorator to a class, with a CSS selector to identify the host element(s).

You will also need to add the directive class to the declarations array of @NgModule.

toggle.directive.ts
import { Directive, HostBinding, HostListener, Input } from '@angular/core';

@Directive({ selector: '[appToggle]' })
export class ToggleButtonDirective { @HostBinding('class.selected') private hostSelected = false; @HostListener('click') private onClick() { this.hostSelected = !this.hostSelected; } }

The host is the DOM element that hosts the directive, which in our case is the <button> element.

@HostBinding binds to a property on the host. Here we add or remove the selected class depending on the value of the hostSelected variable.

@HostListener binds to a standard DOM event on the host, such as the click event.

toggle.directive.ts
import { Directive, HostBinding, HostListener, Input } from '@angular/core';

@Directive({
  selector: '[appToggle]'
})
export class ToggleButtonDirective {

  @HostBinding('class.selected')
  private hostSelected = false;

  @HostListener('click')
  private onClick() {
    this.hostSelected = !this.hostSelected;
  }
}

Re-using the Directive

We can re-use this directive on a different type of element, a paragraph for example.

toggle-para.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <header>Click on the paragraphs to toggle selection.</header>
    <p appToggle *ngFor="let para of paras">
      {{para}}
    </p>
  `,
  styles: [`
    p {
      color: DarkGray;
      cursor: pointer;
      margin-bottom: 10px;
    }
    p.selected {
       color: Black;
       border: 1px solid DimGray;
       padding: 9px;
     }
  `]
})
export class ToggleParaComponent {
  paras: string[] = [
    'Angular turns your templates into code that\'s highly optimized for today\'s JavaScript virtual machines, ' +
    'giving you all the benefits of hand-written code with the productivity of a framework.',
    'Serve the first view of your application on node.js, .NET, PHP, and other servers for near-instant ' +
    'rendering in just HTML and CSS. Also paves the way for sites that optimize for SEO.',
    'Angular apps load quickly with the new Component Router, which delivers automatic code-splitting ' +
    'so users only load code required to render the view they request.'
    ];
}

The same directive has a different effect when applied to a paragraph element as you can see here.

More about @HostBinding and @HostListener

This next example uses a couple of directives to show several examples of @HostBinding and @HostListener.

The Component

Here is the component implementation. It's not obvious but we are using two directives here; one on the section element and one on the input fields.

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

@Component({
  selector: 'app-host',
  template: `
    <header [hidden]="show">
      Click the box to change hover color, double click to remove.
    </header>
    <header [hidden]="!show">
      Enter an input field to see the border, press 'esc' key to remove.
    </header>
    <main>
      <section class="container border">
        <input type="date">
        <input type="date">
      </section>
      <section>
        <p>
          <button id="show" (click)="show = !show">{{show ? 'Hide' : 'Show'}}</button>
        </p>
        <header>Click the button</header>
      </section>
    </main>
  `,
  styles: [`
    main {
      display: flex;
      align-items: center;
    }
    section.container {
      display: flex;
      justify-content: space-around;
      height: 24px;
      margin-left: 20px;
      padding: 30px 20px;
      width: 450px;
    }
  `]
})
export class HostComponent {
  show: boolean;
}

The Directive on the Section Element

This is because, rather than using a named attribute, the selector of our first directive is section.container which attaches itself to any section element with the container class.

The @HostBindings (in yellow) set the background color of the box to the value in the color variable, and the title to some descriptive text.

The @HostListeners (in green) listen for the specified DOM events (such as mouseover, click etc) and execute the associated methods when they are received.

container.directive.ts
import { Directive, HostBinding, HostListener } from '@angular/core';

@Directive({
  selector: 'section.container',
})
export class ContainerDirective {

  private colors = colorGenerator();
  private boxColor = null;

  @HostBinding('style.background-color')
  private color: string;

  @HostBinding('title')
  private get title() {
    return `${this.color ? this.color : 'Blank'} box`;
  }

  @HostListener('mouseover')
  private onMouseOver() {
    this.color = this.boxColor;
  }

  @HostListener('mouseleave')
  private onMouseLeave() {
    this.color = null;
  }

  @HostListener('click')
  private onClick() {
    this.boxColor = this.colors.next().value;
    this.onMouseOver();
  }

  @HostListener('dblclick')
  private onDoubleClick() {
    this.boxColor = null;
    this.onMouseOver();
  }
}

function* colorGenerator(): IterableIterator<string> {
  const colors = ['LightBlue', 'Pink', 'LightGreen'];
  let i = -1;
  while (true) {
    i < colors.length - 1 ? i++ : i = 0;
    yield colors[i];
  }
}

The Directive on the Date Input Fields

The second directive will associate itself with any input elements with a type of date.

Note that this technique is mainly to illustrate how different selectors can be used. In many cases it may be better to use an explicit attribute selector for clarity.

The first two @HostBindings remove the default input field outline, and optionally add a border depending on the value of the showBorder variable. This adds an OrangeRed border around the active input field.

The first two @HostListeners highlighted in green listen for the focus and blur events, and set the showBorder variable accordingly.

The last one removes the border from the current input field when the escape key is pressed. The built-in $event variable of type KeyboardEvent is passed to the method in order to identify the key being pressed.

date-input.directive.ts
import { Directive, HostBinding, HostListener } from '@angular/core';

@Directive({
  selector: 'input[type="date"]'
})
export class DateInputDirective {

  private showBorder: boolean;

  @HostBinding('style.outline')
  private outline = 'none';

  @HostBinding('style.border')
  private get borderStyle(): string {
    return this.showBorder ? '2px solid OrangeRed' : '';
  }

  @HostBinding('hidden')
  private hidden = true;

  @HostListener('focus')
  private onFocus() {
    this.showBorder = true;
  }

  @HostListener('blur')
  private onBlur() {
    this.showBorder = false;
  }

  @HostListener('keydown', ['$event'])
  private onKeyDown(event: KeyboardEvent) {
    if (event.key === 'Escape') {
      this.showBorder = false;
    }
  }

  @HostListener('click', ['$event'])
  @HostListener('dblclick', ['$event'])
  @HostListener('contextmenu', ['$event'])
  private onMouseClicks(mouseClick: MouseEvent) {
    if (mouseClick.type === 'contextmenu') {
      alert('Context menu is not supported');
      return false;
    }
    event.stopPropagation();
  }

  @HostListener('document:click', ['$event.target'])
  public onDocumentClick(target: HTMLElement) {
    if (target.id === 'show') {
      this.hidden = !this.hidden;
    }
  }
}

The remaining @HostListeners demonstrate a couple of extra features.

Firstly, multiple @HostListeners can be applied to the same method. Here we handle several mouse events and use the $event parameter to distinguish between them.

Secondly, we use document:click to listen for an event on the document. This shows that directives can in fact handle events outside of the host. window: (eg window:scroll), and body: are supported too.

$event.target passes in the HTMLElement that dispatched the event.

date-input.directive.ts
import { Directive, HostBinding, HostListener } from '@angular/core';

@Directive({
  selector: 'input[type="date"]'
})
export class DateInputDirective {

  private showBorder: boolean;

  @HostBinding('style.outline')
  private outline = 'none';

  @HostBinding('style.border')
  private get borderStyle(): string {
    return this.showBorder ? '2px solid OrangeRed' : '';
  }

  @HostBinding('hidden')
  private hidden = true;

  @HostListener('focus')
  private onFocus() {
    this.showBorder = true;
  }

  @HostListener('blur')
  private onBlur() {
    this.showBorder = false;
  }

  @HostListener('keydown', ['$event'])
  private onKeyDown(event: KeyboardEvent) {
    if (event.key === 'Escape') {
      this.showBorder = false;
    }
  }

  @HostListener('click', ['$event'])
  @HostListener('dblclick', ['$event'])
  @HostListener('contextmenu', ['$event'])
  private onMouseClicks(mouseClick: MouseEvent) {
    if (mouseClick.type === 'contextmenu') {
      alert('Context menu is not supported');
      return false;
    }
    event.stopPropagation();
  }

  @HostListener('document:click', ['$event.target'])
  public onDocumentClick(target: HTMLElement) {
    if (target.id === 'show') {
      this.hidden = !this.hidden;
    }
  }
}

Passing Data Into a Directive

We pass data into a directive using property binding. For example, these appConfirm directives take a confirmation message to be displayed when the host element is clicked, and a function to be executed if the user selects OK on the dialog box.

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

@Component({
  selector: 'app-confirm',
  template: `
    <header>Click the buttons to see the confirmation message.</header>
    <p>
      <button [appConfirm]="'Are you sure you want to change the color to blue?'"
              [functionToRun]="setBoxColor('dodgerblue')"
              [disabled]="boxColor==='dodgerblue'">
        Blue
      </button>
      <button appConfirm="Are you sure you want to change the color to red?"
              [functionToRun]="setBoxColor('crimson')"
              [disabled]="boxColor==='crimson'">
        Red
      </button>
    </p>
    <div [style.backgroundColor]="boxColor"
         class="box">
    </div>
  `,
  styles: [`
    div.box {
      height: 50px;
      width: 210px;
    }
  `]
})
export class ConfirmComponent {
  boxColor = 'dodgerblue';

  setBoxColor(color: string) {
    return () => this.boxColor = color;
  }
}

Notice the difference between the two appConfirm bindings above. The outcome is the same but the first uses a standard property binding while the second uses one-time string initialization. For more details on the difference between the two, and component communication in general, see this component input output article.

In the directive, the properties are bound to variables which are decorated with @Input(). functionToRun matches the property name directly while appConfirm uses an @Input alias (the value in parentheses) to bind to the message variable.

confirm.directive.ts
import { Directive, HostListener, Input } from '@angular/core';

@Directive({
  selector: '[appConfirm]'
})
export class ConfirmDirective {

  @Input('appConfirm') message = 'Are you sure?';
  @Input() functionToRun = () => {};

  @HostListener('click')
  confirm() {
    if (window.confirm(this.message)) {
      this.functionToRun();
    }
  }
}

Furthermore, because the @Input alias is the same as the directive selector (the two values in yellow above), we can use this binding syntax

<button [appConfirm]="'Really?'" ..

or this (using one-time string intialization)

<button appConfirm="Really?" ..

whereas without the alias we would need to do this

<button appConfirm message="Really?" ..

Let's see it in action.

Passing Data Out of a Directive

Take a look at this demo app. Try selecting some text by dragging the mouse or double clicking a word.

EventEmitter

We send data from a directive using an instance of the EventEmitter class which has been decorated with @Output. This is the same technique as a child component uses to communicate with its parent and is described in detail in this component input output tutorial.

In our example, when the mouseup event is received, the emit method on the the EventEmitter object sends an event called textSelected (the name of the property) with the chosen text as the payload.

text-snippets.directive.ts
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';

@Directive({
  selector: '[appTextSelector]',
  exportAs: 'appTextSelector'
})
export class TextSnippetDirective {

  @Output() textSelected = new EventEmitter<string>();

  private _snippets: string[] = [];

  @HostListener('mouseup')
  onSelected() {
    const text = document.getSelection().toString();
    if (text) {
      this._snippets.push(text);
      this.textSelected.emit(text);
    }
  }

  get snippets(): string[] {
    return this._snippets;
  }

  clear(): void {
    this._snippets = [];
  }
}

In the component, we use Angular's event binding syntax to receive the textSelected event and access the payload using the built-in $event variable.

text-snippets.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-text-selector',
  template: `
    <header [hidden]="quote.snippets.length">Select some of the text below.</header>
    <header [hidden]="!quote.snippets.length">Select some more to add to the list.</header>
    <p appTextSelector
       #quote="appTextSelector"
       (textSelected)="onSelected($event)">
      {{movieQuote}}
    </p>
    <p>Last selection: <em>{{text || 'none'}}</em></p>
    <div>
      <a [hidden]="!quote.snippets.length"
         (click)="quote.clear(); false" href="">
        Clear
      </a>
      <ol>
        <li *ngFor="let snippet of quote.snippets">{{snippet}}</li>
      </ol>
    </div>
  `,
  styles: [`
    em {
      color: Green;
      font-style: normal;
    }
  `]
})
export class TextSnippetComponent {
  movieQuote = `
      Didn’t see the first shark for about a half-hour. Tiger. 13-footer.
      You know how you know that in the water, Chief? You can tell by lookin’
      from the dorsal to the tail. What we didn’t know, was that our bomb
      mission was so secret, no distress signal had been sent. They didn’t
      even list us overdue for a week. Very first light, Chief, sharks come
      cruisin’ by, so we formed ourselves into tight groups. It was sorta
      like you see in the calendars, you know the infantry squares in the
      old calendars like the Battle of Waterloo and the idea was the shark
      come to the nearest man, that man he starts poundin’ and hollerin’
      and sometimes that shark he go away… but sometimes he wouldn’t go away.`;

  text: string;

  onSelected(text: string) {
    this.text = text;
  }
}

Template Reference Variable

The example also uses a template reference variable to access the public methods on the directive.

text-snippets.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-text-selector',
  template: `
    <header [hidden]="quote.snippets.length">Select some of the text below.</header>
    <header [hidden]="!quote.snippets.length">Select some more to add to the list.</header>
    <p appTextSelector
       #quote="appTextSelector"
       (textSelected)="onSelected($event)">
      {{movieQuote}}
    </p>
    <p>Last selection: <em>{{text || 'none'}}</em></p>
    <div>
      <a [hidden]="!quote.snippets.length"
         (click)="quote.clear(); false" href="">
        Clear
      </a>
      <ol>
        <li *ngFor="let snippet of quote.snippets">{{snippet}}</li>
      </ol>
    </div>
  `,
  styles: [`
    em {
      color: Green;
      font-style: normal;
    }
  `]
})
export class TextSnippetComponent {
  movieQuote = `
      Didn’t see the first shark for about a half-hour. Tiger. 13-footer.
      You know how you know that in the water, Chief? You can tell by lookin’
      from the dorsal to the tail. What we didn’t know, was that our bomb
      mission was so secret, no distress signal had been sent. They didn’t
      even list us overdue for a week. Very first light, Chief, sharks come
      cruisin’ by, so we formed ourselves into tight groups. It was sorta
      like you see in the calendars, you know the infantry squares in the
      old calendars like the Battle of Waterloo and the idea was the shark
      come to the nearest man, that man he starts poundin’ and hollerin’
      and sometimes that shark he go away… but sometimes he wouldn’t go away.`;

  text: string;

  onSelected(text: string) {
    this.text = text;
  }
}

This is only possible because we included the exportAs property in the @Directive decorator.

The template reference variable references this exported value (appTextSelector) which is often the same name as the attribute selector, but doesn't need to be.

text-snippets.directive.ts
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';

@Directive({
  selector: '[appTextSelector]',
  exportAs: 'appTextSelector'
})
export class TextSnippetDirective {

  @Output() textSelected = new EventEmitter<string>();

  private _snippets: string[] = [];

  @HostListener('mouseup')
  onSelected() {
    const text = document.getSelection().toString();
    if (text) {
      this._snippets.push(text);
      this.textSelected.emit(text);
    }
  }

  get snippets(): string[] {
    return this._snippets;
  }

  clear(): void {
    this._snippets = [];
  }
}

Accessing the Host DOM Element

To work with the host element directly, inject ElementRef in the constructor of the directive and use the nativeElement property to access the DOM object.

However, before you do, you might want to read the Angular documentation for ElementRef which recommends using nativeElement only as a last resort due to security issues.

Here we do choose to use nativeElement to set the focus to a particular input field.

item-focus.directive.ts
import { Directive, ElementRef, OnInit } from '@angular/core';

@Directive({
  selector: 'input[appFocus]'
})
export class ItemFocusDirective implements OnInit {

  constructor(private elementRef: ElementRef) {
  }

  ngOnInit(): void {
    this.elementRef.nativeElement.focus();
  }
}
item-focus.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-item-focus',
  template: `
    <header>Focus is on the second input.</header>
    <form>
      One: <input type="text">
      Two: <input appFocus type="text">
      Three: <input type="text">
    </form>
  `
})
export class ItemFocusComponent {
}

The focus will be on the second input field.

Using Renderer2

Paragraph Stats

Let's add a second directive called appStats to the paragraph toggle component we saw earlier, and pass the paragraph as an input binding.

toggle-para-with-stats.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <header>Hover your mouse over the paragraphs to see the stats.</header>
    <p appToggle [appStats]="para" *ngFor="let para of paras">
      {{para}}
    </p>
  `,
  styles: [`
    p {
      color: DarkGray;
      cursor: pointer;
      margin-bottom: 10px;
    }
    p.selected {
       color: Black;
       border: 1px solid DimGray;
       padding: 9px;
     }
  `]
})
export class ToggleParaWithStatsComponent {
  paras: string[] = [
    'Angular turns your templates into code that\'s highly optimized for today\'s JavaScript virtual machines, ' +
    'giving you all the benefits of hand-written code with the productivity of a framework.',
    'Serve the first view of your application on node.js, .NET, PHP, and other servers for near-instant ' +
    'rendering in just HTML and CSS. Also paves the way for sites that optimize for SEO.',
    'Angular apps load quickly with the new Component Router, which delivers automatic code-splitting ' +
    'so users only load code required to render the view they request.'
  ];
}

Note that we can have multiple input bindings on multiple directives on the same element. For example

<div p1="abc" directive1 [p2]="'xyz'" directive2 [p3]="123" p1="999"> 

The position doesn't matter. If p2 is a property of directive1 or directive2 then it will work just fine. In fact the properties can be on both directives and it will still work. In the case of the duplicated p1 property, the first value (abc) will be used and the second one (999) is ignored.

Our new appStats directive will show some basic stats when the user hovers the mouse over a paragraph.

The Angular documentation recommends that we use the built-in Renderer2 class to access DOM elements, and use nativeElement directly only as a last resort.

So we inject an instance of Renderer2 into the directive constructor and use it to set the title property of the host element to our calculated paragraph stats.

We still use nativeElement to reference the host element, but any changes made to it are delegated to Renderer2.

stats.directive.ts
import { AfterViewInit, Directive, ElementRef, Input, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appStats]'
})
export class StatsDirective implements AfterViewInit {

  @Input('appStats') text: string;

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

  ngAfterViewInit(): void {
    const stats = `Words: ${this.text.split(' ').length}\n` +
      `Characters (with spaces): ${this.text.length}\n` +
      `Characters (no spaces): ${this.text.replace(/ /g, '').length} `;
    this.renderer.setProperty(this.elementRef.nativeElement, 'title', stats);
  }

}

Glossary

Another example provides a glossary of terms.

A collection of terms and definitions is passed into the directive and the text is automatically updated to display the definitions when the user hovers over a term.

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

@Component({
  selector: 'app-glossary',
  template: `
    <header>Hover your mouse over the colored text to see a definition.</header>
    <p [appGlossary]="glossaryTerms">
      {{techieText}}
    </p>
  `
})
export class GlossaryComponent {
  techieText = `
      TypeScript is a free and open-source programming language developed
      and maintained by Microsoft. It is a strict syntactical superset of
      JavaScript, and adds optional static typing to the language. Anders Hejlsberg,
      lead architect of C# and creator of Delphi and Turbo Pascal, has worked on
      the development of TypeScript. TypeScript may be used to develop JavaScript
      applications for client-side or server-side (Node.js) execution.
      TypeScript is designed for development of large applications and compiles to
      JavaScript. As TypeScript is a superset of JavaScript, existing JavaScript
      programs are also valid TypeScript programs.`;

  glossaryTerms: {[term: string]: string} = {
    'JavaScript':   'A high-level, dynamic, weakly typed, prototype-based, multi-paradigm, ' +
                    'and interpreted programming language.',
    'Node.js':      'Node.js is a JavaScript runtime built on Chrome\'s V8 JavaScript engine.',
    'open-source':  'Software for which the original source code is made freely available and ' +
                    'may be redistributed and modified.',
    'superset':     'A group of commands or functions that exceed the capabilities of the ' +
                    'original specification.',
  };
}

This time, however, we use nativeElement to read the innerText of the host element and Renderer2 to create the necessary elements.

glossary.directive.ts
import { AfterViewInit, Directive, ElementRef, Input, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appGlossary]'
})
export class GlossaryDirective implements AfterViewInit {

  @Input('appGlossary') glossary: {[term: string]: string};
  private text: string;

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

  ngAfterViewInit() {
    this.text = this.elementRef.nativeElement.innerText;
    const root = this.renderer.selectRootElement(this.elementRef.nativeElement);

    const terms = Object.keys(this.glossary);
    const term = new RegExp(`(${terms.join('|')})`, 'g');

    this.text
      .split(term)
      .forEach( item => {
        const section = terms.includes(item) ?
            this.createTermSpan(item) :
            this.renderer.createText(item);
        this.renderer.appendChild(root, section);
      });
  }

private createTermSpan(term: string): HTMLSpanElement { const span = this.renderer.createElement('span'); const text = this.renderer.createText(term); this.renderer.appendChild(span, text); this.renderer.setStyle(span, 'color', 'tomato'); this.renderer.setStyle(span, 'cursor', 'pointer'); this.renderer.setProperty(span, 'title', this.glossary[term]); return span; }
}

Note: watch out for this command which is used in the example above

const root = this.renderer.selectRootElement(this.elementRef.nativeElement);

It actually removes all the children of the specified element from the DOM. However, in this case we are re-creating the element contents so this is precisely the functionality we need.

Text Highlighting

Another example which replaces the host contents is this text highlighter.

The text to highlight and the color to highlight it in are passed to the directive as input bindings.

text-highlight.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <header>
      Enter some text such as 'rule' or 'Club' to highlight (case sensitive).
    </header>
    <div>
      <input #text
             (keyup)="0"
             type="text"
             placeholder="enter text to highlight">
      <select #color (change)="0">
        <option value="yellow" selected>Yellow</option>
        <option value="pink">Red</option>
        <option value="lightgreen">Green</option>
      </select>
    </div>
    <p [appTextHighlight]="text.value" [color]="color.value">
      {{movieQuote}}
    </p>
  `
})
export class TextHighlightComponent {
  movieQuote = `
      The first rule of Fight Club is: You do not talk about Fight Club.
      The second rule of Fight Club is: You do not talk about Fight Club.
      Third rule of Fight Club: someone yells stop, goes limp, taps out, the fight is over.
      Fourth rule: only two guys to a fight. Fifth rule: one fight at a time, fellas.
      Sixth rule: no shirts, no shoes.
      Seventh rule: fights will go on as long as they have to.
      And the eighth and final rule: if this is your first night at Fight Club,
      you have to fight.
  `;
}

In the directive, the element text is searched and any occurrences of the user's input are wrapped in mark tags.

So if the user enters 'rule', then

Third rule of Fight Club..

becomes

Third <mark style="background-color: yellow;">rule</mark> of Fight Club..

This time we re-construct the text as an HTML string and set the innerHTML of the host element to this new value.

text-highlight.directive.ts
import { Directive, ElementRef, Input, OnChanges, Renderer2, SimpleChanges } from '@angular/core';

@Directive({
  selector: '[appTextHighlight]',
})
export class TextHighlightDirective implements OnChanges {

  @Input('appTextHighlight') textToHighlight: string;
  @Input() color = 'yellow';

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

  ngOnChanges(changes: SimpleChanges): void {
    if (!this.elementRef.nativeElement.innerText) { return; }

const innerText = this.elementRef.nativeElement.innerText; const markElement = `<mark style="background-color: ${this.color};">${this.textToHighlight}</mark>`; const html = innerText .split(this.textToHighlight) .join(markElement); this.renderer.setProperty(this.elementRef.nativeElement, 'innerHTML', html);
} }

Star Rating

And finally, we take a list of movie images and dynamically add a configurable number of stars to allow the user to rate the movie.

In this example we choose five stars which overrides the default in the input binding of the directive.

star-rating.component.ts
import { Component } from '@angular/core';
import { environment } from '../../environments/environment';

@Component({
  selector: 'app-star-rating',
  template: `
    <header>
      Click on the stars to rate the movie, double click the image to remove selected stars.
    </header>
    <main>
      <div [appStarRating]="5" *ngFor="let movie of movies">
        <img src="{{root}}/assets/images/{{movie}}.jpg">
      </div>
    </main>
  `,
  styles: [`
    main {
      display: flex;
    }
    .star {
      cursor: pointer;
      font-size: 22px;
    }
    .selected {
      color: gold;
    }
  `]
})
export class StarRatingComponent {

  root = environment.imageRoot;

  movies: string[] = [
    'shawshank-redemption',
    'the-godfather',
    'the-dark-knight'
  ];
}

The directive uses several Renderer2 methods to construct the HTML elements for the stars and then append them to the movie image.

ngOnInit creates the specified number of star elements and stores them in an array. It then loops through the array and appends the star elements to a new section element which is then appended to the host element.

star-rating.directive.ts
import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appStarRating]'
})
export class StarRatingDirective implements OnInit {

  @Input('appStarRating') numberOfStars = 3;

  private seq: IterableIterator<number> = seqGen(0);
  private starElements: HTMLSpanElement[];

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

ngOnInit(): void { this.starElements = [...Array(this.numberOfStars)] .map( () => this.createStarElement()); const starGroup = this.renderer.createElement('section'); this.starElements .forEach( starEl => this.renderer.appendChild(starGroup, starEl) ); this.renderer.appendChild(this.elementRef.nativeElement, starGroup); }
@HostListener('click', ['$event.target']) onClick(target: HTMLElement) { if (! target.classList.contains('star')) { return; } const selectedStarPos = +target.getAttribute('data-pos'); this.setStarGroupState(selectedStarPos); } @HostListener('dblclick', ['$event.target']) onDoubleClick(target: HTMLElement) { if (target.nodeName !== 'IMG') { return; } this.setStarGroupState(); } private createStarElement(): HTMLSpanElement { const span = this.renderer.createElement('span'); this.renderer.setAttribute(span, 'data-pos', this.seq.next().value.toString()); this.renderer.addClass(span, 'star'); this.setStarState(span, false); return span; } private setStarGroupState(selectedStarCount = -1): void { this.starElements .forEach( starEl => this.setStarState( starEl, +starEl.getAttribute('data-pos') <= selectedStarCount ) ); } private setStarState(starEl: HTMLSpanElement, isSet: boolean): void { this.renderer.setProperty(starEl, 'innerHTML', isSet ? '&starf;' : '&star;'); isSet ? this.renderer.addClass(starEl, 'selected') : this.renderer.removeClass(starEl, 'selected'); } } function* seqGen(from: number): IterableIterator<number> { let i = from; while (true) { yield i++; } }

The createStarElement method uses Renderer2 to create a span element, sets an attribute called data-pos containing the position of the star within the group, and adds the star class to the element.

star-rating.directive.ts
import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appStarRating]'
})
export class StarRatingDirective implements OnInit {

  @Input('appStarRating') numberOfStars = 3;

  private seq: IterableIterator<number> = seqGen(0);
  private starElements: HTMLSpanElement[];

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

  ngOnInit(): void {
    this.starElements = [...Array(this.numberOfStars)]
      .map( () => this.createStarElement());

    const starGroup = this.renderer.createElement('section');
    this.starElements
      .forEach( starEl =>
        this.renderer.appendChild(starGroup, starEl)
      );

    this.renderer.appendChild(this.elementRef.nativeElement, starGroup);
  }

  @HostListener('click', ['$event.target'])
  onClick(target: HTMLElement) {
    if (! target.classList.contains('star')) { return; }
    const selectedStarPos = +target.getAttribute('data-pos');
    this.setStarGroupState(selectedStarPos);
  }

  @HostListener('dblclick', ['$event.target'])
  onDoubleClick(target: HTMLElement) {
    if (target.nodeName !== 'IMG') { return; }
    this.setStarGroupState();
  }

private createStarElement(): HTMLSpanElement { const span = this.renderer.createElement('span'); this.renderer.setAttribute(span, 'data-pos', this.seq.next().value.toString()); this.renderer.addClass(span, 'star'); this.setStarState(span, false); return span; }
private setStarGroupState(selectedStarCount = -1): void { this.starElements .forEach( starEl => this.setStarState( starEl, +starEl.getAttribute('data-pos') <= selectedStarCount ) ); } private setStarState(starEl: HTMLSpanElement, isSet: boolean): void { this.renderer.setProperty(starEl, 'innerHTML', isSet ? '&starf;' : '&star;'); isSet ? this.renderer.addClass(starEl, 'selected') : this.renderer.removeClass(starEl, 'selected'); } } function* seqGen(from: number): IterableIterator<number> { let i = from; while (true) { yield i++; } }

The @HostListeners handle a single click on a star and a double click on an image. They both call the setStarGroupState method which loops through the star elements and sets the display character (&starf; or &star;) and the CSS class accordingly.

star-rating.directive.ts
import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appStarRating]'
})
export class StarRatingDirective implements OnInit {

  @Input('appStarRating') numberOfStars = 3;

  private seq: IterableIterator<number> = seqGen(0);
  private starElements: HTMLSpanElement[];

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

  ngOnInit(): void {
    this.starElements = [...Array(this.numberOfStars)]
      .map( () => this.createStarElement());

    const starGroup = this.renderer.createElement('section');
    this.starElements
      .forEach( starEl =>
        this.renderer.appendChild(starGroup, starEl)
      );

    this.renderer.appendChild(this.elementRef.nativeElement, starGroup);
  }

@HostListener('click', ['$event.target']) onClick(target: HTMLElement) { if (! target.classList.contains('star')) { return; } const selectedStarPos = +target.getAttribute('data-pos'); this.setStarGroupState(selectedStarPos); } @HostListener('dblclick', ['$event.target']) onDoubleClick(target: HTMLElement) { if (target.nodeName !== 'IMG') { return; } this.setStarGroupState(); }
private createStarElement(): HTMLSpanElement { const span = this.renderer.createElement('span'); this.renderer.setAttribute(span, 'data-pos', this.seq.next().value.toString()); this.renderer.addClass(span, 'star'); this.setStarState(span, false); return span; }
private setStarGroupState(selectedStarCount = -1): void { this.starElements .forEach( starEl => this.setStarState( starEl, +starEl.getAttribute('data-pos') <= selectedStarCount ) ); }
private setStarState(starEl: HTMLSpanElement, isSet: boolean): void { this.renderer.setProperty(starEl, 'innerHTML', isSet ? '&starf;' : '&star;'); isSet ? this.renderer.addClass(starEl, 'selected') : this.renderer.removeClass(starEl, 'selected'); } } function* seqGen(from: number): IterableIterator<number> { let i = from; while (true) { yield i++; } }

Please note that these examples are designed specifically to demonstrate features of custom directives. They are not necessarily the the best solution for the problem.