Gestión de ng-content vacío en Angular: 6 formas eficaces

Antes de dar ejemplos, veamos más detenidamente lo que implica la proyección de contenido.

La documentación oficial de Angular la describe así:

La proyección de contenido es un patrón en el que insertas o proyectas, el contenido que deseas utilizar dentro de otro componente.

En términos simples, la proyección de contenido es una técnica que nos permite colocar algún contenido en una ubicación dedicada dentro de la vista de un componente, reemplazando la etiqueta.

Este contenido se define dinámicamente en tiempo de ejecución y es desconocido en el momento de la creación del componente. Puede ser un simple texto, un trozo de HTML o incluso otro componente.

Un ejemplo básico es cuando insertamos contenido en una envoltura, como una tarjeta, una salida de mensaje, una tostada, un tooltip o un contenedor de lista.

En algunos casos, podemos querer modificar el nombre de clase de un elemento específico, añadir un mensaje, o incluso manipular elementos DOM añadiéndolos y eliminándolos.

Los ejemplos proporcionados en este artículo ilustran varios casos de uso. Depende de ti determinar cuál es el que mejor se adapta a tus necesidades.

A veces, basta con ocultar elementos mediante CSS, mientras que otras veces puede ser necesario un enfoque más complejo para obtener el resultado deseado.

Recuerda que si quieres aprender más acerca de Angular, te dejo el enlace al curso

Las acciones hablan más que las palabras, así que examinemos los ejemplos siguientes.

CSS puro – ocultar elementos vacíos

La primera opción para ocultar una envoltura vacía cuando el contenido está vacío es utilizar la pseudoclase CSS: empty

Supongamos que tenemos un componente definido de la siguiente manera:

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

@Component({
  selector: 'app-wrapper',
  standalone: true,
  styles: [`
    .content-wrapper:empty {
      display: none;
    }`
  ],
  template: `
    <div class="content-wrapper">
      <ng-content></ng-content>
    </div>`
})
export class WrapperComponent {}

Funciona en tres casos principales:

completamente vacío:

  • contiene sólo espacios en blanco como espacio, tabuladores, etc:
  • contiene espacios en blanco y un comentario HTML: ¡<! - texto del comentario ->
  • Sin embargo, no funcionará con elementos anidados vacíos como este: . En este caso se mostrará un elemento vacío en lugar de .

Ten cuidado. Un espacio que no rompa se trata como espacio en blanco, por lo que se mostrará el marcador de posición , ¡pero también se asignará espacio para el espacio que no rompa!

Puro css – simulando una sentencia if-else

Ahora, demos un paso más. A veces queremos mostrar un mensaje cuando no hay contenido. Este mensaje puede servir como marcador de posición para próximos datos asíncronos o simplemente para informar al usuario de la ausencia de contenido.

Una vez más, para una estructura básica, podemos utilizar CSS para lograr esto. Añadamos un párrafo a nuestro ejemplo y apliquémosle estilo.

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

@Component({
  selector: 'app-wrapper',
  standalone: true,
  template: `
    <div class="content-wrapper">
      <ng-content></ng-content>
    </div>
    <p>Nothing to display</p>`,
  styles: [
    `
    .content-wrapper:empty {
      display: none;
    }

    .content-wrapper:not(:empty) + p {
      display: none;
    }`,
  ],
})
export class WrapperComponent {}

Aquí hemos hecho lo contrario que en el ejemplo del primer punto. Si el contenido proporcionado no está vacío ocultamos una etiqueta , que representa un marcador de posición.

Funciona de la misma manera que el ejemplo anterior – teniendo en cuenta los espacios en blanco y los comentarios.

Hay un enfoque más que podemos tomar para lograr el mismo resultado. En lugar de comprobar si la envoltura está vacía, podemos comprobar si nuestro marcador de posición es el único hijo. Puede ser útil si tenemos elementos y en el mismo padre, como se muestra a continuación:

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

@Component({
  selector: 'app-wrapper',
  standalone: true,
  template: ` <div class="content-wrapper">
    <ng-content></ng-content>
    <p class="placeholder">Nothing to display</p>
  </div>`,
  styles: [
    `
      .content-wrapper:empty {
        display: none;
      }

      .placeholder:not(:only-child) {
        display: none;
      }
    `,
  ],
})
export class WrapperComponent {}

Template-driven approach

Ahora vamos a centrarnos en una solución más pragmática. En este punto, crearemos una variable de plantilla que contenga una referencia al elemento .content-wrapper <div> Con esta referencia, podemos determinar el número de nodos hijos dentro de la envoltura. Si no hay ningún nodo hijo, se puede mostrar el marcador de posición.

import { NgIf } from '@angular/common';
import { Component } from '@angular/core';

@Component({
  selector: 'app-wrapper',
  standalone: true,
  imports: [NgIf],
  template: ` <div class="content-wrapper" #contentWrapper>
      <ng-content></ng-content>
    </div>
    <p *ngIf="!contentWrapper.childNodes.length">Nothing to display</p>`,
})
export class WrapperComponent {}

Por supuesto no estamos limitados a la directiva *ngIf. También podemos hacer uso de Angular Control Flow de la siguiente manera:

<div class="content-wrapper" #contentWrapper>
  <ng-content></ng-content>
</div>

@if(!contentWrapper.childNodes.length) {
  <p>Nothing to display</p>
}

Es más, tener una referencia a un elemento DOM nos da acceso a una variedad de opciones para comprobar si el componente está vacío.

Por ejemplo, en lugar de 'childNodes', podemos utilizar 'children', 'textContent', 'innerHTML', etc.

He aquí algunos ejemplos de cómo implementar estas propiedades en nuestro código:

@if(!contentWrapper.children.length) {
  <p>Nothing to display</p>
} 

@if(!contentWrapper.textContent!.trim().length) {
  <p>Nothing to display</p>
} 

@if(!contentWrapper.innerHTML!.trim().length) {
  <p>Nothing to display</p>
}

Aunque todas ellas cumplen su función, hay una pequeña diferencia entre children y todas las demás. La propiedad child comprueba si existe al menos un nodo hijo. Esto significa que cuando proporcionamos texto sin formato dentro de, por ejemplo: algunos texto aleatorio , se mostrarán tanto el texto como el marcador de posición. Así que ten cuidado con esto también.

Gestionar el contenido desde la clase componente

Continuando, vamos a dar el siguiente paso y gestionar nuestro contenido desde la clase componente. Para ello, necesitamos obtener una referencia al elemento DOM del componente y seleccionar nuestro .content-wrapper. Después de obtenerlo podemos comprobar si el wrapper contiene childNodes, si no se mostrará el placeholder:


import {
  AfterViewInit,
  Component,
  ElementRef,
  inject,
} from '@angular/core';

@Component({
  selector: 'app-wrapper',
  standalone: true,
  styles: [
    `
      .placeholder {
        display: none;
      }
    `,
  ],
  template: `<div class="content-wrapper" #contentWrapper>
      <ng-content></ng-content>
    </div>
    <p class="placeholder">Nothing to show</p>`,
})
export class WrapperComponent implements AfterViewInit {
  private elementRef = inject(ElementRef);

  ngAfterViewInit(): void {
    const wrapperRef: HTMLDivElement =
      this.elementRef.nativeElement.querySelector('.content-wrapper');

    if (!wrapperRef.childNodes.length) {
      this.elementRef.nativeElement.querySelector(
        '.placeholder'
      ).style.display = 'block';
    }
  }
}

Ten en cuenta que no podemos acceder a los elementos durante el proceso de inicialización – dentro del hook ngOnInit. Es por eso que el hook ngAfterViewInit fue utilizado en este caso.

Más adelante, podemos mejorar el código anterior para hacerlo más acorde con las prácticas de Angular encapsulando la consulta dentro del decorador @ViewChild, como se muestra a continuación:

import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core';

@Component({
  selector: 'app-wrapper',
  standalone: true,
  template: `<div class="content-wrapper" #contentWrapper>
      <ng-content></ng-content>
    </div>

    @if (!hasContent) {
      <p>Nothing to show</p>
    }`,
})
export class WrapperComponent implements AfterViewInit {
  protected hasContent = false;

  @ViewChild('contentWrapper')
  contentWrapper!: ElementRef;

  ngAfterViewInit(): void {
    this.hasContent = !!this.contentWrapper.nativeElement.childNodes.length;
  }
}

Como puede ver, estamos utilizando las mismas propiedades que en el enfoque basado en plantillas, como childNodes, children, innerText, etc, y funcionan de forma similar. La única diferencia clave es que tenemos control sobre ellas dentro de la clase componente, lo que puede ser ventajoso para soluciones más complejas.

Gestión del contenido dinámico

Hasta ahora, hemos asumido que el contenido proporcionado puede estar vacío o no, y esta condición no cambia con el tiempo.

En esta sección, analizaremos cómo adaptar nuestra solución al contenido creado dinámicamente. Antes de seguir adelante, preparemos nuestro componente "padre". Supongamos que tenemos un formulario muy simple, con un campo de entrada. Lo que pongamos en este campo es lo que «enviamos» a <ng-content>.

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { WrapperComponent } from './wrapper/wrapper.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [WrapperComponent, FormsModule],
  template: `
    <input type="text" [(ngModel)]="name" />

    <app-wrapper>
      @if (name.length) {
      <ng-container
        ><p>{{ name }}</p></ng-container
      >
      }
    </app-wrapper>
  `,
})
export class AppComponent {
  name="";
}

