Plantillas

<< Componentes Señales (Signals) >>

De forma similar al patrón Modelo-Vista-Controlador (MVC), se podría considerar la clase del componente como el controlador, y el HTML o plantilla asociada como la vista. La clase del componente se encarga de manipular e interactual con la plantilla, y una forma de hacerlo es mediante interpolación y vinculación de datos (data-binding).

Selector de componente

Vamos a editar la plantilla de AppComponent (app.component.html) para establecer el HTML principal de nuestra aplicación. Recordemos que la plantilla de AppComponent se carga directamente dentro del elemento body de index.html. En esta plantilla usaremos el selector del componente que acabamos de crear (products-page) para insertar su plantilla ahí.

Antes de incluir el selector del componente ProductsPageComponent, debemos importarlo mediante el array imports del componente AppComponent.

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, ProductsPageComponent],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'Angular Products';
}

Si ejecutamos la aplicación (ng serve) veremos este resultado en pantalla:

Interpolación

Como vimos en la sección de "Componentes", podemos mostrar las propiedades de nuestra clase de componente utilizando interpolación {{doble llave}} en la plantilla, y serán reemplazadas por el valor de esa propiedad.

Al realizar interpolación, podemos imprimir cualquier valor: concatenar valores, propiedades, llamar a métodos (de la clase del componente), cálculos matemáticos, etc.:

{{ title }}
{{ "Title: " + title }}
{{ "Title: " + getTitle() }}
{{ 4 * 54 + 6 / 2 }}

Control de flujo: @if

Se pueden añadir estructuras de control en la plantilla de un componente como condicionales o bucles. Desde la versión 17 de Angular se han introducido nuevas instrucciones que empiezan por el carácter '@', que sustituyen a las directivas que se venían usando (se pueden seguir utilizando).

La instrucción @if sustituye a la directiva *ngIf. Todo lo que pongamos entre las llaves se añadirá al DOM en el momento en el cual la condición sea verdadera. Si la condición fuera falsa, se eliminan del DOM, es decir, no es que estén ocultos.

También se puede crear un bloque @else asociado, e incluso @if else.

@if (user.isHuman) {
  <human-profile [data]="user" />
} @else if (user.isRobot) {
  <robot-profile [data]="user" />
} @else {
  <p>Perfil desconocido!</p>
}

Antes de utilizar la nueva sintaxis en nuestro ejemplo de productos, vamos a crear una interfaz que represente a un producto en nuestra aplicación:

ng g interface interfaces/product

export interface Product {
  id?: number;
  description: string;
  price: number;
  available: string;
  imageUrl: string;
  rating: number;
}

A continuación crearemos un array vacío de productos en el componente products-page e indicaremos en la plantilla que la tabla solo se muestre cuando haya productos. En caso de no haber productos, se mostrará un mensaje en su lugar.

@Component({
  selector: 'products-page',
  standalone: true,
  imports: [],
  templateUrl: './products-page.component.html',
  styleUrl: './products-page.component.css'
})
export class ProductsPageComponent {
  title = "Mi lista de productos";
  products: Product[] = [];
}

Control de flujo: @for

La instrucción @for sustituye a la directiva *ngFor. Con esta estructura recorremos los elementos de una colección, añadiendo al DOM los elementos HTML tantas veces como elementos por los cuales se itera.

En la nueva sintaxis, se puede añadir la instrucción @empty que es como @else y se muestra cuando la lista está vacía. Además, la opción track es obligatoria (antes era opcional y se llamaba trackBy). Con esta opción definimos qué propiedad define de forma única a cada elemento de la lista. Esto se hace para que Angular detecte mejor qué elementos han cambiado y solo modifique las partes del DOM precisas en cada cambio en lugar de redibujar todos los elementos (por defecto).

@for (item of items; track item.id) {
  <p>Item {{i}}: {{ item.name }}</p>
} @empty {
  <p>No hay elementos para mostrar</p>
}

Es muy aconsejable utilizar una propiedad única (como la id) cuando iteramos en una colección de objetos. Esto ayuda a Angular a reconocer si un objeto sigue existiendo en la lista aunque cambiemos la referencia a un nuevo array, y no reconstruye el DOM para ese objeto ganando rendimiento. En caso de colección de elementos simples como strings o números que no se repiten, se puede utilizar track by item, siendo item el elemento de la lista actual. Solo cuando no haya más remedio (elementos básicos repetidos por ejemplo) se debe usar el índice: track by $index

El bloque for también permite declarar variables locales que hagan referencia a valores implícitos a los que el bloque for permite acceder, aunque esto no es necesario realmente, ya que se puede acceder a estos valores precedidos por '$' directamente. Estos valores son:

  • $count → Número de elementos de la colección Number of items in a collection iterated over
  • $index → Posición del elemento actual
  • $first → Indica si el elemento actual es el primero (booleano)
  • $last → Indica si el elemento actual es el último (booleano)
  • $even → Indica si el elemento actual tiene un índice par (booleano)
  • $odd → Indica si el elemento actual tiene un índice impar (booleano)
@for (item of items; track item.id; let i = $index, first = $first) {
  <p>Item {{ i }}: {{ item.name }} ({{ first ? "¡Primero!" : "" }})</p>
}

Para el ejemplo de productos, vamos a introducir datos en el array de productos del componente. Después los recorreremos generando una fila de tabla por cada producto:

export class ProductsPageComponent {
  title = "Mi lista de productos";

  products: Product[] = [{
    id: 1,
    description: 'SSD hard drive',
    available: '2016-10-03',
    price: 75,
    imageUrl: '/ssd.jpg',
    rating: 5
  },{
    id: 2,
    description: 'LGA1151 Motherboard',
    available: '2016-09-15',
    price: 96.95,
    imageUrl: '/motherboard.jpg',
    rating: 4
  }];
}

Aquí podemos ver el resultado y como se imprimen los productos:

Control de flujo: @switch

La sintaxis de la estructura @switch es muy parecida a @if. La diferencia con la estructura switch de JavaScript y otros lenguajes es que no se necesita la instrucción break, ya que solo puede entrar en uno de los bloques. Si el valor no se encuentra en ningún bloque @case, se mostrará lo que hay en el bloque @default (opcional). Esta nueva estructura viene a sustituir a ngSwitch.

@switch (user) {
  @case ('admin') {
    <p>Administrador</p>
  } @case ('user') {
    <p>Usuario normal</p>
  } @default {
    <p>Usuario desconocido</p>
  }
}

Vincular atributos (Data-binding)

La vinculación de atributos es un concepto similar al de la interpolación, pero aplicado a atributos de los elementos HTML. Se vincula el valor del atributo al de una propiedad de la clase del componente. Para esto, debemos envolver el nombre del atributo entre corchetes (ejemplo: <img [src]="product.imageUrl">).

Vamos a añadir la imagen del producto en la tabla vinculando el atributo src. Como la imagen puede ser un poco grande, vamos a controlar su tamaño con un poco de CSS para que se vea mejor la tabla.

<table class="table table-striped m-0">
    <thead>
    <tr>
        <th>Imagen</th>
        <th>Producto</th>
        <th>Precio</th>
        <th>Disponible</th>
    </tr>
    </thead>
    <tbody>
    @for (product of products; track product.id) {
        <tr>
        <td><img [src]="product.imageUrl" alt=""></td>
        <td>{{product.description}}</td>
        <td>{{product.price}}</td>
        <td>{{product.available}}</td>
        </tr>
    }
    </tbody>
</table>

Para este ejemplo, las imágenes se encuentran en el directorio public/, ya que Angular desde la versión 17 usa Vite como servidor de desarrollo. Antiguamente las hubiéramos situado en el directorio src/assets.

[ngClass]

La directiva [ngClass] sirve para asignar clases CSS a un elemento de forma dinámica. Como valor se le asigna un objecto cuyos atributos son los nombres de las clases CSS que se le pueden asignar o quitar al elemento y el valor de cada propiedad es un booleano que indica si se le asigna dicha clase o no(puede ser una expresión o llamada a un método siempre que devuelva un booleano).

La sintaxis también permite asignar o quitar más de una clase basada en la misma condición. Para ello, agrupa los nombres de las clases separadas por espacio.

<div [ngClass]="{'active': isActive, 'selected red-border': isSelected}">

Para utilizar la directiva ngClass en la plantilla se debe importar la clase NgClass en el array imports del componente asociado.

El objeto que define las clases también puede estar definido en el componente de la clase. En ese caso, el valor de [ngClass] debe apuntar a dicha propiedad, o a una función que devuelva el objeto.

<div [ngClass]="divClasses">

Otra posibilidad de ngClass es asignarle directamente un array con las clases como elementos (strings) del mismo. Para cambiar las clases del elemento, simplemente habría que modificar el array, por lo que es recomendable que esté referenciado en una propiedad del componente.

<div [ngClass]="divClasses">

[class.clase]

Otra forma de vincular una o varias clases a un elemento es utilizar el atributo [class]. Este funciona de forma similar a [ngClass] aunque es menos flexible (por ejemplo, no permite asignar multiples clases bajo la misma condición. Sin embargo, este atributo se suele utilizar con la sintaxis [class.clase] siendo 'clase' la clase a añadir/quitar y el valor sería un booleano indicando cuando la clase está presente o no.

Cuando queremos modificar una, o pocas clases, esta sintaxis puede resultarnos más sencilla, sin sobrecargar el elemento de atributos.

<div [class.active]="isActive">

[ngStyle]

Similar a la directiva ngClass, la directiva ngStyle vincula directamente varios estilos CSS al elemento basándose en un objeto cuyas claves son las propiedades CSS (en formato kebab-case), y los valores del objeto se corresponden al de la propiedad CSS. Estos valores (y el objeto) pueden cambiar dinámicamente, ser calculados, etc.

Además, los nombres de las propiedades CSS que indican medidas pueden llevar el sufijo .px, .mm. em, etc. para indicar la unidad de medida. En este caso el valor será de tipo number directamente.

<div [ngStyle]="{'font-size.px': currentSize, 'color': product.selected ? 'green' : 'black'}">...</div>

Para utilizar la directiva ngStyle en la plantilla se debe importar la clase NgStyle en el array imports del componente asociado.

Igual que con ngClass, el objeto puede estar en una propiedad del componente, o que lo devuelva un método al que llamemos.

<div [ngStyle]="divStyle">...</div>

[style.propiedad]

De la misma forma, podríamos utilizar directamente [style]. O cuando simplemente queremos vincular el valor de una propiedad CSS, utilizamos [style.propiedad]. El formato de los nombres de las propiedades puede ser kebab-case o camelCase.

<div [style.font-size.px]="textSize">...</div>

Gestión de eventos (Event-binding)

Al diferencia de vincular atributos (data-binding), la vinculación de eventos va en dirección contraria. Es decir, son acciones sobre la plantilla (eventos) que afectan al componente, o más bien que ejecutan un método del componente. Para vincular un evento, añadiremos un atributo con el nombre del evento entre paréntesis → (click), (mouseenter), (keypress), etc. Como valor ponemos la llamada al metódo del componente que queramos ejecutar.

Los paréntesis representan que la dirección del vínculo va desde la plantilla al componente. Lo opuesto que la sintaxis con corchetes.

<button (click)="unMetodo()">

En el ejemplo de productos que estamos desarrollando. Vamos a poner un botón que permita ocultar o mostrar las imágenes de los productos cada vez que lo presionemos. Además, vincularemos clases CSS del botón (y el contenido) a la propiedad (boolean) que indica si las imágenes se muestran o no.

<thead>
    <tr>
        <th>
            <button class="btn btn-sm" [ngClass]="{
                'btn-danger': this.showImage,
                'btn-primary': !this.showImage,
            }" (click)="toggleImage()">
            {{showImage?'Hide':'Show'}} images
            </button>
        </th>
        <th>Producto</th>
        <th>Precio</th>
        <th>Disponible</th>
    </tr>
</thead>
<tbody>
    @for (product of products; track product.id) {
        <tr>
        <td>@if (showImage) { <img [src]="product.imageUrl" alt=""> }</td>
        <td>{{product.description}}</td>
        <td>{{product.price}}</td>
        <td>{{product.available}}</td>
        </tr>
    }
</tbody>

En lugar de usar la instrucción @if, se podría haber conseguido lo mismo con [ngClass] o el atributo [class] usando la clase d-none de Bootstrap, que básicamente añade al elemento la propiedad CSS display: none. La diferencia es que la instrucción @if elimina al elemento del DOM, mientras que con CSS lo ocultamos.

<td><img [src]="product.imageUrl" [class.d-none]="!showImage" alt=""></td>

Vinculación en 2 direcciones [(ngModel)]

La directiva [(ngModel)] se usa para vincular el valor de un campo de formulario a una propiedad del componente. Los corchetes indican que el valor del campo (value) se obtiene del valor de la propiedad de la clase, mientras que los paréntesis indican que si el usuario cambia el valor del input, este cambio se aplica también a la propiedad del componente. Por eso se dice que está vinculado en ambas direcciones.

No siempre que utilicemos formularios en Angular usaremos la directiva ngModel. Solamente cuando se trate de formularios de plantilla. Hay otra forma de gestionar formulario en Angular (Formularios Reactivos) que veremos más adelante y utilizan otra estrategia diferente.

