Search code examples
angularangular-directiveangular-components

Angular 9 - Rendering a limited number of component's children


I have a ButtonGroup component that will render a certain number of ButtonAction components.
I tried to assign a template property (TemplateRef) to each ButtonAction, so that I can loop on and pass them to a ng-template (via *ngTemplateOutlet).
I directly inject TemplateRef into the constructor of ButtonAction, but I get the error “No provider for TemplateRef”.
Since my aim is to render only a limited number of a component’s children, another solution I have found is to access the template via directive. But I don’t want to force our user to use the directive on each child.
So, how can I do?

@Component({
  selector: 'button-group',
  template: `
    <div>
       <ng-content *ngIf="canDisplayAllChildren; else limitedChildren"></ng-content>

       <ng-template #limitedChildren>
         <ng-container *ngFor="let button of buttons">
           <ng-template [ngTemplateOutlet]="button.template"></ng-template>
         </ng-container>
       </ng-template>

       <button-action (click)="toggle()" *ngIf="shouldLimitChildren">
         <icon [name]="'action-more-fill-vert'"></icon>
       </button-action>
    </div>
  `,
})
export class ButtonGroupComponent {
    @Input()
    public maxVisible: number;

    @ContentChildren(ButtonActionComponent) 
    public buttons: QueryList<ButtonActionComponent>;

    public isClosed: boolean = true;

    public toggle() {
        this.isClosed = !this.isClosed;
    }

    public get shouldLimitChildren() {
        return this.hasLimit && this.buttons.length > this.maxVisible;
    }

    public get canDisplayAllChildren() {
        return !this.shouldLimitChildren || this.isOpen;
    }   
}

Where ButtonActionComponent is:

@Component({
  selector: "button-action",
  template: `
    ...
  `
})
export class ButtonActionComponent {
    ...
  constructor(public element: ElementRef, public template: TemplateRef<any>) {}
}

Solution

  • It took me some time to come up with an ipothetical solution but I think I might have something useful that doesn't rely on explicit directive added to your component children.

    Being unable to use TemplateRef without structural directives involved, I thought about a mechanism which is similar to the React.cloneElement API.


    So, let's define a basic ButtonComponent that will be used as a children of the ButtonGroupComponent.

    // button.component.ts
    
    import { Component, Input } from "@angular/core";
    
    @Component({
      selector: "app-button",
      template: `
        <button>{{ text }}</button>
      `
    })
    export class ButtonComponent {
      @Input()
      public text: string;
    }
    
    

    The GroupComponent should do clone and append to its View only the number children specified via the maxVisible input property, which I also given a POSITIVE_INFINITY default value for cases when it isn't provided at all, allowing all children to be shown:

    // group.component.ts
    
    ...
    
    @Input()
    public maxVisible: number = Number.POSITIVE_INFINITY;
    
    ...
    

    Let's ask Angular to provide children given in our content (I would say that this is the best explanation of the difference: https://stackoverflow.com/a/34327754/3359473):

    // group.component.ts
    
    ...
    
    @ContentChildren(ButtonComponent)
    private children: QueryList<ButtonComponent>;
    
    ...
    

    We now need to let Angular inject a couple of things:

    1. our current container where to manually instantiate children to;
    2. a factory resolver that will help us creating components on the fly;
    // group.component.ts
    
    ...
    
    constructor(
      private container: ViewContainerRef,
      private factoryResolver: ComponentFactoryResolver
    ) {}
    
    private factory = this.factoryResolver.resolveComponentFactory(ButtonComponent);
    
    ...
    

    Now that we have been given anything we need from Angular, we can intercept the content initialization implementing the AfterContentInit interface and adding the ngAfterContentInit lifecycle.

    We need to cycle over our children, create new components on the fly and set all the public properties of the new components to the ones of the children given:

    // group.component.ts
    
    ...
    
    ngAfterContentInit() {
      Promise.resolve().then(this.initChildren);
    }
    
    private initChildren = () => {
      // here we are converting the QueryList to an array
      this.children.toArray()
    
        // here we are taking only the elements we need to show
        .slice(0, this.maxVisible)
    
        // and for each child
        .forEach(child => {
    
          // we create the new component in the container injected
          // in the constructor the using the factory we created from
          // the resolver, also given by Angular in our constructor
          const component = this.container.createComponent(this.factory);
    
          // we clone all the properties from the user-given child
          // to the brand new component instance
          this.clonePropertiesFrom(child, component.instance);
        });
    };
    
    // nothing too fancy here, just cycling all the properties from
    // one object and setting with the same values on another object
    private clonePropertiesFrom(from: ButtonComponent, to: ButtonComponent) {
      Object.getOwnPropertyNames(from).forEach(property => {
        to[property] = from[property];
      });
    }
    
    ...
    

    The complete GroupComponent should look like this:

    // group.component.ts
    
    import {
      Component,
      ContentChildren,
      QueryList,
      AfterContentInit,
      ViewContainerRef,
      ComponentFactoryResolver,
      Input
    } from "@angular/core";
    import { ButtonComponent } from "./button.component";
    
    @Component({
      selector: "app-group",
      template: ``
    })
    export class GroupComponent implements AfterContentInit {
      @Input()
      public maxVisible: number = Number.POSITIVE_INFINITY;
    
      @ContentChildren(ButtonComponent)
      public children: QueryList<ButtonComponent>;
    
      constructor(
        private container: ViewContainerRef,
        private factoryResolver: ComponentFactoryResolver
      ) {}
    
      private factory = this.factoryResolver.resolveComponentFactory(
        ButtonComponent
      );
    
      ngAfterContentInit() {
        Promise.resolve().then(this.initChildren);
      }
    
      private initChildren = () => {
        this.children
          .toArray()
          .slice(0, this.maxVisible)
          .forEach(child => {
            const component = this.container.createComponent(this.factory);
            this.clonePropertiesFrom(child, component.instance);
          });
      };
    
      private clonePropertiesFrom(from: ButtonComponent, to: ButtonComponent) {
        Object.getOwnPropertyNames(from).forEach(property => {
          to[property] = from[property];
        });
      }
    }
    

    Beware that we are creating the ButtonComponent at runtime, so we need to add it in the entryComponents array of the AppModule (here is the reference: https://angular.io/guide/entry-components).

    // app.module.ts
    
    import { BrowserModule } from "@angular/platform-browser";
    import { NgModule } from "@angular/core";
    
    import { AppComponent } from "./app.component";
    import { ButtonComponent } from "./button.component";
    import { GroupComponent } from "./group.component";
    
    @NgModule({
      declarations: [AppComponent, ButtonComponent, GroupComponent],
      imports: [BrowserModule],
      providers: [],
      bootstrap: [AppComponent],
      entryComponents: [ButtonComponent]
    })
    export class AppModule {}
    

    With these two simple components, you should be able to render only a subset of the given children maintaining a very clear usage:

    <!-- app.component.html -->
    
    <app-group [maxVisible]="3">
      <app-button [text]="'Button 1'"></app-button>
      <app-button [text]="'Button 2'"></app-button>
      <app-button [text]="'Button 3'"></app-button>
      <app-button [text]="'Button 4'"></app-button>
      <app-button [text]="'Button 5'"></app-button>
    </app-group>
    

    In this case, only the first, second and third children should be rendered.


    The codesandbox I tested everything is this one: https://codesandbox.io/s/nervous-darkness-6zorf?file=/src/app/app.component.html

    Hope this helps.