Cuando lancemos ese código notaremos que inicialmente se muestra el marcador de posición. Desafortunadamente, incluso después de que introduzcamos algún texto dentro del <input> el marcador de posición permanece intacto. Para solucionar esto, tenemos que ajustar ligeramente la lógica.

En primer lugar, tenemos que establecer un punto en el que nuestra lógica se activa en respuesta a los cambios de vista. Para lograr esto, debemos eliminar el gancho ngAfterViewInit y reemplazarlo con ngAfterViewChecked.

El siguiente paso es encapsular la lógica dentro de una función setTimeout. Esto es necesario para prevenir el error común conocido como ExpressionChangedAfterItHasBeenCheckedError. Como resultado, nuestra base de código es como la siguiente:

import {
  AfterContentChecked,
  Component,
  ElementRef,
  ViewChild,
} from '@angular/core';

@Component({
  selector: 'app-wrapper',
  standalone: true,
  template: `<div class="content-wrapper" #contentWrapper>
      <ng-content></ng-content>
    </div>

    @if (!hasContent) {
    <p>Nothing to show</p>
    }`,
})
export class WrapperComponent implements AfterContentChecked{
  protected hasContent = false;

  @ViewChild('contentWrapper')
  contentWrapper!: ElementRef;

  ngAfterContentChecked(): void {
    setTimeout(() => {
      this.hasContent = !!this.contentWrapper.nativeElement.children.length;
    }, 0);
  }
}

Ahora, el marcador de posición sólo se muestra cuando el campo está vacío, y se actualiza en respuesta a los cambios en el valor de entrada.

Una directiva estructural personalizada

El último método que vamos a analizar implica el uso de una directiva estructural personalizada que muestra un marcador de posición cuando el contenido está vacío. Este enfoque permite reutilizar esta lógica en múltiples lugares.

En este caso, adoptamos un enfoque diferente para la condición que comprueba la existencia de contenido. Para asegurarnos de que el contenido es un nodo de texto o un nodo de elemento, comprobamos el tipo de cada nodo proporcionado por <ng-content>. Si al menos un nodo coincide con el tipo que buscamos, permitimos que se muestre todo el contenido. Si no, se muestra un marcador de posición.

 import {
  AfterContentChecked,
  Directive,
  ElementRef,
  Renderer2,
  TemplateRef,
  ViewContainerRef,
  inject,
} from '@angular/core';

@Directive({
  selector: '[hideIfEmpty]',
  standalone: true,
})
export class HideIfEmptyDirective implements AfterContentChecked {
  private elementRef: ElementRef = inject(ElementRef);
  private templateRef: TemplateRef = inject(TemplateRef);
  private viewContainer: ViewContainerRef = inject(ViewContainerRef);
  private renderer: Renderer2 = inject(Renderer2);

  private placeholderText = this.renderer.createText('No content');

  ngAfterContentChecked(): void {
    const viewRef = this.viewContainer.createEmbeddedView(this.templateRef);
    const viewRootNodes = viewRef.rootNodes as Node[];

    const hasChildElements = viewRootNodes.some((node) =>
      [1, 3].includes(node.nodeType)
    );

    if (!hasChildElements) {
      this.renderer.appendChild(
        this.elementRef.nativeElement.parentNode,
        this.placeholderText
      );
    } else {
      if (
        this.placeholderText.parentNode ===
        this.elementRef.nativeElement.parentNode
      ) {
        this.renderer.removeChild(
          this.elementRef.nativeElement.parentNode,
          this.placeholderText
        );
      }
    }
  }
}

El uso de esta directiva es el siguiente:

import { Component } from '@angular/core';
import { HideIfEmptyDirective } from '../hide-if-empty.directive';

@Component({
  selector: 'app-wrapper',
  standalone: true,
  imports: [HideIfEmptyDirective],
  template: `<div class="content-wrapper">
    <ng-content *hideIfEmpty></ng-content>
  </div;>

La directiva funciona bien a menos que cambiemos la estrategia de detección a 'onPush' para el componente en el que la estamos utilizando.

Con la estrategia 'onPush' en su lugar, es importante asegurarse de que activamos el ciclo de detección de cambios cada vez que el contenido de cambia.

Est

En Grupo MET podemos ayudarte a implementar esta y muchas mas herramienta para optimizar tu trabajo. ¡Contáctanos para saber más!

Contactanos