Skip to content

Instantly share code, notes, and snippets.

@JanMalch
Last active February 16, 2024 20:08
Show Gist options
  • Star 21 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save JanMalch/800cc0e1e448961fa5d93289e24e26fb to your computer and use it in GitHub Desktop.
Save JanMalch/800cc0e1e448961fa5d93289e24e26fb to your computer and use it in GitHub Desktop.
Writing your own structural directives with context variables

Writing your own structural directives with context variables

Complete code in math.directive.ts

After reading this you will be able to create a structural directive with inputs and context variables and use it like this:

<div *math="10; exponent: 3; let input; 
            let exponent = exponent; let r = root;
            let p = power; let ctrl = controller">
    input: {{ input }}, exponent = {{ exponent }}<br/>
    root = {{ input }}<sup>1/{{ exponent }}</sup> = {{ r }}<br/>
    power = {{ input }}<sup>{{ exponent }}</sup> = {{ p }}<br/><br/>
    <button (click)="ctrl.increment()">increment input</button>
</div>

The directive will take a number as input (here 10, this can also be connected to a variable in your component of course) and an exponent (here 3). It will give you the calculated power, root and your inputs. You will also be able to increment the input manually and have all values updated.

Written by JanMalch

Table of contents

Basics

First create a new directive with ng g d math. You change the selector to "math" like this:

@Directive({
  selector: '[math]' //tslint:disable-line:directive-selector
})

To get the HTML template you defined (the div container) and a view container to render this template in, we have to change the constructor to this:

constructor(private vcr: ViewContainerRef,
            private tmpl: TemplateRef<any>) { }

Inputs

To create the default input (10 in the example above), you add a @Input() and give it the same name as the directive selector:

@Input() math: number;

To add the exponent input you add another @Input(). The name has to start with the directive selector and then the actual variable name, but with the first letter capitalized.

@Input() mathExponent: number;
// or: @Input("mathExponent") exponent: number;

To set inputs in your HTML you use a :. The default input is first and doesn't need a label.

<div *math="10; exponent: 3">Test</div>

As @Input() values are available in ngOnInit we can use them from now on in our directive. With the HTML above we get the following result

ngOnInit() {
  console.log("base value =", this.math);       // base value = 10
  console.log("exponent =", this.mathExponent); // exponent = 3
}

Rendering

The div won't be rendered at this point. To do this we have to render the TemplateRef in the ViewContainerRef.

To do this we create a new private function.

private createView() {
  this.vcr.clear();
  this.vcr.createEmbeddedView(this.tmpl);
}

and call it in ngOnInit.

ngOnInit() {
  this.createView();
}

Using variables in the template

You cannot use the math or mathExponent variables in your HTML just yet. To do this you have to provide a context object. A context object can be any plain object literal.

First define an interface for our directive

export interface MathContext {
  $implicit: number;
  root: number;
  power: number;
  exponent: number;
}

These variables will be availabe in your directive / HTML. To get these values you have to use the let x = ... syntax. Where x can be any variable name you want. To connect x with the value of root you would write let x = root. Then you can use your x variable in the template like this:

<div *math="10; exponent: 3; let x = root;">
    root = {{ x }}
</div>

$implicit

The $implicit variable is sugared syntax as you can omit it when connecting to a variable. So let input = $implicit; is the same as let input. With this we can already get all our variables in the template:

<div *math="10; exponent: 3; let input; 
            let exponent = exponent; let r = root;
            let p = power">
    input: {{ input }}, exponent = {{ exponent }}<br/>
    root = {{ input }}<sup>1/{{ exponent }}</sup> = {{ r }}<br/>
    power = {{ input }}<sup>{{ exponent }}</sup> = {{ p }}
</div>

excursus: microsyntax and *ngFor

What we are writing here is called microsyntax. While it's advantageous for readability, you could also omit the :, ; or =.

Everyone has used *ngFor in their applications like this:

<div *ngFor="let val of values"></div>

You may have thought, well, that's just a JavaScript for-loop, but it's actually microsyntax with some sugar. You can reduce the sugar step by step:

<div *ngFor="let val of values"></div> <!-- normally -->
<div *ngFor="let val; of: values"></div> <!-- with ; and : -->
<div *ngFor="let val = $implicit; of values"></div> <!-- using $implicit -->

Implementing logic

You are almost done. The only thing missing is filling our context variables like power and root with data.

To pass in the context we simply add it as an argument in createEmbeddedView

this.vcr.createEmbeddedView(this.tmpl, {
    $implicit: this.math,                             // the value from our @Input()
    power: Math.pow(this.math, this.mathExponent),    // scary math
    root: Math.pow(this.math, 1 / this.mathExponent), // even scarier math
    exponent: this.mathExponent                       // the value from our @Input()
});

To ensure correct typing you set the context interface MathContext as the generic type of your TemplateRef.

constructor(private vcr: ViewContainerRef,
            private tmpl: TemplateRef<MathContext>) {
}

Also make sure you clean up after yourself in ngOnDestroy.

export class MathDirective implements OnInit, OnDestroy {
    // ...
    ngOnDestroy() {
        this.vcr.clear();
    }
}

You now have a fully functioning *math directive!

Advanced functionality

The last thing missing is the ability to increment the input value and update the output. First we create a private function called increment, which increases our math variable and renders the template again.

private increment() {
    this.math++;
    this.createView();
}

To use this method we add a controller to our context, which exposes a increment() function. This function simply calls our private increment() function.

this.vcr.createEmbeddedView(this.tmpl, {
    // ...
    controller: {
        increment: () => this.increment()
    }
});

Get the controller property, add a button and you are done:

<div *math="10; exponent: 3; let input; 
            let exponent = exponent; let r = root;
            let p = power; let ctrl = controller">
    <!-- ... -->
    <button (click)="ctrl.increment()">increment input</button>
</div>

You now have a fully functional and dynamic structural directive.

Practice time

As practice you can now implement an image carousel directive, that takes an array of objects and exposes a controller, that allows you to move to the next or previous image.

Here is some code that might get you on the right track:

app.component.ts

images = [
    {
        source: "https://angular.io/assets/images/logos/angular/logo-nav@2x.png"
        title: "Angular logo"
    },
    {
        source: "https://angular.io/generated/images/marketing/home/code-icon.svg"
        title: "Angular code icon"
    },
    {
        source: "https://angular.io/generated/images/marketing/home/angular-connect.png"
        title: "Angular Connect"
    }
];

app.component.html

<div *carousel="let source from images; let title = title; let ctrl = controller">
    <button (click)="ctrl.previous()">Previous</button>
    <img [src]="source" [title]="title">
    <button (click)="ctrl.next()">Next</button>
</div>

The biggest advantage is it's entirely up to the developer how he wants so style his carousel, but you provide him a nice and simple API with all the functionality he needs.

Credits & Learn more

This guide is heavily inspired by Alex Rickabaugh's talk Advanced Angular Concepts on YouTube and Google Presentations.

In the second part he shows how to implement the *carousel directive.

@renatoaraujoc
Copy link

This article made me explain in minutes what the official documentation couldn't explain in years.
Thank you!

@junaidrr
Copy link

Hi, really good article, thanks for sharing. Could you explain why do we have to clear on ngOnDestroy please?

@JanMalch
Copy link
Author

Unfortunately I can't remember why I put it there. I just checked the implementation of Angular's core directives like ngIf & ngFor, and they don't have any ngOnDestroy at all. So you probably don't need it here either. The directive and its ViewContainerRef should have the same lifecycle.

@junaidrr
Copy link

@JanMalch thanks for your reply!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment