Superpowers with Directives and Dependency Injection: Part 1
Armen Vardanyan
Posted on March 13, 2023
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';
}
}
And then we can use it in the template like this:
<input type="password" appPasswordStrength>
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:
- 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? - 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?
- What about customizing the evaluator function? Can we make it so that the user can provide their own function to evaluate the password strength?
- 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
Now we can just drop the attribute selector:
<input type="password">
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
}
And then we can use it like this:
<input type="password" [noStrengthCheck]="true">
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';
}
}
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:
<input type="password" #evaluator="passwordStrength">
<div *ngIf="evaluator.strength === 'weak'">Weak password</div>
<div *ngIf="evaluator.strength === 'medium'">Medium password</div>
<div *ngIf="evaluator.strength === 'strong'">Strong password</div>
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);
}
}
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
});
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 });
//...
}
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;
//...
}
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;
//...
}
And now we can use it like this:
<input type="password"
#evaluator="passwordEvaluator"
[evaluatorFn]="myEvaluatorFn"/>
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.
Posted on March 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 2, 2024
September 13, 2024