Superpowers with Directives and Dependency Injection: Part 1

Valentsea
Total
0
Shares

Original cover photo by Lucas Kapla on Unsplash.



Introduction

I have been saying this for a looong while: Directives are the most underutilized part of Angular.
They provide a powerful toolset for doing magic in templates, and yet in most projects, it is used in the most common, “attribute directives that do some limited business logic” style.

The next most underutilized thing is dependency injection. It is an awesome concept for building reusable stuff, yet 95% of DI usage in Angular is for services.

I have written a bunch of articles on both topics, and here is a list of them. I recommend you read those before diving into this one, although that is not a requirement:

In this series of articles (yes, there are going to be more than one!) we will dive deeper and explore how both of those concepts can be utilized (often together) to significantly simplify our templates. We will do so on use case examples, in a step-by-step format.

Note: I do not choose these examples specifically because they are very common or very useful; often, solutions in form of third-party libraries exist; these examples are just good from the learning perspective, as they allow to showcase a lot of concepts in a relatively small amount of code.

So, without further ado, let’s get started!



Building a password strength meter

A functionality that exists in lots of modern-day web apps is checking for a user’s password strength. Of course, solutions for this exist in the open. but let’s build something of our own, and in a way that it would be really customizable.

Let’s start with the simplest possible scenario: we add some class on the input element so it can be shown visually:

type PasswordStrength = 'weak' | 'medium' | 'strong';

@Directive({
  selector: '[appPasswordStrength]',
  standalone: true,
})
export class PasswordStrengthDirective {
  private readonly el: inject(ElementRef);

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    const input = event.target as HTMLInputElement;
    const value = input.value;
    const strength = this.evaluatePasswordStrength(value);
    this.el.nativeElement.classList.add(
      `password-strength-${strength}`
    );
  }

  evaluatePasswordStrength(password: string): PasswordStrength {
    if (password.length < 6) {
      return 'weak';
    } else if (password.length < 10) {
      return 'medium';
    }
    return 'strong';
  }
}
Enter fullscreen mode

Exit fullscreen mode

And then we can use it in the template like this:

 type="password" appPasswordStrength>
Enter fullscreen mode

Exit fullscreen mode

Fairly simple. (Ignore the simplicity of the logic behind evaluation; it is really irrelevant and we can put any logic there – our aim is to make this directive maximally customizable).

But now we have several issues:

  1. Why the selector? If we forget to out the [appPasswordStrength] attribute, the directive will not work. Can we make it work automatically on all password inputs?
  2. What if we need logic that is not just adding a class, but also adding some text to the DOM, for example? Can we make the directive just tell the template about the strength of the password and then let it handle in a custom way?
  3. What about customizing the evaluator function? Can we make it so that the user can provide their own function to evaluate the password strength?
  4. If the developer provides the logic for evaluation, can we make it possible to both provide the logic application-wide, from one place, and customize it on a per-input basis?

Let’s explore all of these issues and improve our directive. Let’s start with the first, simplest one:

@Directive({
  selector: 'input[type="password"]',
  standalone: true,
})
// directive implementation
Enter fullscreen mode

Exit fullscreen mode

Now we can just drop the attribute selector:

 type="password">
Enter fullscreen mode

Exit fullscreen mode

Now it will work automatically. But what if, in some cases, we want to ignore the checking? We can add an input for that:

@Directive({
  selector: 'input[type="password"]',
  standalone: true,
})
export class PasswordStrengthDirective {
  @Input() noStrengthCheck = false;
  private readonly el: inject(ElementRef);

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    if (this.noStrengthCheck) {
      return;
    }
    // logic goes here
  }

  // the other methods
}
Enter fullscreen mode

Exit fullscreen mode

And then we can use it like this:

 type="password" [noStrengthCheck]="true">
Enter fullscreen mode

Exit fullscreen mode

Cool, the first improvement is done. Let’s now make it so the component, rather than add a class, just informs the template about the strength of the password and lets it do the job itself. We could do that by adding an output, but that would mean more boilerplate for the developers in the template to capture the strength in a variable before using it. So instead we will use exportAs to work with the directive instance directly:

@Directive({
  selector: 'input[type="password"]',
  standalone: true,
  exportAs: 'passwordStrength',
})
export class PasswordStrengthDirective {
  @Input() noStrengthCheck = false;
  // property to capture in the template
  strength: PasswordStrength = 'weak'; 
  // no need for ElementRef anymore

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    if (this.noStrengthCheck) {
      return;
    }
    this.strength = this.evaluatePasswordStrength(value);
  }

  evaluatePasswordStrength(password: string): PasswordStrength {
    if (password.length < 6) {
      return 'weak';
    } else if (password.length < 10) {
      return 'medium';
    }
    return 'strong';
  }
}
Enter fullscreen mode

Exit fullscreen mode

Now we only write the strength itself to a property to let the developer capture it in the template. Here is how it is done:

 type="password" #evaluator="passwordStrength">
 *ngIf="evaluator.strength === 'weak'">Weak password
*ngIf="evaluator.strength === 'medium'">Medium password
*ngIf="evaluator.strength === 'strong'">Strong password
Enter fullscreen mode

Exit fullscreen mode

We use exportAs to capture the directive instance in a template variable, and then we can use it to access the strength property. You can read more about it in the official documentation.

Now, let’s make it so the developer can provide their own logic for evaluating the password strength. Again, we could do it using a standard Input property, but that would mean we would have to provide that function every time we have a password input, and that is cumbersome and error-prone – easy to forget. So instead we will use an InjectionToken together with a small helper function to provide the logic application-wide:

type PasswordEvaluatorFn = (password: string) => PasswordStrength;

export const evaluatorFnToken = new InjectionToken<
  PasswordEvaluatorFn
>(
  'PasswordEvaluatorFn',
);

export function providePasswordEvaluatorFn(
  evaluatorFn: PasswordEvaluatorFn,
) {
  return [{
    provide: evaluatorFnToken,
    useValue: evaluatorFn,
  }];
}

@Directive({
  // eslint-disable-next-line @angular-eslint/directive-selector
  selector: 'input[type="password"]',
  exportAs: 'passwordEvaluator',
  standalone: true,
})
export class PasswordEvaluatorDirective {
  strength: PasswordStrength = 'weak';
  @Input() evaluatorFn = inject(evaluatorFnToken);
  @Input() noStrengthCheck = false;

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    if (this.noStrengthCheck) {
      return;
    }
    const input = event.target as HTMLInputElement;
    const value = input.value;
    this.strength = this.evaluatorFn(value);
  }
}
Enter fullscreen mode

Exit fullscreen mode

Now we can just provide a custom evaluation function application-wide:

bootstrapApplication(AppComponent, {
  providers: [
    providePasswordEvaluatorFn((password: string) => {
      if (password.length < 6) {
        return 'weak';
      } else if (password.length < 10) {
        return 'medium';
      }
      return 'strong';
    }),
  ],
  // the rest of the application
});
Enter fullscreen mode

Exit fullscreen mode

And use it as we please.

But here comes a problem: what if the user does not provide a custom evaluator function? We can make it so the directive throws an error if it is not provided, but that might not be the best solution. So, let’s instead make the directive use a default evaluator function if the user has not provided one. But, right now, if there is no custom function provided, the dependency injection mechanism will throw a NullInjectorError error. Here, the optional flag comes to the rescue:

@Directive({
  //...
})
export class PasswordEvaluatorDirective {
  //...
  evaluatorFn = inject(evaluatorFnToken, { optional: true });
  //...
}
Enter fullscreen mode

Exit fullscreen mode

Now the inject function will return null instead of throwing an error if the token is not provided. We can use that to provide a default evaluator function:


export const defaultEvaluatorFn: PasswordEvaluatorFn = (
  password: string,
): PasswordStrength => {
    if (password.length < 6) {
        return 'weak';
    } else if (password.length < 10) {
        return 'medium';
    }
    return 'strong';
}

@Directive({
  //...
})
export class PasswordEvaluatorDirective {
  //...
  evaluatorFn = inject(
   evaluatorFnToken,
   { optional: true },
  ) ?? defaultEvaluatorFn;
  //...
}
Enter fullscreen mode

Exit fullscreen mode

So now, if the user is satisfied with the default evaluator function, they don’t have to provide anything. But if they want to provide their own, they can do that as well, both at the component level and application-wide.

So now, that last question remains: how to provide a custom evaluator on an input basis? Meaning, we can have several password inputs in the same component, but we want some of them to work differently. Because of how the inject function works, we can just decorate our evaluatorFn with @Input and it will work:

@Directive({
  //...
})
export class PasswordEvaluatorDirective {
  //...
  @Input() evaluatorFn = inject(
    evaluatorFnToken,
    { optional: true },
  ) ?? defaultEvaluatorFn;
  //...
}
Enter fullscreen mode

Exit fullscreen mode

And now we can use it like this:

 type="password" 
       #evaluator="passwordEvaluator"
       [evaluatorFn]="myEvaluatorFn"/>
Enter fullscreen mode

Exit fullscreen mode

Here is the final version of our component, with a live demo:



Conclusion

In this article, we have explored how to use InjectionToken to provide custom logic to a directive, how to use an exported instance of the directive, and use a custom selector for matching. In the next one, we will dive into using structural directives and performing advanced DOM manipulations.

Total
0
Shares
Valentsea

Streamlining Software Rollouts: Finding the Perfect Deployment Strategy for Your Team’s Goals and User’s Needs

What is a deployment strategy ? A deployment strategy is a set of methods and techniques used to…

You May Also Like