No hace falta que los elementos tipo input, textarea, o select, estén dentro de un formulario para poder usar esta directiva. Lo único que hay que tener en cuenta, es que los elementos del formulario que usen ngModel, deben tener el atributo name. Más adelante estudiaremos la gestión de formularios con Angular, incluyendo temas como la validación de campos.

Como ejemplo, vamos a crear un formulario en la página de productos para añadir un nuevo producto. Como se puede observar, el evento de envío del formulario es ahora (ngSubmit). Entre otras cosas, utilizar este evento en lugar de submit, nos permite evitar llamar a preventDefault() para que no se recargue la página. Los campos del formulario van asociados a las propiedades de un objeto de tipo Product.

Importante: Para poder usar ngModel y ngSubmit, debemos importar en el componente el módulo FormsModule (@angular/forms).

Una vez se envía el formulario, las propiedades del objeto ya tendrán valor, por lo que simplemente añadimos el producto al array del componente. La id del producto por ahora nos la inventaremos hasta que tengamos comunicación con el servidor e inserte el producto en una base de datos.

<div class="card mb-4">
  <div class="card-header bg-success text-white">Add product</div>
  <div class="card-body">
    <form (ngSubmit)="addProduct()">
      <label class="mb-3 row">
        <span class="col-sm-2 col-form-label text-sm-end">Description</span>
        <div class="col-sm-10">
          <input type="text" class="form-control" name="description" [(ngModel)]="newProduct.description"/>
        </div>
      </label>
      <label class="mb-3 row">
        <span class="col-sm-2 col-form-label text-sm-end">Price</span>
        <div class="col-sm-10">
          <input type="number" class="form-control" name="price" [(ngModel)]="newProduct.price" />
        </div>
      </label>
      <label class="mb-3 row">
        <span class="col-sm-2 col-form-label text-sm-end">Available</span>
        <div class="col-sm-10">
          <input  type="date"  class="form-control"  name="available" [(ngModel)]="newProduct.available"  />
        </div>
      </label>
      <label class="mb-3 row">
        <span class="col-sm-2 col-form-label text-sm-end"> Image</span>
        <div class="col-sm-10">
          <input type="file" class="form-control" accept="image/*" (change)="changeImage($event)" name="fileName" [(ngModel)]="fileName" />
        </div>
      </label>
      @if (newProduct.imageUrl) {
      <div class="row mb-3">
        <div class="col-sm-10 offset-sm-2">
          <img class="product-img" [src]="newProduct.imageUrl" alt="" />
        </div>
      </div>
      }

      <div class="mb-3 row">
        <div class="offset-sm-2 col-sm-10">
          <button type="submit" class="btn btn-primary">Submit</button>
        </div>
      </div>
    </form>
  </div>
</div>

<!-- Card con la lista de productos aquí -->

Como podemos ver, es importante reasignar el producto asociado al formulario a un nuevo objeto en lugar de simplemente vaciar los campos. Si no lo hacemos así, se borrarían también los campos del objeto añadido al array, ya que en ambos casos (propiedad newProduct y última posición del array) se estaría apuntando al mismo objeto en memoria (referencia). Esto implicaría que si borramos el valor de los campos de newProduct, también vaciaríamos el último producto del array le aparecerían en blanco al usuario.

Otras cosas que podemos observar son la propiedad fileName, creada simplemente para que contenga el nombre del archivo y poder vacíar el input del archivo una vez añadido el producto. También el evento change asociado al campo del archivo, útil para poder convertir la imagen a base64 y previsualizarla. En angular, la variable $event en la plantilla representa al objeto emitido por el evento.

Variables en la plantilla

Variable local: @let

Desde la versión 18 de Angular se pueden definir nuevas variables en la plantilla utilizando la instrucción @let. La sintaxis es similar a la instrucción let o const de JavaScript para crear variables y asignarles un valor. Este tipo de variables se puede utilizar para crear nombres más cortos, o valores calculados directamente en la plantilla. La instrucción debe acabar obligatoriamente con punto y coma ';'.

<table class="table table-striped m-0">
    <thead>
    <tr>
        <th>
            @let buttonText = (showImage?'Hide':'Show') + ' images';
            <button class="btn btn-sm" [ngClass]="{
                'btn-danger': this.showImage,
                'btn-primary': !this.showImage,
            }" (click)="toggleImage()">
                {{ buttonText }}
            </button>
        </th>
        <!-- Resto de campos -->
    </tr>
    </thead>
    <tbody>
    @for (product of products; track product.id) {
        @let img = product.imageUrl;
        <tr>
            <td><img [src]="img" [class.d-none]="!showImage" alt=""></td>
            <!-- Resto de campos -->
        </tr>
    }
    </tbody>
</table>

Las variables declaradas con @let no pueden ser reasignadas en la plantilla. Pero si su valor depende de otras variables, propiedades, o llamadas a métodos, Angular actualizará el valor de la variable automáticamente.

Variable de referencia '#'

También se pueden crear variables en la plantilla si dentro de un elemento HTML ponemos un nombre de variable precedido por almohadilla '#'. En ese caso, dependiendo el tipo de elemento y si se le asigna valor o no, pueden referenciar diferentes cosas:

  • En elementos HTML estándar, la variable hará referencia al objeto del DOM de dicho elemento
  • En selectores de componentes, la variable hará referencia a la instancia de dicho componente (objeto de la clase)
  • Se le puede asignar como valor una directiva aplicada al elemento (como puede ser ngModel por ejemplo). En ese caso, hará referencia a la instancia de dicha directiva.

En la plantilla, la variable puede ser referenciada en cualquier momento, sin utilizar '#' delante, como si se tratara de una propiedad de la clase del componente, o una variable declarada con @let.

Por ejemplo, podríamos referenciar la directiva ngForm que Angular añade automáticamente a los formularios y pasárselo al método addProduct para que pueda resetear los campos del formulario automáticamente. En este caso, ya no necesitamos la propiedad fileName para vaciar el campo de la imagen (la directiva ngModel tiene que seguir estando presente), y el campo newProduct.imageUrl, al no estar vinculado en el formulario con ngModel hay que resetearlo manualmente. También nos podemos ahorrar el método que resetea el formulario y asignar el objeto con los campos vacíos directamente al declarar newProduct.

Es importante darse cuenta que al resetear el formulario mediante el objeto ngForm, los campos del producto referenciados se vacían (se ponen a null), por lo que tendremos que clonar el objeto que añadimos al array antes de resetear.

También crearemos una referencia al input de la imagen para pasárselo al método changeImage. Al ser una referencia al elemento del DOM, será del tipo HTMLInputElement, y nos ahorrará el tener que tiparlo a partir de event.target.

<div class="card mb-4">
  <div class="card-header bg-success text-white">Add product</div>
  <div class="card-body">
    <form (ngSubmit)="addProduct(productForm)" #productForm="ngForm">
      <!-- Resto de campos -->
      <label class="mb-3 row">
        <span class="col-sm-2 col-form-label text-sm-end"> Image</span>
        <div class="col-sm-10">
          <input type="file" class="form-control" accept="image/*" #imgInput (change)="changeImage(imgInput)" name="fileName" ngModel />
        </div>
      </label>
      <!-- Imagen y botón de submit -->
    </form>
  </div>
</div>

Encapsulación de estilos

Los estilos definidos en los componentes (CSS) solamente afecta a la plantilla del componente. No afecta a otros componentes, aunque estén dentro del mismo (El css de AppComponent no afecta a ProductsPageComponent por ejemplo).

Esto lo consigue Angular añadiendo un atributo cuyo nombre genera aleatoriamente tanto a los selectores CSS como a los elementos HTML de la plantilla. Por cada componente se genera un atributo diferente.

Los navegadores modernos soportan una característica llamada shadow DOM. Esto permite crear componentes web aislados en cuando al DOM y a los estilos CSS que se le aplican. Por defecto, Angular utiliza una emulación de esta técnica como ya hemos visto, añadiendo un atributo aleatorio a todos los elementos de la plantilla de un componente.

Se puede activar esta característica nativa con la opción encapsulation del componente. De esta manera, sin necesidad de crear un atributo extra, los estilos del componente no afectarán a otros, ya que su DOM estará aislado del DOM general. Hay que tener en cuenta que en este caso, los estilos globales tampoco afectarán al componente, por lo que este debe incluir e importar en su CSS propio aquellos estilos que quiera usar. Las variables CSS son una excepción y sí podrá acceder a las variables globales definidas en :root.

@Component({
  //...,
  encapsulation: ViewEncapsulation.ShadowDom // Por defecto es Emulated
})
export class ExampleComponent {
  //...
}

Por curiosidad, activar esta opción nos obliga a que el nombre del selector del componente deba tener un guión '-'. En este caso es buena idea establecer un prefijo global (prefix) para los componentes de la aplicación (que anteriormente habíamos eliminado).

<< Componentes Señales (Signals) >>