Power up host component by directive composition API in Angular

Connie Leung - Aug 19 '23 - - Dev Community

Introduction

In this blog post, I am going to illustrate how to use directive composition API to power up host component. Directive composition API is new in Angular 15 and it allows host component to register standalone directives to modify its appearance. Moreover, Angular developers can use standalone directives as building blocks of complex directives with the help of the API.

What is it like without Directive Composition API

// background-color.directive.ts
import { Directive, HostBinding, Input } from '@angular/core';

@Directive({
  selector: '[appBackgroundColor]',
  standalone: true,
})
export class BackgroundColorDirective {
  @Input()
  @HostBinding('style.background-color')
  bgColor = 'goldenrod';
}

// font-size.directive.ts
import { Directive, HostBinding, Input } from '@angular/core';

@Directive({
  selector: '[appFontSize]',
  standalone: true,
})
export class FontSizeDirective {
  @Input()
  @HostBinding('style.font-size.px')
  size = 18;
}

// hover-block.directive.ts

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

@Directive({
  selector: '[appHoverBlock]',
  standalone: true,
})
export class HoverBlockDirective {

  el = inject<ElementRef<HTMLParagraphElement>>(ElementRef<HTMLParagraphElement>).nativeElement;

  @HostListener('mouseenter')
  mouseEnter() {
    this.el.style.fontWeight = 'bold';
  }

  @HostListener('mouseleave')
  mouseLeave() {
    this.el.style.fontWeight = 'normal';
  }
}
Enter fullscreen mode Exit fullscreen mode

In my demo, I have 3 directives:

  • BackgroundColorDirective - set the background color of a block
  • FontSizeDirective - set the font size of a text
  • HoverBlockDirective - bold text when mouse hovers on a block

In the old way, I can apply these directives and directive inputs by specifying them on the HTML code of the host component

// hello-username-without-api.component.ts

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

@Component({
  selector: 'app-hello-username-without-api',
  standalone: true,
  template: `
    <p>Hello {{username}}!!! Hover me to bold text</p>
  `,
  styles: [`
    :host {
      display: block;
    }

    p {
      padding: 0.5rem;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HelloUsernameWithoutApiComponent {
  @Input({ required: true })
  username!: string;
}
Enter fullscreen mode Exit fullscreen mode

HelloUsernameWithoutApiComponent is a component that does not leverage the Directive Composition API and I want to render it in AppComponent.

// main.ts

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [FormsModule, HelloUsernameWithoutApiComponent, FontSizeDirective,
  BackgroundColorDirective, HoverBlockDirective],
  template: `
    <h3>Practice standalone directive API</h3>

    <p>Host component uses directives the old way</p>
    <app-hello-username-without-api appFontSize
      appBackgroundColor appHoverBlock
      [username]="'John Doe'" [size]="size" [bgColor]="bgColor">
    </app-hello-username-without-api>

    <div>
      <label for="size">
        <span>Size:</span>
        <input id="size" name="size" type="number"
          [(ngModel)]="size"  
          [min]="minSize"
          [max]="maxSize"
        >
      </label>
    </div>

    <div>
      <label for="bgColor">
        <span>Background Color:</span>
        <select id="bgColor" name="bgColor" type="text"
          [(ngModel)]="bgColor"
        >
          <option value="">Please select a color</option>
          <option value="red">Red</option>
          <option value="yellow">Yellow</option>
          <option value="cyan">Cyan</option>
          <option value="green">Green</option>
          <option value="magenta">Magenta</option>
        </select>
      </label>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  size = 12;
  bgColor = 'yellow';

  minSize= 12;
  maxSize= 64;
}
Enter fullscreen mode Exit fullscreen mode

I include the component and directives to the imports array, and specify the directive names (appFontSize, appBackgroundColor and appHoverBlock) and the directive inputs (size and bgColor) on <app-hello-username-without-api>.

One component does not seem a lot of work. However, copy-and-paste becomes tedious when many components use the same directives to modify styles.

<app-component-1 appFontSize appBackgroundColor appHoverBlock [size]="size" [bgColor]="bgColor"></app-component-1>
....
<app-component-n appFontSize appBackgroundColor appHoverBlock [size]="size" [bgColor]="bgColor"></app-component-n>
Enter fullscreen mode Exit fullscreen mode

Directive Composition API is designed to eliminate the tedious work and pass data to the directive inputs as if they are part of the host component. Let's see how Directive Composition API can power up host component.

Power up host component by directive composition API

// hello-username.component.ts

import { ChangeDetectionStrategy, Component, Input  } from '@angular/core';
import { FontSizeDirective } from '../directives/font-size.directive';
import { BackgroundColorDirective } from '../directives/background-color.directive';
import { HoverBlockDirective } from '../directives/hover-block.directive';

@Component({
  selector: 'app-hello-username',
  standalone: true,
  hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    },
    {
      directive: BackgroundColorDirective,
      inputs: ['bgColor'],
    },
    HoverBlockDirective,
  ],
  template: `
    <p>Hello {{username}}!!! Hover me to bold text</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HelloUsernameComponent {
  @Input({ required: true })
  username!: string;
}
Enter fullscreen mode Exit fullscreen mode

First, I use hostDirectives property to register the standalone directives. FontSizeDirective has size input; therefore, I must provide it in the inputs array. Similarly, BackgroundColorDirective has bgColor input and it is included in inputs array.

hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    },
    {
      directive: BackgroundColorDirective,
      inputs: ['bgColor'],
    },
    HoverBlockDirective,
],
Enter fullscreen mode Exit fullscreen mode

When I render HelloUsernameComponent in AppComponent, I can omit the directive names.

<p>Host component that host directives and inputs</p>
<app-hello-username [username]="'John Doe'" [bgColor]="bgColor" [size]="size">
</app-hello-username>

<p>Host component that uses host directives and default values</p>
<app-hello-username [username]="'John Doe'"></app-hello-username>
Enter fullscreen mode Exit fullscreen mode

The first instance of <app-hello-username> changes font size and background color when form values update. The second instance of <app-hello-username> uses the default font size and background color in the standalone directives respectively.

Power up host component by composite directive

We can also use the API to compose directive from standalone directives to combine their capabilities.

// background-block.directive.ts

import { Directive, Input } from '@angular/core';
import { BackgroundColorDirective } from './background-color.directive';
import { FontSizeDirective } from './font-size.directive';
import { HoverBlockDirective } from './hover-block.directive';

@Directive({
  selector: '[appBackgroundBlock]',
  standalone: true,
  hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    },
    {
      directive: BackgroundColorDirective,
      inputs: ['bgColor:backgroundColor'],
    },
    HoverBlockDirective,
  ]
})
export class BackgroundBlockDirective {
  @Input()
  size!: string;

  @Input()
  bgColor!: string;
}
Enter fullscreen mode Exit fullscreen mode

BackgroundColorDirective is consisted of FontSizeDirective, BackgroundColorDirective and HoverBlockDirective directives. Moreover, I rename bgColor to backgroundColor for demo purpose.

Now, I can register BackgroundBlockDirective in the hostDirectives array of other host component.

// hello-background-block.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { BackgroundBlockDirective } from '../directives/background-block.directive';

@Component({
  selector: 'app-hello-background-block',
  standalone: true,
  hostDirectives: [
    BackgroundBlockDirective
  ],
  template: `
    <p>I use BackgroundBlockDirective that is composed of 3 other directives!!!</p>
  `,
  styles: [`
    :host {
      display: block;
    }

    p {
      padding: 0.5rem;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HelloBackgroundBlockComponent {}
Enter fullscreen mode Exit fullscreen mode

In App component, I can render HelloBackgroundBlockComponent and it should exhibit the same behavior as HelloUserNameComponent

<p>Host component that uses composite host directive</p>
<app-hello-background-block [backgroundColor]="bgColor" [size]="size"></app-hello-background-block>

<p>Host component that uses composite host directive and default values</p>
<app-hello-background-block></app-hello-background-block>
Enter fullscreen mode Exit fullscreen mode

The first instance of <app-hello-background-block> changes font size and background color when form values update. The second instance of <app-hello-background-block> uses the default font size and background color in the standalone directives respectively.

The following Stackblitz repo shows the final results:

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .