Construindo componentes mais flexíveis com ngTemplateOutlet - Angular 2+

Reaproveitamento de código com certeza é uma das preocupações de todo desenvolvedor. Quando criamos um componente, a ideia de compartilhá-lo em outros projetos pode trazer economia de tempo e dinheiro.

Eventualmente temos problemas quando tentamos utilizar um componente que quase "encaixa" na nossa necessidade. Por exemplo, suponha que você tenha um componente que renderize um card conforme a imagem abaixo, porém sua necessidade é um pouco diferente:

Renderização do componente original vs desejado

Como este cenário é muito comum, podemos recorrer ao <ng-template> e <ng-container> para deixar nossos componentes mais customizáveis.

Componente Card

Vamos direto ao código para mostrar como o componente card foi estruturado:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CardComponent } from './card.component';

@NgModule({
    declarations: [
        CardComponent,
    ],
    imports: [
        CommonModule,
    ],
    exports: [
        CardComponent,
    ]
})
export class CardModule {

}
card.module.ts
import { Component, ContentChild, Input, TemplateRef } from '@angular/core';

@Component({
    selector: 'app-card',
    styleUrls: ['./card.component.css'],
    templateUrl: './card.component.html',
})
export class CardComponent {
    @Input() conteudo: string;
    @Input() data: Date;
    @Input() titulo: string;
}
card.component.ts
<div class="card">
    <div class="titulo">
        <div>{{titulo}}</div>
        <div>
            {{data | date}}
        </div>
    </div>
    <div class="conteudo">{{conteudo}}</div>
</div>
card.component.html
.card {
    background: #fff;
    border: 1px solid #ccc;
    border-radius: 8px;
    box-shadow: 0 1px 8px #ccc;
    margin: 16px auto;
}

.titulo {
    align-items: center;
    border-bottom: 1px solid #ddd;
    color: #444;
    display: flex;
    font-size: 1.2em;
    font-weight: bold;
    text-transform: uppercase;
}

.titulo div:nth-child(1) {
    flex-grow: 1;
}

.titulo div:nth-child(2) {
    color: #aaa;
    font-size: 0.6em;
}

.conteudo {
    color: #777;
    font-size: 1em;
    line-height: 26px;
}

.card, .titulo, .conteudo {
    padding: 8px;
}
card.component.css

Utilizando o componente:

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 1"
  conteudo="Comportamento padrão do card. É
            exibido um título, data e um texto">
</app-card>
app.component.html
Renderização do componente card - código acima

Customização com ngTemplateOutlet

Veja que o componente acima não permite que o desenvolvedor customize a parte onde a data é renderizada (canto superior direito). Vamos tomar como objetivo a alteração do componente de modo que seja possível customizar esta região.

<div class="card">
    <div class="titulo">
        <div>{{titulo}}</div>
        <div>
            <!-- ********************************** -->
            <!-- POSSIBILITAR UMA CUSTOMIZAÇÃO AQUI -->
            {{data | date}}
            <!-- ********************************** -->
        </div>
    </div>
    <div class="conteudo">{{conteudo}}</div>
</div>
card.component.html

Fazendo uma analogia, nosso componente é como se fosse a moldura de um quadro, você pode simplesmente troca a tela para obter um outro resultado mantendo os aspectos originais da moldura, ou seja, o "componente" moldura permite a customização do seu "template" (tela).


Como primeiro passo vamos isolar em um template (<ng-template>) a responsabilidade de renderizar uma data já formatada:

<ng-template #header let-data="dataFormatada">
    {{data}}
</ng-template>

Veja que este template é identificado através da marcação #header e recebe um parâmetro chamado dataFormatada. O valor do dataFormatada é atribuído à variável data.

No lugar do trecho {{data | date}} vamos adicionar um <ng-container>:

    <div class="card">
      <div class="titulo">
        <div>{{titulo}}</div>
        <div>
          <!--
              Como "jogar" o template aqui
              no container?
          -->
 ┌→       <ng-container>
 |        </ng-container>
 |      </div>
 |    </div>
 |    <div class="conteudo">{{conteudo}}</div>
 |  </div>
 |
 └→  <ng-template #header let-data="dataFormatada">
        {{data}}
    </ng-template>

Agora vamos utilizar algumas propriedades do <ng-container> para "dizer" ao <ng-container> que renderize o <ng-template #header>:

<div class="card">
  <div class="titulo">
    <div>{{titulo}}</div>
    <div>
      <ng-container
        [ngTemplateOutlet]="header"
        [ngTemplateOutletContext]="{ dataFormatada: (data | date) }">
      </ng-container>
    </div>
  </div>
  <div class="conteudo">{{conteudo}}</div>
</div>

<ng-template #header let-data="dataFormatada">
  {{data}}
</ng-template>
card.component.htmlc
  • [ngTemplateOutlet] recebe o template que será renderizado. No nosso caso será o template header
  • [ngTemplateOutletContext] envia um objeto (contexto) para o template. Neste caso vamos passar a data já formatada na variável dataFormatada. O template header já está preparado para receber e tratar esta variável.

Pronto! com esta pequena refatoração isolamos a área responsável por renderizar a data no canto superior direito. Porém, nada mudou no resultado final, ainda! veja na imagem abaixo o resultado até este momento:

Renderização do componente card

Importante: perceba que o <ng-container> pode receber qualquer template. Esta refatoração foi importante justamente por este motivo. Agora podemos passar um template que assumirá a responsabilidade de renderizar a data no canto superior direito.

Trazendo isto para o código, teríamos a seguinte situação:

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 4"
  conteudo="Card Teste">
    <!-- 
         Template para customizar a renderização
         do canto superior direito
    -->
    <ng-template let-data="dataFormatada">
      Meu template: {{data}}
    </ng-template>
</app-card>

Esboço de como passar um template diferente para a renderização da data

No código acima estamos passando um <ng-template> para dentro do app-card, porém o componente ainda não está preparado para receber um <ng-template> externo. Então para identificar a presença de um <ng-template>, podemos utilizar o @ContentChild e convencionar um identificador (como um id) padrão para este <ng-template> externo:

import {
    Component,
    ContentChild,
    Input,
    TemplateRef
} from '@angular/core';

@Component({
    selector: 'app-card',
    styleUrls: ['./card.component.css'],
    templateUrl: './card.component.html',
})
export class CardComponent {
    @Input() conteudo: string;
    @Input() data: Date;
    @Input() titulo: string;

    // Aqui verificamos se existe um template chamado tmpHeader
    // O Angular irá procurar por um <ng-template #tmpHeader>
    @ContentChild('tmpHeader') headerCustomizado: TemplateRef<any>;
}
card.component.ts

Agora nosso componente consegue identificar a presença de um <ng-template> identificado com o valor #tmpHeader. Então podemos passar um template customizado da seguinte forma:

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 4"
  conteudo="Card Teste">
    <!-- 
         Template para customizar a renderização
         do canto superior direito
    -->
    <ng-template #tmpHeader let-data="dataFormatada">
      Meu template: {{data}}
    </ng-template>
</app-card>

Para finalizar, precisamos "dizer" ao <ng-container> (dentro do template do componente) que se a variável headerCustomizado tiver um valor, ou seja, houver um <ng-template #tmpHeader>, ela (template externo) deverá ser utilizada para renderizar o canto superior direito do card. Caso contrário, o <ng-template #header> (dentro do template do próprio componente) deverá ser utilizado.

O código ficará da seguinte forma:

<div class="card">
  <div class="titulo">
    <div>{{titulo}}</div>
    <div>
     <!-- quando headerCustomizado é null,
          utilizamos o template padrão header -->
      <ng-container
        [ngTemplateOutlet]="(headerCustomizado || header)"
        [ngTemplateOutletContext]="{ dataFormatada: (data | date) }">
      </ng-container>
    </div>
  </div>
  <div class="conteudo">{{conteudo}}</div>
</div>

<ng-template #header let-data="dataFormatada">
  {{data}}
</ng-template>
card.component.html

Resultado:

Card com a renderização da data customizada

Veja que gora podemos passar qualquer template para o componente de modo a customizar o trecho onde a data é renderizada. Abaixo há alguns exemplos:

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 1"
  conteudo="Comportamento padrão do card. É
            exibido um título, data e um texto">
</app-card>

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 2"
  conteudo="Customizando parte do template
            através da injeção de um template
            customizado para incluir um botão
            ao lado da data">
  <ng-template #tmpHeader let-data="dataFormatada">
    <div style="display: flex; align-items: center">
      <div>{{data}}</div>
      <div>
        <input
          type="button"
          value="?"
          title="Ajuda">
      </div>
    </div>
  </ng-template>
</app-card>

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 3"
  conteudo="Customizando parte do template
            através da injeção de um template
            customizado para criar botão
            com o texto da data.">
  <ng-template #tmpHeader let-data="dataFormatada">
    <input type="button" [value]="data">
  </ng-template>
</app-card>

<app-card
  [data]="dataAtual"
  titulo="Card Exemplo 4"
  conteudo="Card Teste">
    <ng-template #tmpHeader let-data="dataFormatada">
      Meu template: {{data}}
    </ng-template>
</app-card>
app.component.html
Cards com/sem customização na renderização da data

Considerações

Com o entendimento do <ng-container> e ngTemplateOutlet podemos construir componentes mais flexíveis e aumentar o reaproveitamento de código. Inclusive, em um post um pouco mais antigo utilizamos este recurso para criar um componente no estilo tabela.

Código fonte:

Angular Ivy (forked) - StackBlitz
Exemplo de como utilizar o ngTemplateOutlet para deixar seus componentes mais customizáveis
Exemplo da implementação utilizada neste post

Links interessantes: