Practical Angular Directives
Use Cases

Staff Software Engineer @Ascenda

🎤 Angular Vietnam

📍 Ho Chi Minh City @ 27 Aug 2022

About me

Hi, My name's Trung 😊

  • Staff Software Engineer @Ascenda
  • Author of Angular Spotify, Jira Clone, Tetris
  • Founder @Angular Singapore
  • Community Lead @Angular Vietnam
  • Biggest Angular group in APAC
  • Advocate and grow the Angular developer community in Vietnam
  • 16k 25k members
  • Founded in 2017 by
  • 100 Days Of Angular series
  • Founded in 2021
  • Advocate and grow the Angular developer community in Singapore
  • Monthly meetup on the first Tuesday  
  • FREE one-on-one support 👉 BOOK NOW!

Agenda

  • What are Directives, again?

  • Directives on your project

  • What "else" you can do?

    1. The power of Angular selector
    2. Directives context & extend another component's functionality
  • Q&A
  • A fun QUIZ with rewards

What are Directives, again?

Directives are classes that add additional behavior to elements in your Angular applications.

Example

Looks familiar, isn't it?

<div [ngClass]="'first second'"></div>

<div [ngClass]="['first', 'second']"></div>

<div [ngClass]="{'first': true, 'second': true}"></div>

<div [ngClass]="stringExp|arrayExp|objExp"></div>

<div [ngClass]="{'class1 class2' : true}"></div>

Example

That's how it is implemented

@Directive({
  selector: '[ngClass]',
  standalone: true,
})
export class NgClass implements DoCheck {
  @Input('ngClass')
  set ngClass(
    value: 
      string | 
      string[] | 
      Set<string> | 
      { [klass: string]: any }
  ) {}
}

Directives in Angular

How many types of directives in Angular?

*correct answer will get a surprise 🎁

Directives in Angular

How many types of directives in Angular?

Directives in Angular

Attribute directives: Change the appearance or behavior of an element, component, or another directive.

Structural directives: Change the DOM layout by adding and removing DOM elements.

Components: Used with a template. This type of directive is the most common directive type.

Directives Interface

export interface Directive {
  selector?: string;
  inputs?: string[];
  outputs?: string[];
  providers?: Provider[];
  exportAs?: string;
  queries?: {[key: string]: any};
  host?: {[key: string]: string};
  jit?: true;
  standalone?: boolean;
}
// Usage
@Directive({
  // blah blah
})

Component Interface

export interface Component extends Directive {
  changeDetection?: ChangeDetectionStrategy;
  viewProviders?: Provider[];
  moduleId?: string;
  templateUrl?: string;
  template?: string;
  styleUrls?: string[];
  styles?: string[];
  animations?: any[];
  encapsulation?: ViewEncapsulation;
  interpolation?: [string, string];
  preserveWhitespaces?: boolean;
  standalone?: boolean;
  imports?: (Type<any>|any[])[];
  schemas?: SchemaMetadata[];
}

Which use cases you are applying directives to your project?

Which use cases you are applying directives to your project?

  • Custom component, that's obvious
  • Auto focus 
  • Check permission (structural directive)
  • Textarea autosize
  • Infinite scroll (fire event when reached the bottom)
  • Debounce

What "else" can you do?

1. Power of Angular selector

Think CSS Selector

Open link a new tab use case

Open link a new tab use case

<a href="https://trungk18.com/" 
  target="_blank"
  rel="noopener"
>
  Safe Link 
</a>

<a href="https://trungk18.com/" 
  target="_blank"
>
  Not Safe Link
</a>

* All major browsers should have fixed this issue by now

Naive approach

@Directive({
  selector: '[ngSafeLink]',
})
export class ManualSafeLinkDirective {
  @HostBinding('rel') rel = 'noopener';
}
<a
  href="https://trungk18.com/"
  target="_blank"
  ngSafeLink
>
  Safe Link With Manual Directive
</a>

Auto opt-in approach

@Directive({
  selector: 'a[target="_blank"]:not([rel="noopener"])',
//selector: 'a:not([routerLink]):not([rel="noopener"])',
})
export class SafeLinkDirective {
  @HostBinding('rel') rel = 'noopener';
}
<a
  href="https://trungk18.com/"
  target="_blank"
>
  Safe Link Auto Opt-in
</a>

Directive selector

  • element-name: Select by element name.
  • .class: Select by class name.
  • [attribute]: Select by attribute name.
  • [attribute=value]: Select by attribute name and value.
  • :not(sub_selector): Select only if the element does not match the sub_selector.
  • selector1, selector2: Select if either selector1 or selector2 matches.

NgForm

@Component({
  selector: 'simple-form',
  template: `
    <form #f="ngForm" (ngSubmit)="onSubmit(f)" novalidate>
      <input name="first" ngModel #first="ngModel" required>
      <input name="last" ngModel>
      <button>Submit</button>
    </form>

    <p>First name value: {{ first.value }}</p>
    <p>Form value: {{ f.value | json }}</p>
    <p>Form valid: {{ f.valid }}</p>
  `,
})
export class SimpleFormComponent {

NgForm

@Directive({
  selector: `
    form:not([ngNoForm]):not([formGroup]),
    ng-form,
    [ngForm]
  `,
  providers: [formDirectiveProvider],
  host: {'(submit)': 'onSubmit($event)', '(reset)': 'onReset()'},
  outputs: ['ngSubmit'],
  exportAs: 'ngForm'
})
export class NgForm extends ControlContainer {}

Takeaway #1️⃣

Attribute selector ([]) is not the only way that you can write your directive 💡

I hope it makes sense ^_^

TL;DR: Think CSS selectors

Auto-focus a form control with an Angular directive

@Directive({
  selector: 'form:not([ngNoFocus])',
})
export class FormFocusDirective implements AfterViewInit {
  focusables = ['input', 'select', 'textarea']

  constructor(private element: ElementRef) {}

  ngAfterViewInit() {
    const input = this.element.nativeElement.querySelector(
      this.focusables.join(','),
    )
    input?.focus()
  }

  @HostListener('submit')
  submit() {
    const input = this.element.nativeElement.querySelector(
      this.focusables.map((x) => `${x}.ng-invalid`).join(','),
    )
    input?.focus()
  }
}

Augmenting native elements

Augmenting native elements

Native HTML elements capture a number of standard interaction patterns that are important to accessibility. When authoring Angular components, you should re-use these native elements directly when possible, rather than re-implementing well-supported behaviors.

Creating a custom button

Creating a custom button

@Component({
  selector: 'shared-button',
  template: `
  <button
    class="button focus-link"
    [attr.aria-label]="ariaLabel"
    [attr.disabled]="isDisabled ? true : null"
    [attr.type]="buttonType"
  >
    <img *ngIf="iconPath" alt="" class="btn-icon" [src]="iconPath" />
    <span class="button-text">
      <ng-container *ngIf="!!buttonText"> {{ buttonText }} </ng-container>
      <ng-container *ngTemplateOutlet="buttonContent"> </ng-container>
    </span>
  </button>`
})
export class ButtonComponent implements OnInit {
  @HostBinding('class.is-disabled') get isButtonDisabled(): boolean {
    return this.isDisabled;
  }

  @Input() buttonClass: string;
  @Input() buttonContent: TemplateRef<any>;
  @Input() buttonText: string;
  @Input() buttonType: 'button' | 'link' = 'button';
  @Input() isDisabled: boolean;
  @Input() iconPath: string;
  @Input() ariaLabel: string;
}

Creating a custom button

<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonText]="'Login'"
>
</shared-button>

<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonContent]="loginButtonTmpl"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

Creating a custom button

<shared-button
  [buttonContent]="readmoreTmpl"
  [buttonTheme]="'primary'"
  [isTargetBlank]="true"
  redirectURL="https://trungk18.com/"
>
  <ng-template #readmoreTmpl> Readmore ↗ </ng-template>
</shared-button>

Creating a custom button

Problems 

⚠️ This approach won't scale very well 

Problems 

Problems 

Problems 

  @Input() ariaHidden: boolean;
  @Input() ariaPlaceholder: string;
  @Input() ariaPressed: string;
  @Input() ariaReadonly: string;
  @Input() ariaRequired: string;
  @Input() ariaSelected: string;
  @Input() ariaSort: string;
  @Input() ariaValueText: string;
  @Input() ariaControls: string;
  @Input() ariaDescribedBy: string;
  @Input() ariaDescription: string;
  @Input() ariaDetails: string;
  @Input() ariaErrorMessage: string;
  @Input() ariaFlowTo: string;
  @Input() ariaLabelledBy: string;
  // and another 20 more Inputs 😏

A better alternative

Create a component that uses an attribute selector that extends the native <button> element. 

A better alternative

A better much better alternative

@Component({
  selector: 'button[shared-button], a[shared-button]',
  template: ` <ng-content></ng-content> `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['./button-v2.component.scss'],
  encapsulation: ViewEncapsulation.None,
})
export class ButtonV2Component {
  @HostBinding('class') get rdButtonClass(): string {
    const classes = ['button', `btn-${this.buttonTheme}`];
    return classes.filter(Boolean).join(' ');
  }

  @Input() buttonTheme: ButtonTheme = 'secondary';
}

A better much better alternative

<button 
  shared-button 
  class="item-button-wrapper login-button-wrapper"
>
  Login
</button>
<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonText]="'Login'"
>
</shared-button>

🔁

A better much better alternative

<button 
  shared-button 
  class="item-button-wrapper login-button-wrapper"
>
  Login
  <shield-icon class="btn-icon"></shield-icon>
</button>
<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonContent]="loginButtonTmpl"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

🔁

A better much better alternative

<a
  shared-button
  [buttonTheme]="'primary'"
  target="_blank"
  href="https://trungk18.com/"
>
  Readmore ↗
</a>
<shared-button
  [buttonContent]="readmoreTmpl"
  [buttonTheme]="'primary'"
  [isTargetBlank]="true"
  redirectURL="https://trungk18.com/"
>
  <ng-template #readmoreTmpl> Readmore ↗ </ng-template>
</shared-button>

🔁

Takeaway #2️⃣

Utilize attribute selector for component to extend the native element 💡

TL;DR: Components doesn't have to be <some-component> lah 😆

Material Button

@Component({
  selector: `
    button[mat-button], 
    button[mat-raised-button], 
    button[mat-flat-button],
    button[mat-stroked-button]
  `,
  templateUrl: 'button.html',
  styleUrls: ['button.css', 'button-high-contrast.css'],
  inputs: MAT_BUTTON_INPUTS,
  host: MAT_BUTTON_HOST,
  exportAs: 'matButton',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatButton extends MatButtonBase {

NgZorro Button

@Component({
  selector: 'button[nz-button], a[nz-button]',
  exportAs: 'nzButton',
  preserveWhitespaces: false,
  encapsulation: ViewEncapsulation.None,
  template: `
    <span nz-icon nzType="loading" *ngIf="nzLoading"></span>
    <ng-content></ng-content>
  `,
  host: {
    class: 'ant-btn',
    '[class.ant-btn-primary]': `nzType === 'primary'`,
    '[class.ant-btn-dashed]': `nzType === 'dashed'`,
    '[class.ant-btn-link]': `nzType === 'link'`,
  }
})
export class NzButtonComponent implements OnDestroy, OnChanges, AfterViewInit, AfterContentInit, OnInit {

What else?

@Component({
  selector: 'mat-table, table[mat-table]',
  exportAs: 'matTable',
  template: CDK_TABLE_TEMPLATE,
  styleUrls: ['table.css'],
  host: {
    'class': 'mat-table',
    '[class.mat-table-fixed-layout]': 'fixedLayout',
  },

What else?

MatTabNav ↗️

@Component({
  selector: '[mat-tab-nav-bar]',
  exportAs: 'matTabNavBar, matTabNav',
  inputs: ['color'],
  templateUrl: 'tab-nav-bar.html',
  styleUrls: ['tab-nav-bar.css'],
  host: {
    'class': 'mat-tab-nav-bar mat-tab-header',
    '[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls',
    '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'",
    '[class.mat-primary]': 'color !== "warn" && color !== "accent"',
    '[class.mat-accent]': 'color === "accent"',
    '[class.mat-warn]': 'color === "warn"',
  },
  encapsulation: ViewEncapsulation.None,

Does this mean we should never replace native components with custom components?

No, Of course not.
 

The key takeaway from this is that, whenever you’re creating a new component, you should ask yourself:

can I augment an existing one instead?

Configure for specific use case

Configure for specific use case

<button>
  <span class="button-text">
    <ng-container *ngIf="!!buttonText">
      {{ buttonText }} 
    </ng-container>
    <ng-container *ngTemplateOutlet="buttonContent">
    </ng-container>
  </span>
</button>
<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonContent]="loginButtonTmpl"
  [buttonText]="'Hihi'"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

Configure for specific use case

Configure for specific use case

<shared-button
  [buttonText]="'Hihi'"
>
</shared-button>
<shared-button
  [buttonContent]="loginButtonTmpl"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

We only want either buttonText, or buttonContent. 
NOT both of them

How?

@Component({
  selector: `
    shared-button[buttonText]:not([buttonContent]),
    shared-button[buttonContent]:not([buttonText])`,
  templateUrl: './button.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent implements OnInit {
  @Input() buttonClass!: string;
  @Input() buttonContent!: TemplateRef<any>;

How?

How?

Real example

@Component({
  selector: `
    ngx-lil-gui:not([config]):not([object]),
    ngx-lil-gui[config]:not([object]),
    ngx-lil-gui[object]:not([config])
  `,
  template: ` <ng-content></ng-content> `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
})

Takeaway #3️⃣

Combine multiple selectors to have very flexible components 💡

2. Directive Context

In Angular, you can attach Attribute Directives on any elements on the DOM, and if the Directive’s selector matches, Angular will instantiate the Directive with the appropriate context.

Into the Directive lane

What is this “context” thing?

@Directive({
  selector: '[some]'
})
export class SomeDirective {
  // 👇 what is available here?
  constructor() { }
}

A context is an environment in that the Directive is created. In other words,

What is available to access via Angular’s Dependency Injection depends on where it is attached.

1. HTML Elements

<div some></div>
<button some></button>
@Directive({/*...*/ })
export class SomeDirective {
  constructor(elRef: ElementRef<HTMLButtonElement>) { }
}

ElementRef<TElement> where TElement is the actual type of the element

  • <button> is HTMLButtonElement
  • <div> is HTMLDivElement.

2. Components

<some-component some></some-component>
@Directive({/*...*/ })
export class SomeDirective {
  constructor(
    // 👇 the <some-component> element
    elRef: ElementRef<HTMLElement>,
    // 👇 the SomeComponent instance
    some: SomeComponent
  ) { }
}
  • ElementRef<HTMLElement>: same as above
  • TComponent: where TComponent is the type of the Component. The Directive has access to the instance of the Component that Angular creates.

3. ng-template

<ng-template some></ng-template>
@Directive({/*...*/ })
export class SomeDirective {
  constructor(
    // 👇 the <!-- container --> comment
    elRef: ElementRef<Comment>,
    // 👇 the TemplateRef instance
    // 👇.                   👇 you can use any specific generic here if you know what the Context is
    templateRef: TemplateRef<any>
  ) {
    console.log(elRef.nativeElement.textContent): // logs: 'container'
  }
}

4. Inheritance

4.1 Other directives

<input [ngModel]="name" some />
@Directive({/*...*/ })
export class SomeDirective {
  constructor(
    ngModel: NgModel,
  ) { }
}

SomeDirective has access to the NgModel instance and anything that NgModel might have inherited (the underlying NgControl), so you can have a Directive that might do some additional Form logic.

4.2 Component’s Providers

<some-component some></some-component>
@Component({
	/*...*/,
  // 👇 this will also be made available to SomeDirective
  providers: [SomeService]
})
export class SomeComponent { }

@Directive({/*...*/ })
export class SomeDirective {
  constructor(
    someService: SomeService
  ) { }
}

SomeDirective has access to anything that is provided in SomeComponent’s providers

4.2 Component’s Providers

<some-parent>
  <some-component some></some-component>
</some-parent>
@Directive({/*...*/ })
export class SomeDirective {
  constructor(
    someParent: someParentComponent,
    someComponent: someComponent,
  ) { }
}

SomeDirective also has access to SomeComponent’s ancestors’ Injector

5. Root/Platform Providers

@Directive({/*...*/ })
export class SomeDirective {
  constructor(
    cdr: ChangeDetectionRef,
    @Inject(DOCUMENT) document: Document,
  ) { }
}
  • ChangeDetectorRef
  • NgZone
  • ApplicationRef
  • @Inject(DOCUMENT)
  • etc.

So WHAT?

Everything listed here might not be exhaustive, as these are the things that I am aware of. 

Extend components that you don't own use case

Use case

<p-calendar
  [(ngModel)]="date"
  id="date"
  name="date"
  dateFormat="dd/mm/yy"
  [showIcon]="true"
  [showButtonBar]="true"
  [monthNavigator]="true"
  [yearNavigator]="true"
  [firstDayOfWeek]="1"
>
</p-calendar>

Problems

  • Pollute the template, looks complicated
  • Forget to add the required attributes needed
  • Nightmare when remove/ change /add a new attribute

Naive solution

@Component({
  selector: 'shared-calendar',
  template: `
  <p-calendar
    [formControl]="formControl"
    [name]="name"
    dateFormat="dd/mm/yy"
    [showIcon]="true"
    [showButtonBar]="true"
    [monthNavigator]="true"
    [yearNavigator]="true"
    [firstDayOfWeek]="1"
  >
  </p-calendar>`,
})
export class ShareCalendar {
  @Input() name: string;
  @Input() formControl: FormControl;
}

Naive solution

<shared-calendar [formControl]="control">
</share-calendar>

We got the same issues as what we did for the custom button

A better much better solution

import { Calendar } from 'primeng/calendar';

@Directive({
  selector: 'p-calendar',
})
export class CalenderDirective {
  constructor(private calendar: Calendar) {
    this.calendar.dateFormat = 'dd/mm/yy';
    this.calendar.showIcon = true;
    this.calendar.showButtonBar = true;
    this.calendar.monthNavigator = true;
    this.calendar.yearNavigator = true;
    this.calendar.firstDayOfWeek = 1;
  }
}

A better much better solution

<p-calendar 
  [(ngModel)]="date"
  id="date" 
  name="date">
</p-calendar>
  • The directive uses the p-calendar selector to be applied to all calendar elements.
  • The Calendar instance is injected into the directive and is configured to our needs.

Opt-in

import { Calendar } from 'primeng/calendar';

@Directive({
  selector: 'p-calendar[resetCalendar]',
})
export class CalenderDirective {
  constructor(private calendar: Calendar) {
    this.calendar.dateFormat = 'dd/mm/yy';
    this.calendar.showIcon = true;
    this.calendar.showButtonBar = true;
    this.calendar.monthNavigator = true;
    this.calendar.yearNavigator = true;
    this.calendar.firstDayOfWeek = 1;
  }
}

Opt-out

import { Calendar } from 'primeng/calendar';

@Directive({
  selector: 'p-calendar:not([noResetCalendar])',
})
export class CalenderDirective {
  constructor(private calendar: Calendar) {
    this.calendar.dateFormat = 'dd/mm/yy';
    this.calendar.showIcon = true;
    this.calendar.showButtonBar = true;
    this.calendar.monthNavigator = true;
    this.calendar.yearNavigator = true;
    this.calendar.firstDayOfWeek = 1;
  }
}

Read more

Summary

1️⃣ Attribute selector ([]) is not the only way that you can write your directive
 

2️⃣ Utilize attribute selector for component to extend the native element
 

3️⃣ Combine multiple selectors to have very flexible components

 

4️⃣ Directive context

Demo

Thank you!

Practical Angular Directives

By Trung Vo

Practical Angular Directives

Deck for Angular Vietnam 2nd in-person meetup. Ho Chi Minh City, Vietnam, 27 Aug 2022

  • 1,220