DEV Community

Cover image for Wrapping Angular Material button in custom Angular component (part 2)
Dzhavat Ushev for This is Angular

Posted on • Originally published at dzhavat.github.io

Wrapping Angular Material button in custom Angular component (part 2)

In the first part of this post we ended up with a simple but repetitive solution to wrapping Angular Material button in a custom component. In this post we’ll explore another way of solving the problem.

To remind you of what we ended up with in part 1, here’s the final solution again:

<!-- 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>
Enter fullscreen mode Exit fullscreen mode

The repetition here is quite obvious. Can we do something about it?

Solution 1

First, we can start by creating a component for each button type and move some of the code there. Imagine the following:

<!-- my-button.component.html -->
<primary-button *ngIf="type === 'primary'">
  <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</primary-button>

<secondary-button *ngIf="type === 'secondary'">
  <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</secondary-button>

<text-button *ngIf="type === 'text'">
  <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</text-button>

<ng-template #buttonContent>
  <ng-content></ng-content>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

<primary-button>* component might look like this:

import { Component } from "@angular/core";

@Component({
  selector: "primary-button",
  template: `<button mat-flat-button color="primary">
    <ng-content></ng-content>
  </button>`,
})
export class PrimaryButtonComponent {}
Enter fullscreen mode Exit fullscreen mode

* Note: <secondary-button> and <text-button> components are identical the only difference being that secondary button uses mat-stroked-button and text button uses mat-button directives.

StackBlitz demo.

The code was improved a tiny bit. Can we do better?

Solution 2

What if instead of using ngIfs to decide which button to show, we move that logic in the component’s class and use another Angular API to instantiate the relevant component dynamically?

Meet ngComponentOutlet. As written in the documentation, this directive provides a declarative approach for dynamic component creation. Declarative, meaning we tell Angular which component to instantiate and where to place it. The rest, like rendering it on the page, updating it on changes and destroying it, is handled by the framework.

Let’s see it in practice.

// my-button.component.ts
import { Component, Input } from "@angular/core";

import { PrimaryButtonComponent } from "../primary-button/primary-button.component";
import { SecondaryButtonComponent } from "../secondary-button/secondary-button.component";
import { TextButtonComponent } from "../text-button/text-button.component";

@Component({
  selector: "my-button",
  templateUrl: "./my-button.component.html",
})
export class MyButtonComponent {
  @Input() type: "primary" | "secondary" | "text" = "text";

  get buttonComponentType() {
    switch (this.type) {
      case "primary":
        return PrimaryButtonComponent;
      case "secondary":
        return SecondaryButtonComponent;
      case "text":
      default:
        return TextButtonComponent;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- my-button.component.html -->
<ng-container
  *ngComponentOutlet="buttonComponentType; content: [[buttonContent]]"
></ng-container>

<div #buttonContent>
  <ng-content></ng-content>
</div>
Enter fullscreen mode Exit fullscreen mode

The template is much cleaner now. There’s no repetitive logic. We send two properties to ngComponentOutlet - buttonComponentType, which holds the component that we want to instantiate, and content which takes a list of nodes to project into <ng-content> element(s) inside the component. buttonContent is a local variable that references the div element.

* Note: The type of content is any[][] hence the double square brackets around buttonContent. This also means that we can project more than one node elements into the component. The node can be any element that implements the Node interface like a text node, div, span, etc.

Demo time 🎉 (StackBlitz)

Demo to solution 2: All buttons are still displayed correctly

Well, visually the buttons still look the same but the code behind them is more organized. MyButton component is more flexible. We can easily add another button type by creating a new component and adding it to the switch statement.

In this post we looked at ngComponentOutlet and how it helps us dynamically create components to support a few basic requirements (outlined in part 1).

But things don’t have to stop here! MyButton component will rarely stay as it is right now. New use cases emerge all the time and some adjustments will be necessary. In part 3 we’ll introduce more requirements like icon support and disabled state.

Thanks to Lars Gyrup Brink Nielsen for reviewing this post.


Photo by Chris Lawton on Unsplash

Latest comments (20)

Collapse
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
Thanks a lot for this blog post and the idea behind it.
I think it's a good approach, but I wondere how you would manage some important properties for buttons, like disabled and type ('submit').
When we use ngComponentOutlet we can't use input. Maybe we would have to create other types of components, for submit it could be fine, but for disabled, which is supposed to be dynamic, it might be harder.

Collapse
 
dzhavat profile image
Dzhavat Ushev

Hi Benoît,
Thanks for you comment.

I wondere how you would manage some important properties for buttons, like disabled and type ('submit').

We're using this approach on a button component at work. We recently had a use case where we had to change the type of one button to submit and another one to button. What we did was we introduced a new property. We changed the type property to accept a type (i.e. submit or button, button being the default) and we added a variant which could be primary, secondary or text.

As for the disabled attribute, the ngComponentOutlet has an injector input which can be used to inject values. How we use it is to provide a custom token, which is also injected in the internal components as well. This way it's possible to pass dynamic values. If you're interested in knowing more, I can point you to some examples. Hope some of this helps you :)

Collapse
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
Thanks a lot for your reply. Yes I learnt than we can use an injector in the ngComponentOutlet params but I didn't succeed making it work yet. If you have an example, it would be helpful :-)

Thread Thread
 
dzhavat profile image
Dzhavat Ushev

Sure :)

In this code you can see how we create the injector which is later passed to ngComponentOutlet in the template. We provide disabledAttributeToken which is injected in all internal buttons to control the disabled state. The same way we inject the type attribute.

Thread Thread
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
Thanks a lot for this example ! It will help a lot, hopefully not only me :-)
Have a nice day !

Thread Thread
 
benlune profile image
Benoît Plâtre • Edited

Hi Dzhavat,
I could successfully implement my version on your button ;-)
I faced a problem regarding the disabled property. It works well on instanciated buttons, but, when listening to click event on the wrapper, you still get the event, even if the button is disabled.
I tried to preventDefault/stopPropagation the click event from the Host but it didn't work.
Finally I did this :

constructor(
    private injector: Injector,
    private el: ElementRef<HTMLElement>
  ) {}

  ngOnChanges(changes: SimpleChanges): void {
    const { nativeElement } = this.el;
    if (this.disabled) {
      nativeElement.style.pointerEvents = 'none';
    } else {
      nativeElement.style.pointerEvents = 'auto';
    }
  }
Enter fullscreen mode Exit fullscreen mode

I don't know if you faced that problem yet :-)

Thread Thread
 
dzhavat profile image
Dzhavat Ushev

Hey Benoît,
Thanks for you letting me know about this. I haven't faced this issue before/yet. Do you have an example with the problem? I quickly put together a StackBlitz with my interpretation of your description and I'm not seeing the problem.

If you see the button-overview-example.html file, on line 4 I have added the disabled property and a click listener. Right now the disabled property is true. If you click it, nothing is printed to the console. However, if you change it to false, clicking on the button will print a message to the console. Is my solution similar to yours? If not, what is different? :)

Thread Thread
 
benlune profile image
Benoît Plâtre • Edited

Hi Dzhavat,
Thank you for your reply and the StackBlitz example ;-)
When I click on your disabled button, I see the console.log() in the console. See my screenshot. That's strange if you don't have this behaviour.
Image upload doesn't seem to work...

Thread Thread
 
dzhavat profile image
Dzhavat Ushev

Hi Benoît,
You're right! There's something strange going on. I'm testing it in Firefox and it works fine. No console log on disabled button. In Edge and Chrome however it doesn't work and can see the console log when the button is disabled but not when it's enabled. Which is even more strange :D
Will have to dig deeper :)

Thread Thread
 
benlune profile image
Benoît Plâtre • Edited

Hi Dzhavat,
I should have tell you with which browser I tested ;-) But yes, that's very strange you have a different behaviour with Firefox on such a basic case...
As we listen to the click on host element, it seems logic to me that the event is still emitted even if a child button is disabled. The workaround I found, not ideal, is to use pointer-events none or auto depending on disabled value. I tried to work with HostListener to preventDefault on click, which seems more clean to me, but it didn't work...

Thread Thread
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
I think a better way should to emit a custom event from the Button Host when user click on a real button. We won't have a kind of fake click event but a buttonClick event, emitted from the button. Getting this buttonClick could also remove confusion with the real click event.
I will follow this idea this week and let you know.
I'm also thinking about the way I will use a link tag, which can have a taste of button thanks to Angular Material, but is a link.

