I was recently working on a button component as part of a Design System based on Angular Material. While working on this component, I faced some interesting challenges which I would like to share with you in this and following posts.
When you want to build your own Design System on top of Angular Material (or pretty much any other 3rd party component library), you have a couple of options:
- Build a custom Angular Material theme and use the components as they are described in the documentation.
- Build a custom Angular Material theme and build your own component library on top of Angular Material by wrapping their components in your own components.
There are pros and cons with both approaches. That is a topic for another post so I’m not going into the details here. Long story short, we went with option 2.
So my task was to wrap an Angular Material button in our own component and expose that for other developers to use in the rest of the app. Interesting, huh?
Let’s recreate that component in this series of posts.
Requirements
Well, the first step is to define some basic requirements:
- Call the component
my-button
. - Button’s text must go between the opening and closing tags of the component.
- The button must support three different styles depending on its usage - as a primary, secondary or a text button.
One component or three components?
By reading the second requirement, a valid question might arise. Should we create a single component and control its style with an Input property or three separate components - one for each style? I think having only one component is better because the consumers of the Design System will have only one way to display a button and adding support for more styles later on is easier.
So based on this, two more requirements can be specified:
- The component should have a
type
property that takes one of these values - “primary”, “secondary” or “text”. If not specified, the default value should be “text”. - Depending on the
type
, a different button style must be shown. This is illustrated in the image below.
How would you build it?
Implementation
There are a number of ways to approach the problem. In this, and following posts, I’m going to share my solution(s). If you have a different solution, questions, or tips that can help me improve my solution(s), please let me know. I’ll be happy to learn from you!
Solution 1
Let’s start with something simple to see if we can make it work.
// my-button.component.ts
import { Component, Input } from "@angular/core";
@Component({
selector: "my-button",
templateUrl: "./my-button.component.html",
})
export class MyButtonComponent {
@Input() type: "primary" | "secondary" | "text" = "text";
}
<!-- my-button.component.html -->
<ng-container *ngIf="type === 'primary'">
<button mat-flat-button color="primary">
<ng-content></ng-content>
</button>
</ng-container>
<ng-container *ngIf="type === 'secondary'">
<button mat-stroked-button color="primary">
<ng-content></ng-content>
</button>
</ng-container>
<ng-container *ngIf="type === 'text'">
<button mat-button color="primary">
<ng-content></ng-content>
</button>
</ng-container>
At first, the above solution might look kinda ok. Directives in Angular cannot be applied dynamically based on some logic so ngIf
is used to decide which button style to display depending on the value of type
.
Demo time 🎉 (StackBlitz)
Well, something is clearly not working.
The problem here lies in the way <ng-content>
works inside ngIf
. If we look at the "Conditional content projection" section in the Angular docs, we can read that “[...] when the consumer of a component supplies the content, that content is always initialized, even if the component does not define an <ng-content>
element or if that <ng-content>
element is inside of an ngIf
statement”.
What I get out of this is that we cannot use more than one <ng-content>
element in the same template when a condition is used to decide where to place the projected content. The recommendation for such scenarios is to use <ng-template>
.
So solution 1 cannot be used to implement the requirements. We need to find something else.
Solution 2
In this solution we’re going to take Angular’s recommendation and use <ng-template>
. Some minor adjustments in the HTML template are necessary. Component’s class can stay the same.
<!-- my-button.component.html -->
<ng-container *ngIf="type === 'primary'">
<button mat-flat-button color="primary">
<ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</button>
</ng-container>
<ng-container *ngIf="type === 'secondary'">
<button mat-stroked-button color="primary">
<ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</button>
</ng-container>
<ng-container *ngIf="type === 'text'">
<button mat-button color="primary">
<ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</button>
</ng-container>
<ng-template #buttonContent>
<ng-content></ng-content>
</ng-template>
There are a couple of new things here. First, the <ng-content>
inside each button is moved inside the ng-template
. Then a template variable #buttonContent
that holds a references to the ng-template
is defined. Lastly, ngTemplateOutlet
is used inside each button
to render the given <ng-template>
.
Demo time 🎉 (StackBlitz)
It works! The reason why it works is because Angular will not initialize the content of an <ng-template>
element until that element is explicitly rendered.
Is the task done? Should we open a pull request (PR) and call it a day?
Before we do that, let’s ask ourselves some questions:
- What are the downsides of this solution?
- Can the component easily be extended to support further requirements like more button styles, displaying of icon(s), disabled and/or loading state, links?
A downside is that the template will grow in size if more button styles have to be supported. The same will happen if the same component should also be used for links (yes, because a “button” and a “link” should be one component) Another downside is that the template contains repetitive logic that doesn’t belong there - deciding which button style to show using a bunch of ngIf
s. There are other Angular APIs that can help us achieve the same result by moving the logic in the component’s class instead thus keeping the template small and tidy.
As for adding more requirements, that’s a valid concern. New use cases come up all the time and some of them will probably need to be supported at some point. Is the component flexible enough to allow that?
Some might stop here and say that the solution is good enough. After all, it fulfills the current requirements. Others might be curious to see other solutions and see how we can extend the component to support more requirements. That is what we’re going to explore in part 2.
Thanks to Lars Gyrup Brink Nielsen for reviewing this post.
Photo by Chris Lawton on Unsplash
Top comments (6)
Hey Lars.
I made a similar button within a DS based on bootstrap, but I managed to only use classes, so no ngIf. I don't feel alright with this code repetition you present, but I must say that I rarely work with material except for cdk.
Cheers.
Alain
Hey Alain,
Thanks for your comment. If I might make one remark, the repetition is my "fault", not Lars' :)
I fully agree that the solution presented so far is not a good one. I wanted to start with something simple and gradually work my way out to a better solution. (In a sense, show how I would've solved the problem).
For me, the main difference between Material and Bootstrap is that Material is using Directives, which cannot be applied dynamically, whereas Bootstrap is using classes (like you mention as well). So in part 2, I'm going to try a different solution that removes the repetition.
Awesome, I knew it was yours actually, since I did see tat code on twitter, but I was confused :)
I see what you mean indeed, and I'm curious about the solution you come up with !
Great talk anyway, thank you for the explanations :)
How would we remove repetitive code? We see just one attribute changing. Any way to make it dynamic?
Hej John,
Unfortunately Angular doesn't support dynamically adding of Directives. That's why the repetition is necessary. As for whether it can be removed, yes. This will be the focus in part 2.
Hey John,
I've now published part 2 where remove the repetition from the template and use a more dynamic approach. Would love to hear what do you think about it :)