Search code examples
angularangular-materialangular-content-projection

Programatically adding tabs to tab group in Angular Material


I want to create a tab group that is defined in a child component, but where it's possible to create tabs (and their contents) from the parent component.

Content projection

Initially, I tried to use content projection.

Parent component

<child>
    <mat-tab tab label="Hello">World!</mat-tab>
</child>

Child component

<mat-tab-group>
    <mat-tab label="Some">Tab!</mat-tab>
    <ng-content selector="[tab]"></ng-content>
</mat-tab-group>

But the content wasn't being rendered in the child because since the tabs aren't in a tab group, they're not being initialized. And like said here, ng-content needs to know the contents of the projection at compile time, so it is suggested that we manually initialize the tabs, though with no useful explanation...

Manually adding the tabs to the tab group

This answer, though related to mat-table, leaves a good idea to try for my case.

Child component

@ViewChild(MatTable) tabGroup!: MatTabGroup;
@ContentChildren(MatTab) tabs!: QueryList<MatTab>;

ngAfterContentInit() {
    this.tabs.forEach(tab => this.tabGroup./*🤷‍♂️*/);
}

However, the tab group doesn't support any methods related to changing the Tab list.

Combining *ngFor with content projection

The only information every tab needs really is just the label and its content. We can avoid projecting the tab if we just project its inner contents. We just need the parent to send some meta-information down to the child.

child.component.ts

@Input() tabs!: {label: string, selector: string}[];

child.component.html

<mat-tab-group>
    <!-- some default tabs -->
    
    <mat-tab *ngFor="let tab of tabs" [label]="tab.label">
        <ng-content [select]="tab.selector"></ng-content>
    </mat-tab>
</mat-tab-group>

parent.component.ts

tabs = [{label: "Hello", selector: "hello"}];

parent.component.html

<child [tabs]="tabs">
    <p hello>World!</p>
</child>

To be clear, the tabs.selector property will be used by the parent component to mark the content to project, and by the child component's ng-content. Since it must be kebab-case, it has to be independent from label.

I thought this solution worked, but it turned out it just worked if there were only 2 tabs.

Any other suggestions?


Solution

  • Using ng-template and TemplateRef

    This solution, that actually works, is very similar to the last attempt I made.

    All information transfer (the label and the template) relies on a helper directive, which makes this code almost not rely on TS. This section of the Angular docs helped a lot.

    my-tab.directive.ts

    @Directive({ selector: '[my-tab]', inputs: ['label'], })
    export class MyTabDirective {
      label: string | undefined;
      constructor(/*DI*/ public templateRef: TemplateRef<any>) { }
    }
    

    child.component.html

    <mat-tab-group>
        <mat-tab *ngFor="let tab of tabs" [label]="tab.label">
            <ng-container [ngTemplateOutlet]="tab.templateRef"></ng-container>
        </mat-tab>
    </mat-tab-group>
    

    parent.component.html

    <child>
        <ng-template my-tab label="Tab 1">
            <h1>Hello,</h1>
            <p>Tab 1 content</p>
        </ng-template>
    
        <ng-template my-tab label="Tab 2">
            <h1>World!</h1>
            <p>Tab 2 content</p>
        </ng-template>
    </child>