Thread Thread
 
dzhavat profile image
Dzhavat Ushev

I tried to recreate an isolated StackBlitz with the issue. Yes, Firefox doesn't emit the click event, whereas Chrome/Edge do (haven't tested in other browsers). I think your suggestion makes sense. Even though I'd have preferred to only use the click listener. Let me know about your findings when you try it :)

Thread Thread
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
I tested on Safari and the click event is emitted too, like Chrome/Edge.
Let's keep in touch about that !

Thread Thread
 
dzhavat profile image
Dzhavat Ushev

I got some suggestion for how to fix this on Twitter. Looks like using pointer-events: none when the button is disabled fixes the issue. Created a StackBlitz :)

Thread Thread
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
Yes that's what I did but I think it's not ideal. I think it's more safe to listen to the real button target click event.
I tried another approach, creating the Component manually and listening directly to the button instance. Then emitting a btnClick event with the event coming from the real button. My button wrapper is pointer-events:none, always, and only the child button is pointer-event:auto.
Here is the template :

<ng-template #buttonContainer></ng-template>
<div #contentWrapper>
    <ng-content></ng-content>
</div>
Enter fullscreen mode Exit fullscreen mode

Here is the buildComponent function :

#buildComponent() {
    if (this.buttonContainer && this.#variant && !this.componentRef) {
      this.componentRef = this.buttonContainer.createComponent<ButtonTemplate>(
        this.buttonComponentVariant,
        {
          injector: this.buttonComponentInjector,
          projectableNodes: [[this.contentWrapper.nativeElement]],
        }
      );
      this.componentRef.changeDetectorRef.detectChanges();
      if (this.componentRef.instance.target) {
        fromEvent(this.componentRef.instance.target.nativeElement, 'click')
          .pipe(untilDestroyed(this))
          .subscribe((event: any) => {
            this.btnClick.emit(event as PointerEvent);
          });
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

My buttons implements the ButtonTemplate interface

export interface ButtonTemplate {
  target: ElementRef<HTMLButtonElement>;
}
Enter fullscreen mode Exit fullscreen mode

I'm going on on with it, I'll let you know if it's fine. I plan also to manage not only <button> but <a> too, which may have the look of buttons.

Thread Thread
 
benlune profile image
Benoît Plâtre

Hi Dzhavat,
The way the component was created, with static providers, made the properties on the wrapper impossible to be bound on the final button component. Once the disabled property was set, it couldn't be updated.
I found a way to be able to update value from Wrapper to button instance.
I created a private validateDisabled function which update the button instance isDisables @Input (which is no more injected).

#validateDisabled(firstChange: boolean) {
    if (this.componentRef?.instance) {
      this.componentRef.instance.isDisabled = this.disabled;
      const changes = {
        isDisabled: new SimpleChange('', this.disabled, firstChange),
      };
      this.componentRef.instance.ngOnChanges(changes);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Calling ngOnChange on the dynamically created component seems to be the only way to force the update of the component's properties.
The ButtonTemplate now has more properties

export interface ButtonTemplate {
  target: ElementRef<HTMLButtonElement>;
  isDisabled: boolean;
  ngOnChanges(changes: SimpleChanges): void;
}
Enter fullscreen mode Exit fullscreen mode

For example, here is the AccentButtonComponent

export class AccentButtonComponent implements ButtonTemplate, OnChanges {
  @Input()
  isDisabled = false;

  @ViewChild('target', { static: true, read: ElementRef })
  target!: ElementRef<HTMLButtonElement>;

  constructor(
    @Inject(typeAttributeToken) public type: BheButtonType,
    private cdr: ChangeDetectorRef
  ) {}

  ngOnChanges(changes: SimpleChanges): void {
    this.cdr.detectChanges();
  }
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
dzhavat profile image
Dzhavat Ushev

Hey Benoît,
Thanks for sharing your solution. Appreciate it!
I haven't looked at it in details yet but will do that in the coming days and share my thoughts :)

Collapse
 
beazer profile image
Dave

Learned some good stuff in these two posts - thanks!

Collapse
 
dzhavat profile image
Dzhavat Ushev

Thanks Dave. I'm glad to hear that 😊
Other parts will be coming as well :)

Collapse
 
beazer profile image
Dave

Cool, I look forward to it. ✌🏻