Módulo 20: Angular Material y CDK

Objetivos del módulo

  • Distinguir qué aporta Angular Material y qué aporta el CDK, y cuándo usar cada uno
  • Instalar Material con ng add y entender qué ficheros cambia
  • Elegir la versión de Material que corresponde a tu versión de Angular
  • Crear un tema Material 3 con mat.theme, modo oscuro y tokens CSS
  • Personalizar el tema con mat.theme-overrides y cada componente con sus overrides
  • Montar la estructura de una aplicación con barra, menú lateral y diseño adaptable
  • Usar los componentes más habituales: botones, tarjetas, chips, menús, pestañas y avisos
  • Construir formularios con mat-form-field, matInput, mat-select y el selector de fechas
  • Crear tablas con ordenación y paginación, tanto en el cliente como en el servidor
  • Abrir diálogos, pasarles datos y recoger su resultado
  • Colocar contenido flotante con el overlay del CDK y moverlo con portales
  • Implementar arrastrar y soltar con señales inmutables
  • Mostrar 100 000 filas con virtual scroll
  • Probar componentes de Material con harnesses

🔄 Cambia en Angular 22.0. Angular Material y el CDK 22 exigen Angular 22 (^22.0.0 || ^23.0.0), así que no se instalan sobre la LTS (Angular 21). Si usas la LTS: instala Material y CDK 21.2.14, que es lo que ng add @angular/material elige por sí solo en un proyecto Angular 21. Las API de este módulo son las mismas, salvo los apartados que llevan su propia marca.


1. Material y CDK: dos paquetes, dos ideas

Hasta ahora has escrito tu propio HTML y CSS. Eso es perfecto para aprender, pero construir bien un desplegable lleva más trabajo del que parece: tiene que abrirse encima del resto de la página, no salirse de la ventana, cerrarse con Escape, moverse con las flechas del teclado, anunciarse a los lectores de pantalla y devolver el foco al cerrarse. Lo mismo ocurre con un diálogo, una tabla ordenable o un selector de fechas.

El equipo de Angular publica dos paquetes para no empezar de cero:

  • @angular/material son componentes terminados, con el aspecto de Material Design 3 (M3), el sistema de diseño de Google. Pones <mat-select> y obtienes un desplegable completo.
  • @angular/cdk (Component Dev Kit) es el comportamiento sin aspecto: posicionar capas flotantes, arrastrar y soltar, listas virtuales, gestión del foco. No trae diseño, así que puedes crear componentes con tu propia marca.

Material se construye encima del CDK. MatDialog usa el Dialog del CDK, mat-table extiende cdk-table, y mat-menu o mat-select se abren con su overlay.

Pila de cuatro capas: tu aplicación, Angular Material, CDK y el núcleo de Angular. Material incluye mat-table, MatDialog y mat-menu, y el CDK incluye table, dialog, overlay, portal, drag-drop y scrolling

Figura 1. Material se apoya en el CDK, y tu aplicación puede usar cualquiera de los dos directamente.

¿Cuál usar? Depende de lo que necesites:

Situación Elección
Panel interno, herramienta de gestión, prototipo Material: rápido, coherente y accesible
Tu empresa ya tiene un sistema de diseño propio CDK, más tu CSS, o Tailwind (módulo 22)
Necesitas solo arrastrar y soltar o una lista virtual CDK: no hace falta instalar Material
Quieres patrones accesibles sin ningún estilo @angular/aria (módulo 21)

📘 Concepto: Un componente sin estilos (headless) aporta la lógica y la accesibilidad, y deja la apariencia en tus manos. El CDK sigue esa idea en la mayor parte de sus piezas: cdkDrag no te impone ningún color.

El hilo conductor de este módulo es la aplicación EMPRESA. Crearás un panel, una tabla de empleados, un diálogo de edición y un tablero de pedidos contra la API de ejemplo.


2. Instalación

Qué versión de Material necesitas 🔄 v22.0

🔄 Cambia en Angular 22.0. Material 22 solo admite Angular 22 o superior. Si usas la LTS: usa la rama 21 de Material, cuya última versión es la 21.2.14 (etiqueta v21-lts de npm); admite Angular 21 y también Angular 22.

Material y CDK siguen la versión mayor de Angular, pero van con su propio número de parche. Estas son las versiones comprobadas para este curso:

  Curso LTS
@angular/core 22.2.4 21.2.25
@angular/material y @angular/cdk 22.2.5 21.2.14
Angular que admite Material ^22.0.0 \|\| ^23.0.0 ^21.0.0 \|\| ^22.0.0
Etiqueta de npm latest v21-lts

Material y CDK deben ir siempre en la misma versión exacta: @angular/material declara como dependencia @angular/cdk sin rango, y cualquier otra combinación provoca un conflicto al instalar.

ng add @angular/material

La forma recomendada de instalarlo es:

ng add @angular/material

El comando te hace elegir un tema y deja el proyecto listo. No hace falta indicar la versión: el CLI busca una compatible con tu Angular. En un proyecto Angular 21, esta es la salida real:

> Searching for compatible package version
› Could not find a compatible version with `latest`. Searching for a compatible version.
› Found compatible package version: @angular/material@21.2.14.
...
CREATE src/material-theme.scss (1179 bytes)
UPDATE angular.json (2039 bytes)
UPDATE src/index.html (644 bytes)

Esto es lo que ha cambiado:

Fichero Cambio
package.json Añade @angular/material y @angular/cdk
src/material-theme.scss Fichero nuevo con el tema
angular.json Añade el tema a la lista styles, delante de styles.css
src/index.html Enlaces a las fuentes Roboto y Material Icons en Google Fonts

💡 Consejo: Los enlaces a Google Fonts funcionan al momento, pero tu aplicación dependerá de ese servidor. En producción, muchas empresas sirven las fuentes desde su propio dominio, por rendimiento y por privacidad.

Sin ng add

Hacerlo a mano ayuda a entender qué necesita Material de verdad. En este módulo pondremos todo en un único src/styles.scss, de modo que el angular.json queda así:

"styles": ["src/styles.scss"]

Y en src/index.html:

<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

Fíjate en lo que no hace falta en un proyecto moderno: ningún proveedor de animaciones, ningún NgModule y ningún zone.js. Los componentes de Material son standalone y funcionan sin Zone.js.

Importa lo que usas

Cada componente vive en su propio punto de entrada, como @angular/material/button o @angular/material/table, y se añade a los imports del componente que lo usa:

import { MatButton } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';

@Component({
  imports: [MatButton, MatTableModule],
  // ...
})

Hay dos estilos. Puedes importar cada directiva (MatButton, MatIcon) o el módulo que las agrupa (MatTableModule, MatListModule). Lo primero deja claro qué usas, y lo segundo compensa cuando un componente reparte su función en muchas directivas.

⚠️ Importante: Si olvidas importar una directiva que se aplica como atributo, Angular no da ningún error: matListItemIcon es, para el compilador, un atributo HTML desconocido pero legal. El síntoma es visual. Al preparar este módulo, importamos MatNavList y MatListItem pero no MatListItemIcon: compilaba sin avisos y los iconos salían descolocados respecto al texto. Si algo de Material se ve raro, revisa primero los imports. Con MatListModule desapareció el problema.

💡 Consejo: Para arrancar deprisa, Material trae generadores de pantallas completas: ng generate @angular/material:navigation, :table, :dashboard, :tree y :address-form. El código generado es buen material de lectura.


3. Tema Material 3 con tokens

Del color al botón

Antiguamente, un tema de Material generaba miles de reglas CSS para cada componente. En M3 la idea es otra, mucho más sencilla: el tema define un conjunto de variables CSS y los componentes las leen.

📘 Concepto: Un token de diseño es una decisión de diseño con nombre, como «el color primario» o «el radio de un botón». En Material 3 cada token es una variable CSS. Los hay de sistema, como --mat-sys-primary, compartidos por toda la aplicación, y de componente, como --mat-button-filled-container-shape, que afectan a una sola pieza.

Flujo en cuatro pasos: el mixin mat.theme genera tokens de sistema, los tokens de componente apuntan a ellos y el botón los pinta. mat.theme-overrides actúa sobre los de sistema y mat.button-overrides sobre los de componente

Figura 2. Un cambio en un token de sistema llega a todos los componentes. Uno de componente solo afecta a ese componente.

Esto tiene una consecuencia práctica muy buena: cambiar de tema en el navegador no exige recompilar ni cargar otro CSS, porque basta con que cambie el valor de la variable.

mat.theme

Este es el tema de nuestra aplicación, en src/styles.scss:

@use '@angular/material' as mat;

html {
  color-scheme: light;
  @include mat.theme((
    color: (
      primary: mat.$azure-palette,
      tertiary: mat.$orange-palette,
    ),
    typography: Roboto,
    density: 0,
  ));
}

html.oscuro {
  color-scheme: dark;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  background: var(--mat-sys-surface);
  color: var(--mat-sys-on-surface);
  font: var(--mat-sys-body-medium);
}

El fichero es .scss porque mat.theme es un mixin de Sass: se ejecuta al compilar y produce CSS normal. No necesitas instalar Sass, ya viene con el builder de Angular.

Opción Qué hace Ejemplo
color Una paleta o un mapa con primary y tertiary mat.$azure-palette
typography Familia tipográfica, o un mapa con más detalle Roboto
density 0 es lo normal; los negativos compactan -2

Las paletas incluidas son $red-palette, $green-palette, $blue-palette, $yellow-palette, $cyan-palette, $magenta-palette, $orange-palette, $chartreuse-palette, $spring-green-palette, $azure-palette, $violet-palette y $rose-palette.

Usa los tokens en tu CSS

Los tokens no son solo para Material. Si escribes tus estilos con ellos, tus componentes respetarán el tema y el modo oscuro sin más trabajo:

.columna {
  background: var(--mat-sys-surface-container);
  border-radius: var(--mat-sys-corner-medium);
}

.importe {
  color: var(--mat-sys-primary);
}

.columna h2 {
  font: var(--mat-sys-title-small);
}
Familia Ejemplos
Color --mat-sys-primary, --mat-sys-on-primary, --mat-sys-primary-container, --mat-sys-surface, --mat-sys-outline, --mat-sys-error
Tipografía --mat-sys-body-medium, --mat-sys-title-small
Forma --mat-sys-corner-medium, --mat-sys-corner-full
Elevación --mat-sys-level1, --mat-sys-level3

Las parejas primary/on-primary son importantes. on-primary es el color que garantiza buen contraste encima de primary. Si pones texto sobre --mat-sys-primary-container, usa --mat-sys-on-primary-container.

⚠️ Importante: No escribas colores fijos como #1565c0 en tus componentes. Se verán bien en claro, y mal en cuanto actives el modo oscuro.

Modo oscuro con una señal

Mira el valor real de un token en el navegador:

--mat-sys-primary: light-dark(#005cbb, #abc7ff)

mat.theme genera los dos colores a la vez dentro de la función CSS light-dark(). El navegador elige uno según la propiedad color-scheme. Por eso, el modo oscuro consiste en cambiar color-scheme, y nuestro tema lo hace con la clase oscuro:

// src/app/app.ts (fragmento)
private readonly documento = inject(DOCUMENT);
protected readonly oscuro = signal(false);

constructor() {
  effect(() => {
    this.documento.documentElement.classList.toggle('oscuro', this.oscuro());
  });
}

Si quieres seguir la preferencia del sistema operativo, sin botón, escribe color-scheme: light dark.

Panel de EMPRESA en modo claro, con tarjetas, botones de varias variantes y menú lateral

Figura 3. El panel en modo claro. El botón «Relleno» tiene las esquinas menos redondeadas porque lo personalizamos en el apartado siguiente.

El mismo panel en modo oscuro: fondo casi negro y primario azul claro

Figura 4. La misma aplicación sin recompilar: solo ha cambiado color-scheme, y con él, el valor de cada token.

Personalizar: overrides

Para cambiar el valor de un token de sistema en toda la aplicación:

@include mat.theme-overrides((
  error: #b3261e,
));

Y para cambiar solo un componente, cada uno ofrece su propio mixin. Por ejemplo, botones rellenos con esquinas de 8 px:

@include mat.button-overrides((
  filled-container-shape: 8px,
));

El compilador genera --mat-button-filled-container-shape: 8px, y solo esta forma de botón cambia. Los nombres válidos los comprueba Sass, de modo que una errata detiene la compilación. Es mucho más seguro que intentar pisar clases internas:

/* Mal: depende de la estructura interna, y una actualización puede romperlo */
.mat-mdc-button-base .mdc-button__label { color: red; }

💡 Consejo: Las clases .mat-mdc-* y .mdc-* son detalles internos, no una API. Si necesitas algo que no ofrecen los overrides, envuelve el componente en tu propia clase y cambia solo tokens públicos.

También puedes aplicar un mixin dentro de un selector para que afecte a una parte de la página. Esta clase hace más compactas las tablas que la lleven, con filas de 44 px en lugar de 52:

.densa {
  @include mat.table-density(-2);
}

Paleta propia

Si tu empresa tiene un color corporativo, no hace falta calcular a mano los tonos. Material lo hace con un generador:

ng generate @angular/material:theme-color --primary-color=#1a237e --tertiary-color=#ff7043 --directory=src/ --interactive=false
CREATE src/_theme-colors.scss (2535 bytes)

El fichero exporta $primary-palette y $tertiary-palette, que se usan como cualquier otra paleta. Lo harás en el ejercicio 1.


4. Componentes clave

La estructura de la aplicación

Casi toda aplicación de gestión tiene la misma forma: una barra arriba, un menú lateral y el contenido. Material la resuelve con mat-toolbar y mat-sidenav:

<!-- src/app/app.html -->
<mat-toolbar class="barra">
  <button matIconButton aria-label="Abrir o cerrar el menú" (click)="menu.toggle()">
    <mat-icon>menu</mat-icon>
  </button>
  <span>EMPRESA</span>
  <span class="hueco"></span>
  <button
    matIconButton
    [matTooltip]="oscuro() ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'"
    [attr.aria-label]="oscuro() ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'"
    (click)="oscuro.set(!oscuro())"
  >
    <mat-icon>{{ oscuro() ? 'light_mode' : 'dark_mode' }}</mat-icon>
  </button>
</mat-toolbar>

<mat-sidenav-container class="contenedor">
  <mat-sidenav #menu [mode]="modoMenu()" [opened]="!esMovil()">
    <mat-nav-list>
      @for (enlace of enlaces; track enlace.ruta) {
        <a
          mat-list-item
          [routerLink]="enlace.ruta"
          routerLinkActive
          #activo="routerLinkActive"
          [activated]="activo.isActive"
        >
          <mat-icon matListItemIcon>{{ enlace.icono }}</mat-icon>
          <span matListItemTitle>{{ enlace.texto }}</span>
        </a>
      }
    </mat-nav-list>
  </mat-sidenav>

  <mat-sidenav-content>
    <main class="contenido">
      <router-outlet />
    </main>
  </mat-sidenav-content>
</mat-sidenav-container>
// src/app/app.ts
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { DOCUMENT } from '@angular/common';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSidenav, MatSidenavContainer, MatSidenavContent } from '@angular/material/sidenav';
import { MatToolbar } from '@angular/material/toolbar';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { map } from 'rxjs';

@Component({
  selector: 'app-root',
  imports: [
    MatIcon,
    MatIconButton,
    MatListModule,
    MatSidenav,
    MatSidenavContainer,
    MatSidenavContent,
    MatToolbar,
    MatTooltip,
    RouterLink,
    RouterLinkActive,
    RouterOutlet,
  ],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {
  private readonly documento = inject(DOCUMENT);
  private readonly pantallas = inject(BreakpointObserver);

  protected readonly oscuro = signal(false);
  protected readonly esMovil = toSignal(
    this.pantallas.observe(Breakpoints.Handset).pipe(map((estado) => estado.matches)),
    { initialValue: false },
  );
  protected readonly modoMenu = computed(() => (this.esMovil() ? 'over' : 'side'));

  protected readonly enlaces = [
    { ruta: '/panel', icono: 'dashboard', texto: 'Panel' },
    { ruta: '/tabla-cliente', icono: 'table_view', texto: 'Tabla en cliente' },
    { ruta: '/tabla-servidor', icono: 'cloud', texto: 'Tabla en servidor' },
    { ruta: '/alta', icono: 'person_add', texto: 'Alta de empleado' },
    { ruta: '/superpuesta', icono: 'layers', texto: 'Overlay' },
    { ruta: '/portales', icono: 'open_in_new', texto: 'Portales' },
    { ruta: '/tablero', icono: 'view_kanban', texto: 'Tablero de pedidos' },
    { ruta: '/virtual', icono: 'view_list', texto: 'Lista virtual' },
  ];

  constructor() {
    effect(() => {
      this.documento.documentElement.classList.toggle('oscuro', this.oscuro());
    });
  }
}
/* src/app/app.css */
:host {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.barra {
  background: var(--mat-sys-primary-container);
  color: var(--mat-sys-on-primary-container);
}

.hueco {
  flex: 1;
}

.contenedor {
  flex: 1;
  min-height: 0;
}

mat-sidenav {
  width: 240px;
}

.contenido {
  padding: 16px 24px;
}

Hay varias decisiones que merecen una explicación:

  • mode. Con side, el menú empuja el contenido; con over, flota encima con una capa oscura detrás. El valor sale de una señal que depende del tamaño de pantalla.
  • BreakpointObserver es una pieza del CDK que observa media queries desde TypeScript. Lo envolvemos con toSignal, que viste en el módulo 15.
  • routerLinkActive sin clases, junto con #activo="routerLinkActive", te da la propiedad isActive. La usamos para la entrada [activated], que marca la opción del menú actual.
  • min-height: 0. Un elemento flexible no se encoge por debajo de su contenido salvo que se lo indiques. Sin esto, el contenedor crecía más que la ventana.
  • El relleno va en un <main> interior, no en mat-sidenav-content. Este elemento tiene height: 100%, de modo que añadirle relleno lo hace 32 px más alto que su contenedor. En nuestras primeras pruebas, al enfocar un botón del final, toda la pantalla saltaba hacia arriba.

Menú lateral desplegado sobre el contenido en un móvil, con el fondo oscurecido

Figura 5. En un móvil de 420 px de ancho, BreakpointObserver activa el modo over.

Cada pantalla de este módulo es una ruta con carga perezosa, y todas leen la dirección de la API de un único fichero:

// src/app/api.ts
export const API = 'http://localhost:3000/api';
// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'panel' },
  { path: 'panel', title: 'Panel', loadComponent: () => import('./panel/panel').then((m) => m.Panel) },
  { path: 'tabla-cliente', title: 'Tabla en cliente', loadComponent: () => import('./tabla-cliente/tabla-cliente').then((m) => m.TablaCliente) },
  { path: 'tabla-servidor', title: 'Tabla en servidor', loadComponent: () => import('./tabla-servidor/tabla-servidor').then((m) => m.TablaServidor) },
  { path: 'alta', title: 'Alta', loadComponent: () => import('./alta/alta').then((m) => m.Alta) },
  { path: 'alta-signal', title: 'Alta Signal Forms', loadComponent: () => import('./alta-signal/alta-signal').then((m) => m.AltaSignal) },
  { path: 'superpuesta', title: 'Overlay', loadComponent: () => import('./superpuesta/superpuesta').then((m) => m.Superpuesta) },
  { path: 'portales', title: 'Portales', loadComponent: () => import('./portales/portales').then((m) => m.Portales) },
  { path: 'tablero', title: 'Tablero', loadComponent: () => import('./tablero/tablero').then((m) => m.Tablero) },
  { path: 'virtual', title: 'Lista virtual', loadComponent: () => import('./virtual/virtual').then((m) => m.Virtual) },
];

Botones, iconos y tarjetas

En versiones actuales, los botones se crean con una sola directiva, y la variante se indica con su valor:

<button matButton>Texto</button>
<button matButton="outlined">Contorno</button>
<button matButton="tonal">Tonal</button>
<button matButton="filled">Relleno</button>
<button matButton="elevated">Elevado</button>
<button matIconButton aria-label="Avisos" matTooltip="Tienes 3 avisos">
  <mat-icon matBadge="3" aria-hidden="false">notifications</mat-icon>
</button>
<button matFab extended (click)="avisar()"><mat-icon>summarize</mat-icon> Informe</button>

La importancia visual debe seguir a la importancia de la acción: filled para la acción principal de la pantalla, tonal o outlined para las secundarias, y text para las de poca importancia, como «Cancelar».

⚠️ Importante: Un botón que solo tiene un icono no tiene nombre para un lector de pantalla. aria-label es obligatorio en matIconButton. mat-icon se marca solo con aria-hidden="true", y por eso en la insignia lo ponemos a false: si no, el «3» no se anunciaría.

mat-icon escribe el nombre del icono como texto (delete, edit), y una ligadura de la fuente Material Icons lo convierte en el dibujo. Si un día ves la palabra delete en lugar del icono, es que la fuente no ha cargado.

Las tarjetas agrupan contenido relacionado. Este panel lee las estadísticas de la API, con los estados que aprendiste en el módulo 13:

// src/app/panel/panel.ts
import { KeyValuePipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { MatBadge } from '@angular/material/badge';
import { MatButton, MatFabButton, MatIconButton } from '@angular/material/button';
import { MatCard, MatCardContent, MatCardHeader, MatCardSubtitle, MatCardTitle } from '@angular/material/card';
import { MatChip, MatChipSet } from '@angular/material/chips';
import { MatIcon } from '@angular/material/icon';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTab, MatTabGroup } from '@angular/material/tabs';
import { MatTooltip } from '@angular/material/tooltip';
import { API } from '../api';

interface Estadisticas {
  empleados: number;
  clientes: number;
  productos: number;
  pedidos: number;
  porEstado: Record<string, number>;
}

@Component({
  selector: 'app-panel',
  imports: [
    KeyValuePipe,
    MatBadge,
    MatButton,
    MatCard,
    MatCardContent,
    MatCardHeader,
    MatCardSubtitle,
    MatCardTitle,
    MatChip,
    MatChipSet,
    MatFabButton,
    MatIcon,
    MatIconButton,
    MatMenu,
    MatMenuItem,
    MatMenuTrigger,
    MatProgressBar,
    MatProgressSpinner,
    MatTab,
    MatTabGroup,
    MatTooltip,
  ],
  templateUrl: './panel.html',
  styleUrl: './panel.css',
})
export class Panel {
  private readonly avisos = inject(MatSnackBar);
  protected readonly datos = httpResource<Estadisticas>(() => `${API}/estadisticas`);

  protected avisar() {
    this.avisos.open('Informe generado', 'Cerrar', { duration: 3000 });
  }
}
<!-- src/app/panel/panel.html -->
<h1>Panel</h1>

@if (datos.isLoading()) {
  <mat-progress-bar mode="indeterminate" />
}

@if (datos.hasValue()) {
  <div class="tarjetas">
    <mat-card appearance="outlined">
      <mat-card-header>
        <mat-card-title>{{ datos.value().empleados }}</mat-card-title>
        <mat-card-subtitle>Empleados</mat-card-subtitle>
      </mat-card-header>
    </mat-card>
    <mat-card appearance="outlined">
      <mat-card-header>
        <mat-card-title>{{ datos.value().clientes }}</mat-card-title>
        <mat-card-subtitle>Clientes</mat-card-subtitle>
      </mat-card-header>
    </mat-card>
    <mat-card appearance="filled">
      <mat-card-header>
        <mat-card-title>{{ datos.value().pedidos }}</mat-card-title>
        <mat-card-subtitle>Pedidos</mat-card-subtitle>
      </mat-card-header>
      <mat-card-content>
        <mat-chip-set aria-label="Pedidos por estado">
          @for (par of datos.value().porEstado | keyvalue; track par.key) {
            <mat-chip>{{ par.key }}: {{ par.value }}</mat-chip>
          }
        </mat-chip-set>
      </mat-card-content>
    </mat-card>
  </div>
}

Menús, pestañas, indicadores y avisos

<button matButton="outlined" [matMenuTriggerFor]="acciones">Acciones</button>
<mat-menu #acciones="matMenu">
  <button mat-menu-item><mat-icon>edit</mat-icon> Editar</button>
  <button mat-menu-item><mat-icon>content_copy</mat-icon> Duplicar</button>
</mat-menu>

<mat-spinner diameter="32" mode="determinate" value="70" />

<mat-tab-group>
  <mat-tab label="Datos">El contenido de cada pestaña es una plantilla.</mat-tab>
  <mat-tab label="Historial">Aquí iría el historial.</mat-tab>
</mat-tab-group>
Componente Para qué sirve Importa
mat-toolbar Barra superior MatToolbar
mat-sidenav Panel lateral MatSidenav, MatSidenavContainer, MatSidenavContent
mat-nav-list Lista de navegación MatListModule
mat-card Agrupar contenido MatCard y sus partes
mat-chip-set Etiquetas MatChipSet, MatChip
[matMenuTriggerFor] Menú desplegable MatMenu, MatMenuItem, MatMenuTrigger
mat-tab-group Pestañas MatTabGroup, MatTab
[matTooltip] Pista al pasar por encima MatTooltip
[matBadge] Contador sobre un elemento MatBadge
mat-progress-bar, mat-spinner Progreso MatProgressBar, MatProgressSpinner
MatSnackBar Aviso temporal Se inyecta

💡 Consejo: mat-menu-item depende de mat-menu, que es quien carga sus estilos. Si lo pones suelto, verás botones sin estilo. Nos pasó en el ejercicio 6, y lo resolvimos con mat-action-list, que sí tiene sentido por sí sola.

MatSnackBar es un servicio, no un componente: se inyecta y se llama. Es perfecto para confirmaciones breves, como «Empleado guardado». En el apartado 7 lo usarás con una acción de deshacer.


5. Formularios

mat-form-field

Un campo de Material son dos piezas que trabajan juntas: mat-form-field pone el contenedor visual (etiqueta flotante, línea, pistas y errores), y matInput conecta tu <input> nativo con ese contenedor. Sigue siendo un <input> de verdad, de modo que funcionan el autocompletado y los lectores de pantalla.

📘 Concepto: mat-form-field no sabe qué lleva dentro. Se comunica con una interfaz llamada MatFormFieldControl, que implementan matInput, mat-select y los controles que escribas tú. Si dentro no hay ninguno, se detiene con un error (apartado 14).

Usamos el mismo formulario reactivo del módulo 17. Material no sustituye a los formularios de Angular: solo los viste.

// src/app/alta/alta.ts
import { JsonPipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { MatOption, MatSelect } from '@angular/material/select';
import { MatSlideToggle } from '@angular/material/slide-toggle';

@Component({
  selector: 'app-alta',
  imports: [
    JsonPipe,
    MatButton,
    MatCheckbox,
    MatDatepickerModule,
    MatFormFieldModule,
    MatInput,
    MatOption,
    MatRadioButton,
    MatRadioGroup,
    MatSelect,
    MatSlideToggle,
    ReactiveFormsModule,
  ],
  templateUrl: './alta.html',
  styleUrl: './alta.css',
})
export class Alta {
  private readonly fb = inject(FormBuilder).nonNullable;

  protected readonly enviado = signal<unknown>(null);
  protected readonly formulario = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    salario: [0, [Validators.required, Validators.min(1000)]],
    departamento: ['', Validators.required],
    alta: [null as Date | null, Validators.required],
    jornada: ['completa'],
    teletrabajo: [false],
    acepta: [false, Validators.requiredTrue],
  });

  protected guardar() {
    this.formulario.markAllAsTouched();
    if (this.formulario.valid) {
      this.enviado.set(this.formulario.getRawValue());
    }
  }
}
<!-- src/app/alta/alta.html -->
<h1>Alta de empleado</h1>

<form [formGroup]="formulario" (ngSubmit)="guardar()" novalidate>
  <mat-form-field>
    <mat-label>Correo</mat-label>
    <input matInput type="email" formControlName="email" />
    <mat-hint>Usa el correo de la empresa</mat-hint>
    @if (formulario.controls.email.hasError('required')) {
      <mat-error>El correo es obligatorio</mat-error>
    } @else if (formulario.controls.email.hasError('email')) {
      <mat-error>El correo no es válido</mat-error>
    }
  </mat-form-field>

  <mat-form-field>
    <mat-label>Salario</mat-label>
    <input matInput type="number" formControlName="salario" />
    <span matTextSuffix>€</span>
    @if (formulario.controls.salario.hasError('min')) {
      <mat-error>El mínimo es 1000 €</mat-error>
    }
  </mat-form-field>

  <mat-form-field>
    <mat-label>Departamento</mat-label>
    <mat-select formControlName="departamento">
      <mat-option value="VENTAS">Ventas</mat-option>
      <mat-option value="INFORMÁTICA">Informática</mat-option>
    </mat-select>
    @if (formulario.controls.departamento.hasError('required')) {
      <mat-error>Elige un departamento</mat-error>
    }
  </mat-form-field>

  <mat-form-field>
    <mat-label>Fecha de alta</mat-label>
    <input matInput [matDatepicker]="selector" formControlName="alta" />
    <mat-datepicker-toggle matIconSuffix [for]="selector" />
    <mat-datepicker #selector />
    @if (formulario.controls.alta.hasError('required')) {
      <mat-error>La fecha es obligatoria</mat-error>
    }
  </mat-form-field>

  <mat-radio-group formControlName="jornada" aria-label="Jornada">
    <mat-radio-button value="completa">Completa</mat-radio-button>
    <mat-radio-button value="parcial">Parcial</mat-radio-button>
  </mat-radio-group>

  <mat-slide-toggle formControlName="teletrabajo">Teletrabajo</mat-slide-toggle>
  <mat-checkbox formControlName="acepta">Los datos son correctos</mat-checkbox>

  <button matButton="filled" type="submit">Guardar</button>
</form>

@if (enviado(); as datos) {
  <pre>{{ datos | json }}</pre>
}
/* src/app/alta/alta.css */
form {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  max-width: 420px;
}

mat-form-field {
  width: 100%;
}

Formulario de alta con los campos en rojo y un mensaje de error bajo cada uno

Figura 6. Al pulsar «Guardar» sin datos, markAllAsTouched hace que aparezcan todos los errores.

Conviene entender cuatro detalles:

  • mat-error solo se ve si el control es inválido y se ha tocado. Por eso no aparecen errores nada más abrir la página, y por eso guardar llama a markAllAsTouched. Cuando hay un error, sustituye a mat-hint.
  • La etiqueta es mat-label, no placeholder. Flota hacia arriba al escribir, de modo que nunca pierdes de vista qué campo es.
  • Los prefijos y sufijos se añaden con matTextPrefix, matTextSuffix, matIconPrefix y matIconSuffix.
  • El asterisco de la etiqueta aparece solo: Material ve Validators.required en el control.

La apariencia por defecto es fill. Con <mat-form-field appearance="outline"> obtienes el borde completo. Y si quieres cambiarlo en toda la aplicación, usa el proveedor MAT_FORM_FIELD_DEFAULT_OPTIONS:

{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } }

Fechas en español

Un selector de fechas necesita un adaptador: el objeto que le enseña a manejar fechas. provideNativeDateAdapter() usa el Date de JavaScript. Si no lo proporcionas, el selector falla al crearse. Y MAT_DATE_LOCALE indica el idioma:

// src/app/app.config.ts
import { registerLocaleData } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import es from '@angular/common/locales/es';
import {
  ApplicationConfig,
  LOCALE_ID,
  provideAppInitializer,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/core';
import { MatPaginatorIntl } from '@angular/material/paginator';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { PaginadorEs } from './paginador-es';
import { conToken, entrarComoAna } from './sesion';

registerLocaleData(es);

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(withInterceptors([conToken])),
    provideAppInitializer(entrarComoAna),
    provideNativeDateAdapter(),
    { provide: LOCALE_ID, useValue: 'es-ES' },
    { provide: MAT_DATE_LOCALE, useValue: 'es-ES' },
    { provide: MatPaginatorIntl, useClass: PaginadorEs },
  ],
};

LOCALE_ID afecta a los pipes de Angular, como currency, y MAT_DATE_LOCALE, al selector de fechas. Son independientes: necesitas los dos. Los demás proveedores de esta lista se explican más adelante.

Calendario desplegado con el mes de noviembre, semanas que empiezan en lunes y días en español

Figura 7. Con MAT_DATE_LOCALE, la semana empieza en lunes y las fechas se escriben como 15/11/2026.

⚠️ Importante: El control guarda un objeto Date, y las fechas son una trampa clásica. Al elegir el 15 de noviembre desde España, nuestro formulario contenía "2026-11-14T23:00:00.000Z": día 14, porque JSON.stringify y toISOString convierten la medianoche local a UTC. Si tu API espera solo la fecha, formatéala tú con los campos locales, como hacemos en el apartado 7. Y al revés: new Date('2025-09-01') se interpreta en UTC, y al oeste de Greenwich mostraría el día anterior. Añade la hora, new Date('2025-09-01T00:00'), para que sea local.

Signal Forms con Material 🔄 v22.0

🔄 Cambia en Angular 22.0. Signal Forms pasa de experimental a estable. Si usas la LTS: este ejemplo compila y funciona igual con Material 21.2.14, pero la API es experimental y puede cambiar.

Los campos de Material también funcionan con los Signal Forms del módulo 18: basta con poner [formField] en el elemento con matInput.

// src/app/alta-signal/alta-signal.ts
import { Component, signal } from '@angular/core';
import { email, form, FormField, min, required } from '@angular/forms/signals';
import { MatFormField, MatError, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';

@Component({
  selector: 'app-alta-signal',
  imports: [FormField, MatError, MatFormField, MatInput, MatLabel],
  template: `
    <mat-form-field>
      <mat-label>Correo</mat-label>
      <input matInput [formField]="alta.email" />
      @if (alta.email().touched() && alta.email().invalid()) {
        <mat-error>{{ alta.email().errors()[0].message }}</mat-error>
      }
    </mat-form-field>

    <mat-form-field>
      <mat-label>Salario</mat-label>
      <input matInput type="number" [formField]="alta.salario" />
      @if (alta.salario().touched() && alta.salario().invalid()) {
        <mat-error>{{ alta.salario().errors()[0].message }}</mat-error>
      }
    </mat-form-field>
    <p>{{ modelo().email }} / {{ modelo().salario }}</p>
  `,
})
export class AltaSignal {
  protected readonly modelo = signal({ email: '', salario: 0 });
  protected readonly alta = form(this.modelo, (ruta) => {
    required(ruta.email, { message: 'El correo es obligatorio' });
    email(ruta.email, { message: 'El correo no es válido' });
    min(ruta.salario, 1000, { message: 'El mínimo es 1000 €' });
  });
}

Al escribir ana y salir del campo, aparece «El correo no es válido» y la señal modelo ya contiene ana. Al escribir un correo correcto y 2000 en el salario, desaparecen los errores y el párrafo muestra ana@empresa.es / 2000.


6. Tablas

mat-table es el componente más importante de Material en una aplicación de gestión, y el que más desconcierta la primera vez, porque no se le pasa una lista de columnas con sus datos. En su lugar, declaras cómo se pinta cada columna y luego decides qué columnas se muestran y en qué orden:

<ng-container matColumnDef="oficio">
  <th mat-header-cell *matHeaderCellDef>Oficio</th>
  <td mat-cell *matCellDef="let e">{{ e.oficio }}</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="columnas"></tr>
<tr mat-row *matRowDef="let fila; columns: columnas"></tr>
Pieza Qué hace
matColumnDef="oficio" Da nombre a una columna
*matHeaderCellDef Plantilla de la cabecera de esa columna
*matCellDef="let e" Plantilla de cada celda; e es la fila
*matHeaderRowDef="columnas" Qué columnas lleva la cabecera, y en qué orden
*matRowDef="...; columns: columnas" Lo mismo para las filas de datos

Separar definición y orden tiene una ventaja: para ocultar una columna en el móvil, solo cambias el array. Las columnas son una señal, y las definiciones no se tocan.

Ordenar, paginar y filtrar en el cliente

Si los datos caben en memoria (decenas o cientos de filas), MatTableDataSource lo hace todo por ti:

📘 Concepto: MatTableDataSource es un objeto que guarda todos los datos, y ofrece a la tabla solo los que debe mostrar según el filtro, el orden y la página. La tabla se suscribe a él y se repinta sola.

// src/app/tabla-cliente/tabla-cliente.ts
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, effect, viewChild } from '@angular/core';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-tabla-cliente',
  imports: [
    CurrencyPipe,
    MatFormField,
    MatInput,
    MatLabel,
    MatPaginator,
    MatSort,
    MatSortHeader,
    MatTableModule,
  ],
  templateUrl: './tabla-cliente.html',
})
export class TablaCliente {
  protected readonly columnas = ['empId', 'nombre', 'oficio', 'salario'];
  protected readonly fuente = new MatTableDataSource<Empleado>([]);

  private readonly respuesta = httpResource<Pagina<Empleado>>(
    () => `${API}/empleados?tamano=100`,
  );
  private readonly orden = viewChild.required(MatSort);
  private readonly paginador = viewChild.required(MatPaginator);

  constructor() {
    this.fuente.filterPredicate = (e, filtro) =>
      `${e.nombre} ${e.apellidos} ${e.oficio}`.toLowerCase().includes(filtro);

    this.fuente.sortingDataAccessor = (e, columna) =>
      columna === 'nombre' ? e.apellidos : (e[columna as keyof Empleado] as number | string);

    effect(() => {
      this.fuente.sort = this.orden();
      this.fuente.paginator = this.paginador();
    });

    effect(() => {
      if (this.respuesta.hasValue()) {
        this.fuente.data = this.respuesta.value().contenido;
      }
    });
  }

  protected filtrar(texto: string) {
    this.fuente.filter = texto.trim().toLowerCase();
    this.fuente.paginator?.firstPage();
  }
}
<!-- src/app/tabla-cliente/tabla-cliente.html -->
<mat-form-field>
  <mat-label>Buscar</mat-label>
  <input matInput #busqueda (input)="filtrar(busqueda.value)" />
</mat-form-field>

<table mat-table [dataSource]="fuente" matSort>
  <ng-container matColumnDef="empId">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>Id</th>
    <td mat-cell *matCellDef="let e">{{ e.empId }}</td>
  </ng-container>

  <ng-container matColumnDef="nombre">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>Empleado</th>
    <td mat-cell *matCellDef="let e">{{ e.apellidos }}, {{ e.nombre }}</td>
  </ng-container>

  <ng-container matColumnDef="oficio">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>Oficio</th>
    <td mat-cell *matCellDef="let e">{{ e.oficio }}</td>
  </ng-container>

  <ng-container matColumnDef="salario">
    <th mat-header-cell *matHeaderCellDef mat-sort-header arrowPosition="before">Salario</th>
    <td mat-cell *matCellDef="let e">{{ e.salario | currency: 'EUR' }}</td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="columnas; sticky: true"></tr>
  <tr mat-row *matRowDef="let fila; columns: columnas"></tr>

  <tr class="mat-mdc-row" *matNoDataRow>
    <td class="mat-mdc-cell" [attr.colspan]="columnas.length">
      Nada coincide con «{{ busqueda.value }}»
    </td>
  </tr>
</table>

<mat-paginator [pageSize]="5" [pageSizeOptions]="[5, 10, 25]" showFirstLastButtons />

Tabla de empleados ordenada por salario descendente, con buscador y paginador «1 a 5 de 24»

Figura 8. Tabla ordenada por salario en el cliente. Pulsar la cabecera no genera ninguna petición.

Vamos por partes:

  • Conectar orden y paginador. Son componentes que están en la vista, de modo que no existen hasta que esta se crea. viewChild.required da una señal, y un effect hace la conexión cuando tiene valor.
  • mat-sort-header usa el nombre de la columna para ordenar: la columna oficio ordena por e.oficio. La nuestra llamada nombre no coincide con lo que queremos, porque ordenamos por apellidos. Para eso sirve sortingDataAccessor, que devuelve el valor por el que ordenar cada fila.
  • filterPredicate decide si una fila pasa el filtro. El de serie concatena todos los campos, incluidos los que no se ven: un filtro de 100 encontraría el identificador, y también 100 dentro del teléfono.
  • firstPage(). Si estás en la página 4 y filtras, puede que queden solo dos resultados.
  • *matNoDataRow se muestra cuando el filtro no deja ninguna fila.
  • Una cabecera fija. sticky: true la mantiene visible, siempre que la tabla esté dentro de un contenedor con desplazamiento.

Al buscar analista, solo quedan dos filas, y el paginador muestra 1 a 2 de 2.

⚠️ Importante: No conviertas @if en un envoltorio de la tabla o del paginador con este patrón sin pensarlo: si el MatSort no existe todavía, viewChild.required lanza un error al leerlo. Si tienen que aparecer más tarde, usa viewChild a secas, que devuelve undefined, y comprueba el valor en el effect. Otra opción más sencilla es ocultar la tabla con CSS.

Traducir el paginador

Los textos de mat-paginator vienen en inglés, y no los traduce LOCALE_ID. Se cambian con un servicio propio que se registra con MatPaginatorIntl, como ya viste en app.config.ts:

// src/app/paginador-es.ts
import { Injectable } from '@angular/core';
import { MatPaginatorIntl } from '@angular/material/paginator';

@Injectable()
export class PaginadorEs extends MatPaginatorIntl {
  override itemsPerPageLabel = 'Filas por página';
  override nextPageLabel = 'Página siguiente';
  override previousPageLabel = 'Página anterior';
  override firstPageLabel = 'Primera página';
  override lastPageLabel = 'Última página';

  override getRangeLabel = (pagina: number, tamano: number, total: number) => {
    if (total === 0 || tamano === 0) {
      return `0 de ${total}`;
    }
    const desde = pagina * tamano + 1;
    const hasta = Math.min((pagina + 1) * tamano, total);
    return `${desde} a ${hasta} de ${total}`;
  };
}

Si tu aplicación cambia de idioma mientras funciona, llama a changes.next() en este servicio para que el paginador se repinte. También hay clases equivalentes para el orden, MatSortHeaderIntl, y para el calendario, MatDatepickerIntl.

Ordenar y paginar en el servidor 🔄 v22.0

🔄 Cambia en Angular 22.0. httpResource pasa de experimental a estable. Si usas la LTS: este apartado funciona igual, con Material 21.2.14, pero httpResource es experimental y puede cambiar. Si prefieres no depender de él, usa HttpClient con toObservable y switchMap, como en el módulo 15.

¿Y si la tabla tiene dos millones de filas? No puedes descargarlas todas. Entonces, ordenar y paginar debe hacerlo la base de datos, y la tabla solo muestra la página que recibe. La API EMPRESA ya lo admite:

GET /api/empleados?orden=-salario&pagina=2&tamano=5

Aquí MatTableDataSource sobra: su trabajo lo hace el servidor. Le pasamos a la tabla un array normal, y convertimos los eventos de orden y paginación en señales. A partir de ahí, httpResource reacciona solo.

Ciclo: matSort y mat-paginator escriben en señales, httpResource pide los datos a la API, y las señales filas y total alimentan de nuevo la tabla y el paginador

Figura 9. Cada clic escribe en una señal, y todo lo demás ocurre por reacción.

// src/app/tabla-servidor/tabla-servidor.ts
import { CurrencyPipe } from '@angular/common';
import { HttpClient, HttpErrorResponse, httpResource } from '@angular/common/http';
import { Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatSort, MatSortHeader, Sort } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { API } from '../api';
import { DialogoConfirmar, DatosConfirmar } from '../dialogos/dialogo-confirmar';
import { DialogoEmpleado } from '../dialogos/dialogo-empleado';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-tabla-servidor',
  imports: [
    CurrencyPipe,
    MatButton,
    MatIcon,
    MatIconButton,
    MatPaginator,
    MatProgressBar,
    MatSort,
    MatSortHeader,
    MatTableModule,
  ],
  templateUrl: './tabla-servidor.html',
  styleUrl: './tabla-servidor.css',
})
export class TablaServidor {
  private readonly http = inject(HttpClient);
  private readonly dialogos = inject(MatDialog);
  private readonly avisos = inject(MatSnackBar);

  protected readonly columnas = ['nombre', 'oficio', 'salario', 'acciones'];

  protected readonly orden = signal<Sort>({ active: 'apellidos', direction: 'asc' });
  protected readonly tamano = signal(5);
  protected readonly pagina = linkedSignal({
    source: this.orden,
    computation: () => 0,
  });

  private readonly parametroOrden = computed(() => {
    const { active, direction } = this.orden();
    if (!direction) {
      return 'empId';
    }
    return direction === 'desc' ? `-${active}` : active;
  });

  protected readonly respuesta = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: {
      orden: this.parametroOrden(),
      pagina: this.pagina() + 1,
      tamano: this.tamano(),
    },
  }));

  protected readonly ultima = linkedSignal<Pagina<Empleado> | undefined, Pagina<Empleado> | undefined>({
    source: () => (this.respuesta.hasValue() ? this.respuesta.value() : undefined),
    computation: (nueva, previa) => nueva ?? previa?.value,
  });
  protected readonly filas = computed(() => this.ultima()?.contenido ?? []);
  protected readonly total = computed(() => this.ultima()?.total ?? 0);

  protected cambiarPagina(evento: PageEvent) {
    this.tamano.set(evento.pageSize);
    this.pagina.set(evento.pageIndex);
  }

  // crear(), editar() y borrar(): apartado 7
}
<!-- src/app/tabla-servidor/tabla-servidor.html -->
<div class="cabecera">
  <h1>Empleados</h1>
  <button matButton="filled" (click)="crear()"><mat-icon>add</mat-icon> Nuevo</button>
</div>

<div class="marco" [class.cargando]="respuesta.isLoading()">
  <mat-progress-bar [mode]="respuesta.isLoading() ? 'indeterminate' : 'determinate'" [value]="0" />

  <table
    mat-table
    [dataSource]="filas()"
    matSort
    [matSortActive]="orden().active"
    [matSortDirection]="orden().direction"
    (matSortChange)="orden.set($event)"
  >
    <ng-container matColumnDef="nombre">
      <th mat-header-cell *matHeaderCellDef mat-sort-header="apellidos">Empleado</th>
      <td mat-cell *matCellDef="let e">{{ e.apellidos }}, {{ e.nombre }}</td>
    </ng-container>

    <ng-container matColumnDef="oficio">
      <th mat-header-cell *matHeaderCellDef mat-sort-header>Oficio</th>
      <td mat-cell *matCellDef="let e">{{ e.oficio }}</td>
    </ng-container>

    <ng-container matColumnDef="salario">
      <th mat-header-cell *matHeaderCellDef mat-sort-header arrowPosition="before">Salario</th>
      <td mat-cell *matCellDef="let e">{{ e.salario | currency: 'EUR' }}</td>
    </ng-container>

    <ng-container matColumnDef="acciones">
      <th mat-header-cell *matHeaderCellDef></th>
      <td mat-cell *matCellDef="let e">
        <button matIconButton aria-label="Editar" (click)="editar(e)"><mat-icon>edit</mat-icon></button>
        <button matIconButton aria-label="Borrar" (click)="borrar(e)"><mat-icon>delete</mat-icon></button>
      </td>
    </ng-container>

    <tr mat-header-row *matHeaderRowDef="columnas"></tr>
    <tr mat-row *matRowDef="let fila; columns: columnas"></tr>
  </table>

  <mat-paginator
    [length]="total()"
    [pageIndex]="pagina()"
    [pageSize]="tamano()"
    [pageSizeOptions]="[5, 10, 25]"
    (page)="cambiarPagina($event)"
  />
</div>
/* src/app/tabla-servidor/tabla-servidor.css */
.cabecera {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.marco.cargando table {
  opacity: 0.5;
}

.marco {
  max-width: 820px;
}

Estas son las peticiones reales al pulsar dos veces «Salario», pasar de página y ordenar por «Oficio»:

GET /api/empleados?orden=apellidos&pagina=1&tamano=5
GET /api/empleados?orden=salario&pagina=1&tamano=5
GET /api/empleados?orden=-salario&pagina=1&tamano=5
GET /api/empleados?orden=-salario&pagina=2&tamano=5
GET /api/empleados?orden=oficio&pagina=1&tamano=5

Tabla de empleados con columnas de acciones y botón Nuevo, ordenada por salario ascendente

Figura 10. Ahora cada clic en una cabecera pide al servidor la página ordenada.

Fíjate en estas decisiones:

  • La página empieza en 0 en Material y en 1 en la API. Es fácil que se te pase. Por eso enviamos pagina() + 1.
  • Al ordenar se vuelve a la primera página. Lo consigue linkedSignal: cuando cambia orden, pagina vuelve a 0 sin que nadie lo programe. La última línea de las peticiones lo demuestra. Y como pagina sigue siendo escribible, el paginador puede cambiarla.
  • [length]="total()". Con MatTableDataSource, el paginador conocía el total. Ahora debe decírselo el servidor.
  • mat-sort-header="apellidos". El valor es el identificador de orden, que puede ser distinto del nombre de la columna. Es lo que llega en Sort.active, y lo que enviamos a la API.
  • direction puede estar vacío. Un tercer clic en la cabecera quita el orden, y Sort.direction vale ''. Volvemos al orden por identificador.
  • La tabla no parpadea. Cuando cambian los parámetros, httpResource deja value() en undefined hasta tener datos. Si pasáramos eso directamente, la tabla se vaciaría en cada clic. ultima conserva la respuesta anterior mientras llega la nueva, y mostramos la carga con una barra y una tabla semitransparente.

💡 Consejo: Para elegir entre las dos formas, piensa en el volumen. Con menos de unos pocos miles de filas, MatTableDataSource es más sencillo y más rápido para el usuario. Con más, o si los datos cambian mucho, pagina en el servidor.


7. Diálogos y avisos

El servicio MatDialog

Un diálogo es un componente normal, que abres desde código:

const ref = this.dialogos.open(DialogoConfirmar, { data });
ref.afterClosed().subscribe((resultado) => { /* ... */ });

Tres piezas conectan a quien lo abre con el diálogo:

Pieza Dirección Para qué
data en open(), leído con MAT_DIALOG_DATA Hacia dentro Pasar datos al diálogo
MatDialogRef.close(valor) o [mat-dialog-close] Hacia fuera Devolver un resultado y cerrar
afterClosed() Hacia fuera Observable con ese resultado

Un diálogo de confirmación reutilizable tiene este aspecto:

// src/app/dialogos/dialogo-confirmar.ts
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import {
  MAT_DIALOG_DATA,
  MatDialogActions,
  MatDialogClose,
  MatDialogContent,
  MatDialogTitle,
} from '@angular/material/dialog';

export interface DatosConfirmar {
  titulo: string;
  mensaje: string;
}

@Component({
  selector: 'app-dialogo-confirmar',
  imports: [MatButton, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle],
  template: `
    <h2 mat-dialog-title>{{ datos.titulo }}</h2>
    <mat-dialog-content>{{ datos.mensaje }}</mat-dialog-content>
    <mat-dialog-actions align="end">
      <button matButton [mat-dialog-close]="false">Cancelar</button>
      <button matButton="filled" [mat-dialog-close]="true" cdkFocusInitial>Borrar</button>
    </mat-dialog-actions>
  `,
})
export class DialogoConfirmar {
  protected readonly datos = inject<DatosConfirmar>(MAT_DIALOG_DATA);
}

Diálogo de confirmación centrado sobre la tabla, que aparece oscurecida detrás

Figura 11. El diálogo pide confirmación antes de borrar.

Sin escribir nada más, ya tienes:

  • El foco queda atrapado dentro del diálogo, y vuelve al botón de origen al cerrar.
  • Escape cierra el diálogo, y afterClosed emite undefined.
  • Un clic en el fondo también lo cierra. Si no quieres que sea así, porque hay un formulario a medias, abre con disableClose: true.

cdkFocusInitial indica qué elemento recibe el foco al abrir. Lo hemos comprobado: al abrir, el foco está en «Borrar». En un diálogo que borra algo importante, suele ser más prudente poner el foco inicial en «Cancelar».

Aquí se ve por qué comprobamos el resultado con if (!confirmado): puede llegar false, si se pulsa Cancelar, o undefined, si se pulsa Escape.

// src/app/tabla-servidor/tabla-servidor.ts (fragmento)
protected borrar(empleado: Empleado) {
  const data: DatosConfirmar = {
    titulo: 'Borrar empleado',
    mensaje: `¿Seguro que quieres borrar a ${empleado.nombre} ${empleado.apellidos}?`,
  };
  this.dialogos
    .open(DialogoConfirmar, { data })
    .afterClosed()
    .subscribe((confirmado) => {
      if (!confirmado) {
        return;
      }
      this.http.delete(`${API}/empleados/${empleado.empId}`).subscribe({
        next: () => {
          this.avisos.open('Empleado borrado', undefined, { duration: 3000 });
          this.respuesta.reload();
        },
        error: (e: HttpErrorResponse) => this.avisos.open(e.error?.mensaje ?? 'No se pudo borrar', 'Cerrar'),
      });
    });
}

Si intentas borrar a Ana, la API responde 409 y el aviso muestra su mensaje: «No se puede borrar a un empleado que tiene subordinados». El error de servidor no rompe la pantalla: se lo contamos al usuario. Y tras borrar con éxito, reload() vuelve a pedir la página actual.

📘 Concepto: Las escrituras de la API exigen sesión. Para practicar, entramos como Ana al arrancar con provideAppInitializer, y un interceptor añade el token, como viste en el módulo 12. El ejemplo está a continuación. Una pantalla de inicio de sesión de verdad llega en el módulo 29.

// src/app/sesion.ts
import { HttpClient, HttpInterceptorFn } from '@angular/common/http';
import { inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API } from './api';

const token = signal<string | null>(null);

export const conToken: HttpInterceptorFn = (peticion, siguiente) => {
  const valor = token();
  return siguiente(
    valor ? peticion.clone({ setHeaders: { Authorization: `Bearer ${valor}` } }) : peticion,
  );
};

export async function entrarComoAna() {
  const http = inject(HttpClient);
  const respuesta = await firstValueFrom(
    http.post<{ token: string }>(`${API}/auth/login`, {
      email: 'ana.garcia@empresa.es',
      password: 'secreto',
    }),
  );
  token.set(respuesta.token);
}

El mismo diálogo sirve para crear y para editar: si recibe un empleado, edita. El truco está en que la lógica de red no va en el diálogo. El diálogo devuelve los datos, y quien lo abrió decide qué hacer con ellos. Así se puede reutilizar, y es más fácil de probar.

// src/app/modelos.ts
export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}

export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  telefono: string | null;
  fechaAlta: string;
  oficio: string;
  salario: number;
  comision: number | null;
  jefeId: number | null;
  deptId: number | null;
}

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}
// src/app/dialogos/dialogo-empleado.ts
import { httpResource } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { provideNativeDateAdapter } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import {
  MAT_DIALOG_DATA,
  MatDialogActions,
  MatDialogClose,
  MatDialogContent,
  MatDialogRef,
  MatDialogTitle,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatOption, MatSelect } from '@angular/material/select';
import { API } from '../api';
import { Departamento, Empleado, Pagina } from '../modelos';

export type DatosEmpleado = Pick<
  Empleado,
  'nombre' | 'apellidos' | 'email' | 'telefono' | 'oficio' | 'salario' | 'deptId' | 'fechaAlta'
>;

function aFechaLocal(iso?: string) {
  return iso ? new Date(`${iso}T00:00`) : new Date();
}

function aFechaIso(fecha: Date) {
  const mes = String(fecha.getMonth() + 1).padStart(2, '0');
  const dia = String(fecha.getDate()).padStart(2, '0');
  return `${fecha.getFullYear()}-${mes}-${dia}`;
}

@Component({
  selector: 'app-dialogo-empleado',
  imports: [
    MatButton,
    MatDatepickerModule,
    MatDialogActions,
    MatDialogClose,
    MatDialogContent,
    MatDialogTitle,
    MatFormFieldModule,
    MatInput,
    MatOption,
    MatSelect,
    ReactiveFormsModule,
  ],
  providers: [provideNativeDateAdapter()],
  templateUrl: './dialogo-empleado.html',
  styleUrl: './dialogo-empleado.css',
})
export class DialogoEmpleado {
  private readonly ref = inject(MatDialogRef<DialogoEmpleado, DatosEmpleado>);
  private readonly fb = inject(FormBuilder).nonNullable;

  protected readonly empleado = inject<Empleado | null>(MAT_DIALOG_DATA, { optional: true });
  protected readonly departamentos = httpResource<Pagina<Departamento>>(
    () => `${API}/departamentos`,
  );

  protected readonly formulario = this.fb.group({
    nombre: [this.empleado?.nombre ?? '', Validators.required],
    apellidos: [this.empleado?.apellidos ?? '', Validators.required],
    email: [this.empleado?.email ?? '', [Validators.required, Validators.email]],
    telefono: [this.empleado?.telefono ?? null],
    oficio: [this.empleado?.oficio ?? '', Validators.required],
    salario: [this.empleado?.salario ?? 1500, [Validators.required, Validators.min(1)]],
    deptId: [this.empleado?.deptId ?? null],
    fechaAlta: [aFechaLocal(this.empleado?.fechaAlta), Validators.required],
  });

  protected guardar() {
    if (this.formulario.invalid) {
      this.formulario.markAllAsTouched();
      return;
    }
    const valor = this.formulario.getRawValue();
    this.ref.close({ ...valor, fechaAlta: aFechaIso(valor.fechaAlta) });
  }
}
<!-- src/app/dialogos/dialogo-empleado.html -->
<h2 mat-dialog-title>{{ empleado ? 'Editar empleado' : 'Nuevo empleado' }}</h2>

<form [formGroup]="formulario" (ngSubmit)="guardar()">
  <mat-dialog-content>
    <div class="fila">
      <mat-form-field>
        <mat-label>Nombre</mat-label>
        <input matInput formControlName="nombre" cdkFocusInitial />
        @if (formulario.controls.nombre.hasError('required')) {
          <mat-error>El nombre es obligatorio</mat-error>
        }
      </mat-form-field>

      <mat-form-field>
        <mat-label>Apellidos</mat-label>
        <input matInput formControlName="apellidos" />
        @if (formulario.controls.apellidos.hasError('required')) {
          <mat-error>Los apellidos son obligatorios</mat-error>
        }
      </mat-form-field>
    </div>

    <mat-form-field class="ancho">
      <mat-label>Correo</mat-label>
      <input matInput type="email" formControlName="email" />
      <mat-hint>Usa el correo de la empresa</mat-hint>
      @if (formulario.controls.email.hasError('email')) {
        <mat-error>El correo no es válido</mat-error>
      } @else if (formulario.controls.email.hasError('required')) {
        <mat-error>El correo es obligatorio</mat-error>
      }
    </mat-form-field>

    <div class="fila">
      <mat-form-field>
        <mat-label>Teléfono</mat-label>
        <input matInput type="tel" formControlName="telefono" />
      </mat-form-field>

      <mat-form-field>
        <mat-label>Oficio</mat-label>
        <input matInput formControlName="oficio" />
      </mat-form-field>
    </div>

    <div class="fila">
      <mat-form-field>
        <mat-label>Salario</mat-label>
        <input matInput type="number" formControlName="salario" />
        <span matTextSuffix>€</span>
      </mat-form-field>

      <mat-form-field>
        <mat-label>Departamento</mat-label>
        <mat-select formControlName="deptId">
          <mat-option [value]="null">Sin departamento</mat-option>
          @for (d of departamentos.value()?.contenido; track d.deptId) {
            <mat-option [value]="d.deptId">{{ d.nombre }}</mat-option>
          }
        </mat-select>
      </mat-form-field>
    </div>

    <mat-form-field class="ancho">
      <mat-label>Fecha de alta</mat-label>
      <input matInput [matDatepicker]="selector" formControlName="fechaAlta" />
      <mat-datepicker-toggle matIconSuffix [for]="selector" />
      <mat-datepicker #selector />
    </mat-form-field>
  </mat-dialog-content>

  <mat-dialog-actions align="end">
    <button matButton type="button" mat-dialog-close>Cancelar</button>
    <button matButton="filled" type="submit">Guardar</button>
  </mat-dialog-actions>
</form>
/* src/app/dialogos/dialogo-empleado.css */
.fila {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.ancho {
  width: 100%;
}

mat-dialog-content {
  padding-top: 8px;
}

Diálogo Editar empleado con los datos de Tomás: nombre, apellidos, correo, oficio, salario y fecha

Figura 12. Diálogo de edición. Tomás, el becario, no tiene teléfono ni departamento.

// src/app/tabla-servidor/tabla-servidor.ts (fragmento)
protected crear() {
  this.dialogos
    .open(DialogoEmpleado, { width: '480px' })
    .afterClosed()
    .subscribe((datos) => {
      if (!datos) {
        return;
      }
      this.http.post<Empleado>(`${API}/empleados`, datos).subscribe({
        next: (creado) => {
          this.avisos.open(`Creado: ${creado.nombre}`, undefined, { duration: 3000 });
          this.respuesta.reload();
        },
        error: (e: HttpErrorResponse) => this.avisos.open(e.error?.mensaje ?? 'No se pudo crear', 'Cerrar'),
      });
    });
}

protected editar(empleado: Empleado) {
  this.dialogos
    .open(DialogoEmpleado, { width: '480px', data: empleado })
    .afterClosed()
    .subscribe((datos) => {
      if (!datos) {
        return;
      }
      this.http.put<Empleado>(`${API}/empleados/${empleado.empId}`, { ...empleado, ...datos }).subscribe({
        next: () => this.respuesta.reload(),
        error: (e: HttpErrorResponse) => this.avisos.open(e.error?.mensaje ?? 'No se pudo guardar', 'Cerrar'),
      });
    });
}

Este ejemplo nos enseñó dos cosas al probarlo con datos reales:

⚠️ Importante: La primera versión exigía teléfono y departamento. Al editar a Tomás, pulsar «Guardar» no hacía nada: ni petición, ni cierre, ni error visible. En la API, telefono y deptId pueden ser null, y la validación fallaba en campos que nadie había tocado. Cuando un botón «no hace nada», comprueba qué control es inválido con formulario.controls, y modela los tipos según los datos reales: por eso aquí son string | null y number | null.

La otra: provideNativeDateAdapter() está también en providers del propio diálogo. No hace falta, porque ya está en app.config.ts. Lo dejamos para que veas que puedes declararlo en el componente que lo necesita, algo útil en una ruta con carga perezosa. Ten en cuenta que, si no pasas injector ni viewContainerRef en la configuración, el diálogo ve los proveedores de la aplicación, no los del componente que lo abre.

💡 Consejo: El tipo MatDialogRef<DialogoEmpleado, DatosEmpleado> documenta qué devuelve el diálogo, pero quien lo abre no lo sabe: para open, el resultado es any por defecto. Para tenerlo tipado, indícalo al llamar: open<DialogoEmpleado, Empleado, DatosEmpleado>(...).


8. CDK: overlay

El problema

Un menú desplegable dentro de un contenedor con overflow: hidden queda recortado. Y dentro de un elemento con transform, su position: fixed deja de referirse a la ventana. Da igual lo alto que pongas el z-index: el problema es dónde está en el DOM.

La solución del CDK es sencilla: el contenido flotante no se pinta donde lo declaras, sino en un contenedor aparte, en body, por encima de todo. Después, se coloca con coordenadas. Es lo que usan por dentro MatDialog, mat-menu, mat-select, matTooltip y el selector de fechas.

📘 Concepto: Un overlay es una capa flotante gestionada por el CDK. Tú decides el contenido (un portal), dónde colocarla (estrategia de posición) y qué hacer si la página se desplaza (estrategia de scroll).

Estructura del DOM: body contiene app-root y el contenedor de overlays, con la capa de fondo, la caja de posición y el panel con tu contenido. A la derecha, las opciones positionStrategy, scrollStrategy y hasBackdrop

Figura 13. El contenido vive fuera de app-root, de modo que ningún overflow de tu aplicación puede recortarlo.

Una ficha conectada a un botón

Queremos una ficha flotante, pegada a un botón, que se cierre con Escape o al pulsar fuera:

// src/app/superpuesta/superpuesta.ts
import {
  createBlockScrollStrategy,
  createFlexibleConnectedPositionStrategy,
  createGlobalPositionStrategy,
  createOverlayRef,
  createRepositionScrollStrategy,
  OverlayRef,
} from '@angular/cdk/overlay';
import { ComponentPortal, TemplatePortal } from '@angular/cdk/portal';
import {
  Component,
  DestroyRef,
  ElementRef,
  inject,
  Injector,
  input,
  TemplateRef,
  viewChild,
  ViewContainerRef,
} from '@angular/core';
import { MatButton } from '@angular/material/button';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-ficha',
  template: `
    <strong>{{ empleado().nombre }} {{ empleado().apellidos }}</strong>
    <div>{{ empleado().oficio }}</div>
    <div>{{ empleado().email }}</div>
  `,
  styles: `:host { display: block; padding: 12px 16px; width: 260px; }`,
  host: { class: 'ficha-superpuesta', role: 'dialog', 'aria-label': 'Ficha del empleado' },
})
export class Ficha {
  readonly empleado = input.required<Empleado>();
}

@Component({
  selector: 'app-superpuesta',
  imports: [MatButton],
  template: `
    <h1>Overlay</h1>
    <p>
      <button #origen matButton="outlined" (click)="abrirFicha()">Ver ficha de Ana</button>
      <button matButton="outlined" (click)="abrirAviso()">Aviso centrado</button>
    </p>

    <ng-template #aviso>
      <div class="ficha-superpuesta" style="padding: 24px">
        <p>Este aviso está en el centro de la ventana y bloquea el desplazamiento.</p>
        <button matButton (click)="cerrarAviso()">Entendido</button>
      </div>
    </ng-template>
  `,
})
export class Superpuesta {
  private readonly injector = inject(Injector);
  private readonly contenedorVista = inject(ViewContainerRef);
  private readonly origen = viewChild.required('origen', { read: ElementRef });
  private readonly plantillaAviso = viewChild.required('aviso', { read: TemplateRef });

  private capaFicha?: OverlayRef;
  private capaAviso?: OverlayRef;

  private readonly ana: Empleado = {
    empId: 100,
    nombre: 'Ana',
    apellidos: 'García López',
    email: 'ana.garcia@empresa.es',
    telefono: '600100100',
    fechaAlta: '2010-01-15',
    oficio: 'DIRECTOR GENERAL',
    salario: 6500,
    comision: null,
    jefeId: null,
    deptId: 10,
  };

  constructor() {
    inject(DestroyRef).onDestroy(() => {
      this.capaFicha?.dispose();
      this.capaAviso?.dispose();
    });
  }

  protected abrirFicha() {
    if (this.capaFicha?.hasAttached()) {
      return;
    }

    const posicion = createFlexibleConnectedPositionStrategy(this.injector, this.origen())
      .withPositions([
        { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 },
        { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 },
      ])
      .withPush(true);

    const capa = createOverlayRef(this.injector, {
      positionStrategy: posicion,
      scrollStrategy: createRepositionScrollStrategy(this.injector),
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-transparent-backdrop',
    });

    const referencia = capa.attach(new ComponentPortal(Ficha));
    referencia.setInput('empleado', this.ana);

    capa.backdropClick().subscribe(() => capa.dispose());
    capa.keydownEvents().subscribe((evento) => {
      if (evento.key === 'Escape') {
        capa.dispose();
      }
    });
    this.capaFicha = capa;
  }

  protected abrirAviso() {
    this.capaAviso = createOverlayRef(this.injector, {
      positionStrategy: createGlobalPositionStrategy(this.injector).centerHorizontally().centerVertically(),
      scrollStrategy: createBlockScrollStrategy(this.injector),
      hasBackdrop: true,
    });
    this.capaAviso.attach(new TemplatePortal(this.plantillaAviso(), this.contenedorVista));
  }

  protected cerrarAviso() {
    this.capaAviso?.dispose();
  }
}

Y en el CSS global, porque la ficha se pinta fuera de tu componente:

/* src/styles.scss */
.ficha-superpuesta {
  background: var(--mat-sys-surface-container-high);
  color: var(--mat-sys-on-surface);
  border-radius: var(--mat-sys-corner-medium);
  box-shadow: var(--mat-sys-level3);
}

Ficha flotante con los datos de Ana bajo el botón Ver ficha de Ana

Figura 14. La ficha se abre debajo del botón. Si no cupiera, usaría el segundo plan y se abriría encima.

Aviso centrado en la ventana sobre un fondo oscurecido

Figura 15. Con una posición global centrada y la capa de fondo por defecto.

Los planes de posición

withPositions recibe una lista de planes, por orden de preferencia. Cada uno une un punto del origen con un punto de la capa:

{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }

Se lee así: «la esquina superior izquierda de la capa va en la esquina inferior izquierda del botón, 8 px más abajo». El CDK prueba los planes en orden y se queda con el primero que cabe en pantalla. Por eso definimos un segundo plan, encima del botón, para cuando esté al final de la ventana. withPush(true) añade un último recurso: empujar la capa para que no se salga.

💡 Consejo: Usa start y end, no left y right: se invierten solos en idiomas que se escriben de derecha a izquierda.

Estrategias de scroll

Estrategia Se crea con Qué hace al desplazar Uso típico
Reposicionar createRepositionScrollStrategy Mueve la capa con su origen Desplegables, fichas
Bloquear createBlockScrollStrategy Impide desplazar la página Diálogos
Cerrar createCloseScrollStrategy Cierra la capa Menús contextuales
Nada createNoopScrollStrategy Ignora el desplazamiento Avisos fijos

Todas estas funciones reciben un Injector, y por eso pedimos uno con inject(Injector). En código más antiguo verás el servicio Overlay, con llamadas como overlay.create(...) y overlay.position(). Sigue existiendo y funciona, pero las funciones create* no obligan a inyectar nada más, y permiten eliminar del paquete final lo que no uses.

Cierre y limpieza

  • dispose() destruye la capa, mientras que detach() solo quita el contenido para volver a usarla. Si no vas a reutilizarla, dispose(). Olvidarlo deja nodos en el DOM. Por eso cerramos en DestroyRef: si el usuario navega con la capa abierta, no debe quedar flotando.
  • backdropClick() requiere hasBackdrop: true. La clase cdk-overlay-transparent-backdrop hace que el fondo capture clics sin oscurecer nada.
  • keydownEvents() recibe las teclas de esta capa. outsidePointerEvents() detecta clics fuera sin necesidad de fondo.

Lo comprobamos: al pulsar Escape o fuera de la ficha, app-ficha desaparece del DOM.

¿Qué hay dentro, en realidad? Si inspeccionas la ficha abierta, el contenedor cuelga de body, y dentro hay un elemento con popover="manual". Es un atributo nativo de HTML que coloca el contenido en la capa superior del navegador, por encima incluso de elementos con un z-index muy alto. El CDK lo usa por defecto cuando el navegador lo admite, y puedes desactivarlo con usePopover: false en OverlayConfig.

Cierre al navegar 🔄 v22.0

🔄 Cambia en Angular 22.0. disposeOnNavigation admite, además de true y false, los valores 'url-change' y 'pop-state'. Si usas la LTS: en CDK 21 solo es booleano, y true cierra la capa con los botones Atrás y Adelante. Para cerrarla en cualquier cambio de URL, suscríbete a Router.events y llama a dispose().

La opción disposeOnNavigation de OverlayConfig cierra la capa cuando el usuario navega:

const capa = createOverlayRef(this.injector, {
  disposeOnNavigation: 'url-change', // cierra en cualquier cambio de URL
});
Valor Efecto
false No hace nada al navegar
true o 'pop-state' Cierra con los botones Atrás y Adelante del navegador
'url-change' Cierra con cualquier cambio de URL

Los tipos del CDK avisan de que 'url-change' puede cerrar capas abiertas durante un resolver de una ruta.

La directiva cdkConnectedOverlay

Si prefieres no escribir código, también puedes declarar un overlay en la plantilla:

<button cdkOverlayOrigin #disparador="cdkOverlayOrigin" (click)="abierto.set(!abierto())">
  Opciones
</button>

<ng-template
  cdkConnectedOverlay
  [cdkConnectedOverlayOrigin]="disparador"
  [cdkConnectedOverlayOpen]="abierto()"
>
  <div class="ficha-superpuesta">Contenido flotante</div>
</ng-template>

Se importan CdkConnectedOverlay y CdkOverlayOrigin. Es muy cómodo cuando el estado abierto o cerrado ya es una señal.


9. CDK: portal

Ya has usado portales sin darte cuenta: new ComponentPortal(Ficha) y new TemplatePortal(...).

📘 Concepto: Un portal es un trozo de interfaz que todavía no se ha colocado en ninguna parte: un valor que puedes guardar en una señal o pasar a otra función. Un outlet es el sitio donde se pinta.

Portal Representa Se crea con
ComponentPortal Un componente La clase del componente
TemplatePortal Un ng-template La plantilla y un ViewContainerRef
DomPortal Un elemento del DOM que ya existe Un ElementRef

El overlay es un outlet, y hay otro para tus plantillas: cdkPortalOutlet. Un uso típico es un panel lateral cuyo contenido cambia según la pantalla, o una barra de acciones que cada ruta rellena a su manera.

// src/app/portales/portales.ts
import { CdkPortalOutlet, ComponentPortal, DomPortal, Portal, TemplatePortal } from '@angular/cdk/portal';
import {
  Component,
  ElementRef,
  inject,
  signal,
  TemplateRef,
  viewChild,
  ViewContainerRef,
} from '@angular/core';
import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle';

@Component({
  selector: 'app-ayuda',
  template: `<p>Soy un componente: <strong>Ayuda</strong>.</p>`,
})
export class Ayuda {}

@Component({
  selector: 'app-portales',
  imports: [CdkPortalOutlet, MatButtonToggle, MatButtonToggleGroup],
  template: `
    <h1>Portales</h1>

    <mat-button-toggle-group aria-label="Contenido del panel" (change)="mostrar($event.value)">
      <mat-button-toggle value="componente">Componente</mat-button-toggle>
      <mat-button-toggle value="plantilla">Plantilla</mat-button-toggle>
      <mat-button-toggle value="dom">Elemento DOM</mat-button-toggle>
      <mat-button-toggle value="nada">Vaciar</mat-button-toggle>
    </mat-button-toggle-group>

    <section class="panel" aria-live="polite">
      <ng-template [cdkPortalOutlet]="actual()" />
    </section>

    <ng-template #plantilla>
      <p>Soy una plantilla. Veo datos de mi componente: {{ visitas() }} visitas.</p>
    </ng-template>

    <div #elemento class="suelto">Soy un elemento que ya estaba en la página.</div>
  `,
  styles: `
    .panel { margin: 16px 0; padding: 16px; min-height: 48px; max-width: 480px;
             border: 1px dashed var(--mat-sys-outline); border-radius: 8px; }
    .suelto { color: var(--mat-sys-tertiary); }
  `,
})
export class Portales {
  private readonly contenedorVista = inject(ViewContainerRef);
  private readonly plantilla = viewChild.required('plantilla', { read: TemplateRef });
  private readonly elemento = viewChild.required('elemento', { read: ElementRef });

  protected readonly visitas = signal(0);
  protected readonly actual = signal<Portal<unknown> | null>(null);

  protected mostrar(tipo: string) {
    this.visitas.update((n) => n + 1);
    switch (tipo) {
      case 'componente':
        this.actual.set(new ComponentPortal(Ayuda));
        break;
      case 'plantilla':
        this.actual.set(new TemplatePortal(this.plantilla(), this.contenedorVista));
        break;
      case 'dom':
        this.actual.set(new DomPortal(this.elemento()));
        break;
      default:
        this.actual.set(null);
    }
  }
}

Grupo de botones con cuatro opciones, y un panel punteado que muestra el contenido de la plantilla

Figura 16. La plantilla sigue viendo las señales del componente en el que se declaró.

Al probarlo, se ve bien la diferencia entre los tres:

  • Plantilla muestra 2 visitas: un TemplatePortal conserva el contexto del componente donde se declaró, aunque se pinte en otra parte. Se actualiza con la señal.
  • Elemento DOM mueve el elemento: desaparece de su sitio original y aparece en el panel. Al elegir «Vaciar», vuelve donde estaba. Eso es lo que distingue a DomPortal, porque ni clona ni crea nada.
  • Componente crea una instancia nueva cada vez y la destruye al sustituirla, de modo que su estado interno se pierde.

💡 Consejo: @angular/core ofrece ngComponentOutlet y ngTemplateOutlet, que viste en los módulos 03 y 04. Son suficientes para pintar algo dinámico en tu propia plantilla. Los portales valen la pena para transportar contenido entre sitios lejanos de la aplicación, o hacia un overlay.


10. CDK: arrastrar y soltar

Lista ordenable y tablero

@angular/cdk/drag-drop se basa en tres directivas:

Directiva Función
cdkDrag Hace arrastrable un elemento
cdkDropList Crea una zona que recibe y ordena elementos
cdkDropListGroup Conecta entre sí todas las listas que contiene

El CDK mueve los elementos solo visualmente mientras arrastras. Cuando sueltas, emite cdkDropListDropped y eres tú quien debe actualizar los datos. Si no lo haces, el elemento vuelve a su sitio. Es la idea central de esta pieza.

Vamos a crear un tablero de pedidos. Arrastrar un pedido de una columna a otra cambia su estado en la API.

// src/app/modelos.ts (se añade)
export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';

export interface Pedido {
  pedidoId: number;
  fechaPedido: string;
  estado: EstadoPedido;
  total: number;
  cliente: string;
}
// src/app/tablero/tablero.ts
import {
  CdkDrag,
  CdkDragDrop,
  CdkDragPlaceholder,
  CdkDropList,
  CdkDropListGroup,
  moveItemInArray,
  transferArrayItem,
} from '@angular/cdk/drag-drop';
import { CurrencyPipe } from '@angular/common';
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, effect, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { API } from '../api';
import { EstadoPedido, Pagina, Pedido } from '../modelos';

const ESTADOS: EstadoPedido[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO'];
type Columnas = Record<EstadoPedido, Pedido[]>;

@Component({
  selector: 'app-tablero',
  imports: [CdkDrag, CdkDragPlaceholder, CdkDropList, CdkDropListGroup, CurrencyPipe],
  templateUrl: './tablero.html',
  styleUrl: './tablero.css',
})
export class Tablero {
  private readonly http = inject(HttpClient);
  private readonly avisos = inject(MatSnackBar);
  private readonly respuesta = httpResource<Pagina<Pedido>>(() => `${API}/pedidos?tamano=100`);

  protected readonly estados = ESTADOS;
  protected readonly columnas = signal<Columnas>({
    PENDIENTE: [],
    ENVIADO: [],
    ENTREGADO: [],
    CANCELADO: [],
  });

  constructor() {
    effect(() => {
      if (!this.respuesta.hasValue()) {
        return;
      }
      const pedidos = this.respuesta.value().contenido;
      this.columnas.set({
        PENDIENTE: pedidos.filter((p) => p.estado === 'PENDIENTE'),
        ENVIADO: pedidos.filter((p) => p.estado === 'ENVIADO').slice(0, 4),
        ENTREGADO: pedidos.filter((p) => p.estado === 'ENTREGADO').slice(0, 4),
        CANCELADO: [],
      });
    });
  }

  protected soltar(evento: CdkDragDrop<EstadoPedido, EstadoPedido, Pedido>) {
    const origen = evento.previousContainer.data;
    const destino = evento.container.data;
    const anterior = this.columnas();

    const siguiente: Columnas = {
      ...anterior,
      [origen]: [...anterior[origen]],
      [destino]: [...anterior[destino]],
    };

    if (origen === destino) {
      moveItemInArray(siguiente[destino], evento.previousIndex, evento.currentIndex);
      this.columnas.set(siguiente);
      return;
    }

    transferArrayItem(siguiente[origen], siguiente[destino], evento.previousIndex, evento.currentIndex);
    siguiente[destino][evento.currentIndex] = { ...evento.item.data, estado: destino };
    this.columnas.set(siguiente);

    this.http.patch(`${API}/pedidos/${evento.item.data.pedidoId}`, { estado: destino }).subscribe({
      error: () => {
        this.columnas.set(anterior);
        this.avisos.open('No se pudo cambiar el estado', 'Cerrar', { duration: 4000 });
      },
    });
  }
}
<!-- src/app/tablero/tablero.html -->
<h1>Pedidos</h1>

<div class="tablero" cdkDropListGroup>
  @for (estado of estados; track estado) {
    <section class="columna">
      <h2>{{ estado }} ({{ columnas()[estado].length }})</h2>

      <div
        class="lista"
        cdkDropList
        [cdkDropListData]="estado"
        (cdkDropListDropped)="soltar($event)"
      >
        @for (pedido of columnas()[estado]; track pedido.pedidoId) {
          <article class="tarjeta" cdkDrag [cdkDragData]="pedido">
            <strong>#{{ pedido.pedidoId }}</strong> {{ pedido.cliente }}
            <div class="importe">{{ pedido.total | currency: 'EUR' }}</div>
            <div class="hueco" *cdkDragPlaceholder></div>
          </article>
        }
      </div>
    </section>
  }
</div>
/* src/app/tablero/tablero.css */
.tablero {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  align-items: start;
}

.columna {
  background: var(--mat-sys-surface-container);
  border-radius: var(--mat-sys-corner-medium);
  padding: 8px 12px;
}

.columna h2 {
  font: var(--mat-sys-title-small);
}

.lista {
  min-height: 80px;
}

.tarjeta {
  background: var(--mat-sys-surface);
  border-radius: 8px;
  box-shadow: var(--mat-sys-level1);
  padding: 8px 12px;
  margin-bottom: 8px;
  cursor: move;
}

.importe {
  color: var(--mat-sys-primary);
}

.hueco {
  height: 56px;
  margin-bottom: 8px;
  border: 2px dashed var(--mat-sys-outline-variant);
  border-radius: 8px;
}

.cdk-drag-preview {
  box-shadow: var(--mat-sys-level3);
}

.cdk-drag-animating {
  transition: transform 200ms ease;
}

.lista.cdk-drop-list-dragging .tarjeta:not(.cdk-drag-placeholder) {
  transition: transform 200ms ease;
}

Tablero con tres columnas de pedidos. El pedido 5022 se está arrastrando hacia la columna ENVIADO, donde se ve un hueco punteado

Figura 17. Captura tomada a mitad del arrastre. La tarjeta flotante es la vista previa, y el hueco punteado es el marcador.

Cómo se comporta en la prueba real:

[ 'PENDIENTE (3)', 'ENVIADO (2)', 'ENTREGADO (4)' ]
[ 'PENDIENTE (2)', 'ENVIADO (3)', 'ENTREGADO (4)' ]
PATCH /api/pedidos/5022

Veamos los puntos clave:

  • moveItemInArray y transferArrayItem mutan los arrays. Son funciones del CDK anteriores a las señales. Si las aplicas directamente al valor de una señal, la referencia no cambia y la señal no avisa. Por eso primero hacemos una copia de las dos columnas afectadas (la inmutabilidad del módulo 05) y llamamos a set al final.
  • Actualización optimista. La tarjeta cambia de columna al instante, y luego va la petición. Si falla, se restaura anterior, y el usuario recibe un aviso. Guardar esa referencia es gratis, porque nunca mutamos el estado anterior.
  • cdkDropListData es un dato tuyo que el evento te devuelve en container.data. Aquí es el estado, y con él sabemos de dónde venía y a dónde va. El evento es genérico: CdkDragDrop<EstadoPedido, EstadoPedido, Pedido>.
  • cdkDropListGroup conecta las listas. Sin él, tendrías que enlazarlas una a una con [cdkDropListConnectedTo].
  • El ejemplo no llena la columna con todos los pedidos entregados solo para que la captura sea legible.

Las clases CSS

Mientras arrastras, el CDK mueve nodos y añade clases que puedes aprovechar:

Clase Elemento
.cdk-drag-preview La copia que sigue al ratón
.cdk-drag-placeholder El hueco que indica dónde caerá
.cdk-drag-animating El elemento mientras se recoloca al soltar
.cdk-drop-list-dragging La lista mientras se arrastra algo sobre ella

⚠️ Importante: La vista previa se añade fuera de tu componente, en la capa superior. Por eso, en nuestro CSS la clase .cdk-drag-preview no incluye a .tarjeta como padre. La encapsulación afecta a los estilos: la tarjeta copiada mantiene los atributos de tu componente, por lo que siguen aplicándose, pero la selección por ancestros deja de funcionar. Si la vista previa se ve sin estilo, comprueba si el selector dependía de un padre como .lista .tarjeta.

Otras opciones

Directiva o entrada Efecto
cdkDragHandle Solo se arrastra desde esa asa
cdkDropListLockAxis="y" Restringe el movimiento a un eje
cdkDragBoundary=".lista" No se puede sacar de ese contenedor
cdkDragStartDelay Espera antes de empezar, útil en móviles
[cdkDragDisabled] Desactiva el arrastre de un elemento
*cdkDragPreview Sustituye la vista previa
*cdkDragPlaceholder Sustituye el hueco
cdkDropListEnterPredicate Decide si una lista admite el elemento

⚠️ Importante: Arrastrar con ratón o con el dedo no está al alcance de todo el mundo. Ofrece siempre una forma alternativa: un mat-select en cada tarjeta para cambiar el estado, o botones de subir y bajar. Lo verás en el módulo 21.


11. CDK: virtual scroll

100 000 filas en el DOM

Con @for, 100 000 filas son 100 000 elementos, y el navegador tiene que crearlos, medirlos y pintarlos. La página se congela. Paginar es una solución, pero a veces quieres una lista continua.

El virtual scroll aprovecha un hecho sencillo: el usuario solo ve unas pocas filas a la vez. Basta crear esas, y dejar un hueco del tamaño total para que la barra de desplazamiento parezca la de una lista completa.

A la izquierda, la lista completa de 100 000 movimientos, con solo un tramo central creado en el DOM. A la derecha, el viewport con cuatro filas visibles y dos de búfer

Figura 18. Solo existen las filas visibles y unas pocas de reserva. El resto es espacio vacío.

// src/app/virtual/virtual.ts
import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { CurrencyPipe } from '@angular/common';
import { Component, signal, viewChild } from '@angular/core';
import { MatButton } from '@angular/material/button';

interface Movimiento {
  id: number;
  concepto: string;
  importe: number;
}

const CONCEPTOS = ['Nómina', 'Factura', 'Devolución', 'Dietas', 'Licencias'];

@Component({
  selector: 'app-virtual',
  imports: [CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport, CurrencyPipe, MatButton],
  templateUrl: './virtual.html',
  styleUrl: './virtual.css',
})
export class Virtual {
  private readonly ventana = viewChild.required(CdkVirtualScrollViewport);

  protected readonly movimientos = signal<Movimiento[]>(
    Array.from({ length: 100_000 }, (_, i) => ({
      id: i + 1,
      concepto: CONCEPTOS[i % CONCEPTOS.length],
      importe: ((i * 37) % 2000) + 10,
    })),
  );

  protected readonly porId = (_: number, m: Movimiento) => m.id;

  protected irA(indice: number) {
    this.ventana().scrollToIndex(indice);
  }
}
<!-- src/app/virtual/virtual.html -->
<h1>Movimientos ({{ movimientos().length }})</h1>

<button matButton="outlined" (click)="irA(50000)">Ir al 50 001</button>

<cdk-virtual-scroll-viewport class="ventana" itemSize="48" minBufferPx="200" maxBufferPx="400">
  <div
    class="fila"
    *cdkVirtualFor="let m of movimientos(); trackBy: porId; templateCacheSize: 20"
  >
    <span>#{{ m.id }}</span>
    <span>{{ m.concepto }}</span>
    <span>{{ m.importe | currency: 'EUR' }}</span>
  </div>
</cdk-virtual-scroll-viewport>
/* src/app/virtual/virtual.css */
.ventana {
  height: 340px;
  max-width: 520px;
  margin-top: 12px;
  border: 1px solid var(--mat-sys-outline-variant);
  border-radius: 8px;
}

.fila {
  height: 48px;
  box-sizing: border-box;
  display: grid;
  grid-template-columns: 90px 1fr 110px;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid var(--mat-sys-outline-variant);
}

Lista de movimientos en la fila 50 001, con siete filas visibles

Figura 19. Tras saltar a la fila 50 001, la lista sigue yendo igual de rápido.

Medido en el navegador:

filas en el DOM al abrir: 16          altura de desplazamiento: 4800000 px
filas en el DOM tras saltar: 21       primera fila creada: #49996

De las 100 000 filas, solo existen entre 16 y 21. La altura es exactamente 100 000 × 48.

⚠️ Importante: itemSize debe coincidir exactamente con la altura real de la fila, incluidos bordes y márgenes. Si las filas miden 49 px y declaras 48, la barra de desplazamiento y las filas se desincronizan poco a poco. Por eso usamos box-sizing: border-box con height: 48px, y el borde queda dentro.

Algunos detalles:

  • *cdkVirtualFor es una directiva estructural, sintaxis antigua (*), porque @for no sabe de virtualización. Es uno de los pocos sitios donde verás esta sintaxis en código nuevo.
  • trackBy es una función, no una expresión como el track de @for.
  • La altura del viewport debe venir de tu CSS. Sin ella no sabe cuántas filas caben.
  • minBufferPx y maxBufferPx fijan cuánto se pinta fuera de la vista. Con más búfer se ven menos huecos en un desplazamiento rápido, a cambio de más nodos.
  • templateCacheSize reutiliza las filas que salen de la vista, para no tener que crearlas y destruirlas. Por tanto, una fila no debe guardar estado propio: si tiene un input sin enlazar a tus datos, reaparecerá con ese valor en otra fila.
Si… Usa
Todas las filas miden lo mismo cdk-virtual-scroll-viewport con itemSize
El usuario quiere ir a una página concreta Paginación
Tienes pocas filas (unos centenares) @for: no necesitas nada más
Debe funcionar Ctrl+F en toda la lista Paginación o @for: lo que no existe no se encuentra

12. Otras piezas del CDK

No hace falta usarlas todas, pero conviene saber que existen, antes de escribir tu versión:

Punto de entrada Qué aporta Ejemplo
@angular/cdk/layout Observar tamaños de pantalla BreakpointObserver, usado en el menú lateral
@angular/cdk/clipboard Copiar al portapapeles Clipboard.copy(texto), directiva cdkCopyToClipboard
@angular/cdk/collections Modelos de datos SelectionModel, usado en el ejercicio 3
@angular/cdk/a11y Foco y anuncios FocusTrap, LiveAnnouncer, ver módulo 21
@angular/cdk/text-field Áreas de texto cdkTextareaAutosize
@angular/cdk/stepper, tree, listbox, menu, accordion Componentes sin estilos Base para los tuyos
@angular/cdk/dialog Diálogos sin diseño Dialog, la base de MatDialog
@angular/cdk/table Tablas sin diseño cdk-table, la base de mat-table

Diferencias entre Material 21 y 22 🔄 v22.0

🔄 Cambia en Angular 22.0. Las API públicas de Material y CDK casi no cambian entre 21.2.14 y 22.2.5. Si usas la LTS: todo lo explicado sin marca funciona igual, y solo te faltan los pocos símbolos de la tabla siguiente, que este curso no necesita, salvo el valor de disposeOnNavigation ya marcado en el apartado 8.

Estas son todas las diferencias entre las exportaciones públicas de ambas versiones, sin contar las de pruebas:

Paquete Nuevo en 22 Retirado en 22
cdk/overlay OverlayDisposeOnNavigation  
cdk/scrolling ScrollDispatcherTarget  
cdk/stepper StepControl  
material/badge MAT_BADGE_CONFIG, MatBadgeConfig  
cdk/a11y   CDK_DESCRIBEDBY_HOST_ATTRIBUTE, CDK_DESCRIBEDBY_ID_PREFIX, MESSAGES_CONTAINER_ID
cdk/menu   ContextMenuTracker
material/list   MatListOptionCheckboxPosition
material/sort   ArrowViewState, ArrowViewStateTransition

13. Probar con harnesses

Un componente de Material tiene mucho HTML interno, y puede cambiar entre versiones. Si tu prueba busca .mat-sort-header-container, se romperá en cualquier actualización. Los harnesses resuelven esto.

📘 Concepto: Un harness es un objeto de prueba que expone las acciones de un componente en lenguaje de usuario, como getHarness, click() o getOptions(), y oculta su HTML. Material publica uno por componente, en los puntos de entrada .../testing.

Las pruebas se explican a fondo en el módulo 26. Este es un ejemplo real, con Vitest. El ejemplo usa una tabla mínima, porque la de la aplicación depende de la API:

// src/app/tabla-pequena.spec.ts
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSort, MatSortHeader, Sort } from '@angular/material/sort';
import { MatSortHarness } from '@angular/material/sort/testing';
import { MatTableModule } from '@angular/material/table';
import { MatTableHarness } from '@angular/material/table/testing';
import { beforeEach, describe, expect, it } from 'vitest';

@Component({
  imports: [MatSort, MatSortHeader, MatTableModule],
  template: `
    <table mat-table [dataSource]="filas()" matSort (matSortChange)="ordenar($event)">
      <ng-container matColumnDef="nombre">
        <th mat-header-cell *matHeaderCellDef mat-sort-header>Nombre</th>
        <td mat-cell *matCellDef="let e">{{ e.nombre }}</td>
      </ng-container>
      <tr mat-header-row *matHeaderRowDef="['nombre']"></tr>
      <tr mat-row *matRowDef="let fila; columns: ['nombre']"></tr>
    </table>
  `,
})
class TablaPequena {
  readonly filas = signal([{ nombre: 'Luis' }, { nombre: 'Ana' }]);

  ordenar(orden: Sort) {
    const signo = orden.direction === 'desc' ? -1 : 1;
    this.filas.update((lista) =>
      [...lista].sort((a, b) => signo * a.nombre.localeCompare(b.nombre)),
    );
  }
}

describe('TablaPequena', () => {
  let cargador: HarnessLoader;

  beforeEach(() => {
    const fixture = TestBed.createComponent(TablaPequena);
    cargador = TestbedHarnessEnvironment.loader(fixture);
  });

  it('ordena al pulsar la cabecera', async () => {
    const orden = await cargador.getHarness(MatSortHarness);
    const [cabecera] = await orden.getSortHeaders({ label: 'Nombre' });
    await cabecera.click();

    const tabla = await cargador.getHarness(MatTableHarness);
    const textos = await tabla.getCellTextByColumnName();
    expect(textos['nombre'].text).toEqual(['Ana', 'Luis']);
  });
});

Observa que la prueba no toca ninguna clase de CSS, ni llama a fixture.detectChanges(): el harness espera por sí mismo a que la vista esté estable.


14. Errores frecuentes

Síntoma Causa Solución
mat-form-field must contain a MatFormFieldControl Falta matInput, o no has importado MatInput Añade la directiva y su importación
El selector de fechas lanza No provider found for DateAdapter Falta el adaptador provideNativeDateAdapter()
Un componente se ve sin estilo, sin error Falta una directiva en imports, o un mat-menu-item sin mat-menu Revisa los imports y el uso
Se ve la palabra delete en lugar del icono No ha cargado Material Icons Enlace en index.html
Los colores no cambian en modo oscuro Hay colores fijos en tu CSS Usa var(--mat-sys-...)
La pantalla entera salta al enfocar un botón Relleno en mat-sidenav-content, o falta min-height: 0 Pon el relleno en un elemento interior
«Guardar» no hace nada Hay un control inválido que no se ve Consulta formulario.controls; usa markAllAsTouched()
mat-error no aparece El control no se ha tocado Llama a markAllAsTouched()
La tabla no se actualiza al mutar el array La referencia no cambia Crea un array nuevo; fuente.data = [...]
El paginador muestra un total erróneo en el servidor Falta [length] Enlaza el total de la respuesta
La primera página está vacía Se envía la página 0 a una API que empieza en 1 Envía pageIndex + 1
Al soltar, el elemento vuelve a su sitio No actualizas los datos Trata cdkDropListDropped
La señal no refleja el arrastre moveItemInArray muta el mismo array Copia antes y usa set
La lista virtual tiembla o se desfasa itemSize no coincide con la altura Ajusta el CSS con box-sizing
Un overlay queda en pantalla al navegar No se ha llamado a dispose() DestroyRef.onDestroy
Los estilos del overlay no se aplican Está fuera de tu componente Clase en el CSS global o panelClass

Para depurar el tema, inspecciona un elemento en las herramientas del navegador y busca --mat- en el panel de estilos: verás qué token lee y con qué valor.

⚠️ Importante: Material no sale gratis en el tamaño de la aplicación. Al compilar para producción el proyecto de este módulo, ng build funciona, pero avisa: bundle initial exceeded maximum budget. Budget 500.00 kB was not met by 368.10 kB with a total of 868.10 kB. Son unos 194 kB comprimidos, y ese paquete inicial incluye todo lo que se carga siempre, como la barra y el menú lateral. Lo tratarás en el módulo 23: lleva las pantallas pesadas a rutas perezosas, importa solo lo que uses y revisa qué hay en el paquete inicial.


15. Ejercicios

Ejercicio 1: Tema corporativo

Genera una paleta con el color #1a237e como primario y #ff7043 como terciario, y aplícala solo a los elementos con la clase tema-corporativo.

Ver solución
ng generate @angular/material:theme-color --primary-color=#1a237e --tertiary-color=#ff7043 --directory=src/ --interactive=false

Se crea src/_theme-colors.scss. El guion bajo indica que es un parcial de Sass, y se importa sin él:

/* src/tema-corporativo.scss, añadido a "styles" en angular.json */
@use '@angular/material' as mat;
@use './theme-colors' as colores;

.tema-corporativo {
  @include mat.theme((
    color: (
      primary: colores.$primary-palette,
      tertiary: colores.$tertiary-palette,
    ),
  ));
}

Como los componentes solo leen variables CSS, basta con redefinirlas en un selector: <section class="tema-corporativo"> cambia de colores, y el resto de la página conserva los suyos. Omitimos typography y density, de modo que se heredan del tema global.

Ejercicio 2: Filtro con chips

Muestra los departamentos como chips seleccionables. Al elegir uno, la tabla debe mostrar sus empleados con el filtro deptId de la API. Si se desmarca, se muestran todos.

Ver solución
// src/app/ejercicios/filtro.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
import { MatTableModule } from '@angular/material/table';
import { API } from '../api';
import { Departamento, Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-filtro',
  imports: [MatChipListbox, MatChipOption, MatTableModule],
  template: `
    <mat-chip-listbox aria-label="Departamento" [value]="dept()" (change)="dept.set($event.value ?? null)">
      @for (d of departamentos.value()?.contenido; track d.deptId) {
        <mat-chip-option [value]="d.deptId">{{ d.nombre }}</mat-chip-option>
      }
    </mat-chip-listbox>

    <table mat-table [dataSource]="filas()">
      <ng-container matColumnDef="nombre">
        <th mat-header-cell *matHeaderCellDef>Empleado</th>
        <td mat-cell *matCellDef="let e">{{ e.nombre }} {{ e.apellidos }}</td>
      </ng-container>
      <tr mat-header-row *matHeaderRowDef="['nombre']"></tr>
      <tr mat-row *matRowDef="let fila; columns: ['nombre']"></tr>
    </table>
  `,
})
export class Filtro {
  protected readonly dept = signal<number | null>(null);
  protected readonly departamentos = httpResource<Pagina<Departamento>>(() => `${API}/departamentos`);

  private readonly empleados = httpResource<Pagina<Empleado>>(() => {
    const deptId = this.dept();
    const params: Record<string, number> = { tamano: 5 };
    if (deptId !== null) {
      params['deptId'] = deptId;
    }
    return { url: `${API}/empleados`, params };
  });

  protected readonly filas = computed(() =>
    this.empleados.hasValue() ? this.empleados.value().contenido : [],
  );
}

El tipo Record<string, number> no es un capricho. Con deptId: undefined dentro de params, el compilador lo rechaza (TS2769), porque httpResource no admite valores undefined. Por eso solo añadimos la clave si hay filtro.

Al desmarcar un chip, el evento trae value: undefined, y ?? null lo normaliza.

Ejercicio 3: Selección múltiple

Añade a una tabla una columna con casillas y una casilla en la cabecera para marcar todas, en estado indeterminado si solo hay algunas marcadas. Muestra cuántas hay y la masa salarial de la selección.

Ver solución

SelectionModel del CDK guarda qué está marcado, pero no es una señal. Convertimos su observable changed con toSignal y lo leemos dentro de un computed, para que se recalcule en cada cambio:

// src/app/ejercicios/seleccion.ts
import { SelectionModel } from '@angular/cdk/collections';
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatTableModule } from '@angular/material/table';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-seleccion',
  imports: [CurrencyPipe, MatCheckbox, MatTableModule],
  template: `
    <p>
      Seleccionados: {{ marcados().length }}. Masa salarial: {{ suma() | currency: 'EUR' }}
    </p>

    <table mat-table [dataSource]="filas()">
      <ng-container matColumnDef="marca">
        <th mat-header-cell *matHeaderCellDef>
          <mat-checkbox
            aria-label="Seleccionar todos"
            [checked]="todos()"
            [indeterminate]="marcados().length > 0 && !todos()"
            (change)="alternarTodos()"
          />
        </th>
        <td mat-cell *matCellDef="let e">
          <mat-checkbox
            [attr.aria-label]="'Seleccionar a ' + e.nombre"
            [checked]="seleccion.isSelected(e)"
            (change)="seleccion.toggle(e)"
          />
        </td>
      </ng-container>

      <ng-container matColumnDef="nombre">
        <th mat-header-cell *matHeaderCellDef>Empleado</th>
        <td mat-cell *matCellDef="let e">{{ e.nombre }}</td>
      </ng-container>

      <ng-container matColumnDef="salario">
        <th mat-header-cell *matHeaderCellDef>Salario</th>
        <td mat-cell *matCellDef="let e">{{ e.salario | currency: 'EUR' }}</td>
      </ng-container>

      <tr mat-header-row *matHeaderRowDef="columnas"></tr>
      <tr mat-row *matRowDef="let fila; columns: columnas"></tr>
    </table>
  `,
})
export class Seleccion {
  protected readonly columnas = ['marca', 'nombre', 'salario'];
  protected readonly seleccion = new SelectionModel<Empleado>(true, []);

  private readonly respuesta = httpResource<Pagina<Empleado>>(() => `${API}/empleados?tamano=5`);
  protected readonly filas = computed(() =>
    this.respuesta.hasValue() ? this.respuesta.value().contenido : [],
  );

  private readonly cambios = toSignal(this.seleccion.changed);
  protected readonly marcados = computed(() => {
    this.cambios();
    return this.seleccion.selected;
  });
  protected readonly todos = computed(
    () => this.filas().length > 0 && this.marcados().length === this.filas().length,
  );
  protected readonly suma = computed(() =>
    this.marcados().reduce((total, e) => total + e.salario, 0),
  );

  protected alternarTodos() {
    if (this.todos()) {
      this.seleccion.clear();
    } else {
      this.seleccion.select(...this.filas());
    }
  }
}

Tabla con todas las casillas marcadas y el texto Seleccionados: 5, Masa salarial: 24.800,00 €

Figura 20. Tras marcar dos filas, la casilla de la cabecera aparece indeterminada; al pulsarla, se marcan todas.

Con dos marcados, el texto dice Seleccionados: 2. Masa salarial: 11.300,00 €. Con todos, Seleccionados: 5. Masa salarial: 24.800,00 €.

Fíjate en la línea this.cambios(); dentro de marcados: no usamos su valor, solo creamos la dependencia. Sin ella, el computed no sabría que seleccion.selected ha cambiado.

Ejercicio 4: Borrar con deshacer

Crea una lista de los tres últimos empleados con un botón de borrar. Al pulsarlo, el empleado desaparece al momento, y un aviso de 5 segundos ofrece «Deshacer». La petición DELETE solo se envía si el aviso se cierra sin deshacer.

Ver solución
// src/app/ejercicios/deshacer.ts
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, effect, inject, signal } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatList, MatListItem } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-deshacer',
  imports: [MatIcon, MatIconButton, MatList, MatListItem],
  template: `
    <mat-list>
      @for (e of empleados(); track e.empId) {
        <mat-list-item>
          {{ e.nombre }} {{ e.apellidos }}
          <button matIconButton matListItemMeta [attr.aria-label]="'Borrar a ' + e.nombre" (click)="borrar(e)">
            <mat-icon>delete</mat-icon>
          </button>
        </mat-list-item>
      }
    </mat-list>
  `,
})
export class Deshacer {
  private readonly http = inject(HttpClient);
  private readonly avisos = inject(MatSnackBar);
  private readonly respuesta = httpResource<Pagina<Empleado>>(
    () => `${API}/empleados?orden=-empId&tamano=3`,
  );

  protected readonly empleados = signal<Empleado[]>([]);

  constructor() {
    effect(() => {
      if (this.respuesta.hasValue()) {
        this.empleados.set(this.respuesta.value().contenido);
      }
    });
  }

  protected borrar(empleado: Empleado) {
    const anteriores = this.empleados();
    this.empleados.set(anteriores.filter((e) => e.empId !== empleado.empId));

    this.avisos
      .open(`${empleado.nombre} borrado`, 'Deshacer', { duration: 5000 })
      .afterDismissed()
      .subscribe(({ dismissedByAction }) => {
        if (dismissedByAction) {
          this.empleados.set(anteriores);
        } else {
          this.http.delete(`${API}/empleados/${empleado.empId}`).subscribe({
            error: () => {
              this.empleados.set(anteriores);
              this.avisos.open('No se pudo borrar', 'Cerrar', { duration: 4000 });
            },
          });
        }
      });
  }
}

afterDismissed() emite al cerrarse el aviso, y dismissedByAction indica si fue por el botón. Al pulsar «Deshacer», el empleado vuelve y no sale ninguna petición. Si pasan los 5 segundos, se envía el DELETE.

Esta interfaz es mejor que un diálogo de confirmación para acciones frecuentes: no interrumpe al usuario y corrige sus errores. Pero tiene un riesgo: si cierra la pestaña durante esos segundos, el borrado no ocurre.

Ejercicio 5: Prioridades que se recuerdan

Crea una lista ordenable de proyectos que solo se arrastre en vertical, desde un asa, y sin salirse de la lista. El orden debe conservarse al recargar la página.

Ver solución
// src/app/ejercicios/prioridades.ts
import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, effect, signal } from '@angular/core';
import { MatIcon } from '@angular/material/icon';

const CLAVE = 'prioridades-proyectos';
const INICIALES = ['Migración a la nube', 'Portal de clientes', 'App de comerciales', 'Renovación ERP'];

function leerGuardado(): string[] {
  try {
    return JSON.parse(localStorage.getItem(CLAVE) ?? '') as string[];
  } catch {
    return INICIALES;
  }
}

@Component({
  selector: 'app-prioridades',
  imports: [CdkDrag, CdkDragHandle, CdkDropList, MatIcon],
  template: `
    <ol class="lista" cdkDropList cdkDropListLockAxis="y" (cdkDropListDropped)="soltar($event)">
      @for (proyecto of proyectos(); track proyecto) {
        <li class="fila" cdkDrag cdkDragBoundary=".lista">
          <mat-icon cdkDragHandle class="asa">drag_indicator</mat-icon>
          {{ proyecto }}
        </li>
      }
    </ol>
  `,
  styles: `
    .lista { list-style: none; margin: 0; padding: 0; max-width: 360px; }
    .fila { display: flex; align-items: center; gap: 8px; padding: 8px;
            background: var(--mat-sys-surface-container); margin-bottom: 4px; border-radius: 8px; }
    .asa { cursor: move; }
  `,
})
export class Prioridades {
  protected readonly proyectos = signal(leerGuardado());

  constructor() {
    effect(() => localStorage.setItem(CLAVE, JSON.stringify(this.proyectos())));
  }

  protected soltar(evento: CdkDragDrop<unknown>) {
    this.proyectos.update((lista) => {
      const copia = [...lista];
      moveItemInArray(copia, evento.previousIndex, evento.currentIndex);
      return copia;
    });
  }
}

Al arrastrar el primero hasta la tercera posición, el orden pasa a ser «Portal de clientes», «App de comerciales», «Migración a la nube», «Renovación ERP», y se mantiene tras recargar. El effect guarda cada cambio, de modo que soltar no necesita saber nada de localStorage.

El try cubre la primera visita: JSON.parse('') lanza una excepción, y usamos la lista inicial.

Ejercicio 6: Menú contextual

Crea un menú que se abra con el botón derecho en la posición del ratón. Debe cerrarse con Escape, al pulsar fuera y al desplazar la página.

Ver solución

createFlexibleConnectedPositionStrategy no solo admite un elemento: también un punto { x, y }.

// src/app/ejercicios/contextual.ts
import {
  createCloseScrollStrategy,
  createFlexibleConnectedPositionStrategy,
  createOverlayRef,
  OverlayRef,
} from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import {
  Component,
  DestroyRef,
  inject,
  Injector,
  signal,
  TemplateRef,
  viewChild,
  ViewContainerRef,
} from '@angular/core';
import { MatActionList, MatList, MatListItem } from '@angular/material/list';

@Component({
  selector: 'app-contextual',
  imports: [MatActionList, MatList, MatListItem],
  template: `
    <mat-list>
      @for (nombre of nombres; track nombre) {
        <mat-list-item (contextmenu)="abrir($event, nombre)">{{ nombre }}</mat-list-item>
      }
    </mat-list>
    <p>{{ accion() }}</p>

    <ng-template #menu let-nombre>
      <mat-action-list class="ficha-superpuesta" aria-label="Acciones" style="min-width: 180px">
        <button mat-list-item (click)="ejecutar('Copiar', nombre)">Copiar</button>
        <button mat-list-item (click)="ejecutar('Borrar', nombre)">Borrar</button>
      </mat-action-list>
    </ng-template>
  `,
})
export class Contextual {
  private readonly injector = inject(Injector);
  private readonly contenedorVista = inject(ViewContainerRef);
  private readonly plantilla = viewChild.required('menu', { read: TemplateRef });
  private capa?: OverlayRef;

  protected readonly nombres = ['Ana', 'Luis', 'Marta'];
  protected readonly accion = signal('Pulsa con el botón derecho');

  constructor() {
    inject(DestroyRef).onDestroy(() => this.cerrar());
  }

  protected abrir(evento: MouseEvent, nombre: string) {
    evento.preventDefault();
    this.cerrar();

    const posicion = createFlexibleConnectedPositionStrategy(this.injector, {
      x: evento.clientX,
      y: evento.clientY,
    }).withPositions([
      { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' },
      { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' },
    ]);

    const capa = createOverlayRef(this.injector, {
      positionStrategy: posicion,
      scrollStrategy: createCloseScrollStrategy(this.injector),
    });
    capa.attach(new TemplatePortal(this.plantilla(), this.contenedorVista, { $implicit: nombre }));
    capa.outsidePointerEvents().subscribe(() => this.cerrar());
    capa.keydownEvents().subscribe((tecla) => {
      if (tecla.key === 'Escape') {
        this.cerrar();
      }
    });
    this.capa = capa;
  }

  protected ejecutar(accion: string, nombre: string) {
    this.accion.set(`${accion}: ${nombre}`);
    this.cerrar();
  }

  private cerrar() {
    this.capa?.dispose();
    this.capa = undefined;
  }
}

Lista de nombres con un menú flotante abierto junto al cursor, con las opciones Copiar y Borrar

Figura 21. El menú se abre justo donde se ha pulsado.

El tercer argumento de TemplatePortal es el contexto de la plantilla: $implicit es lo que recibe let-nombre. Al elegir «Borrar» sobre Luis, el párrafo muestra Borrar: Luis. preventDefault evita el menú del navegador, y outsidePointerEvents nos ahorra una capa de fondo, que bloquearía el clic derecho sobre otra fila.

Este ejemplo no gestiona el foco ni las flechas del teclado. Para un menú de verdad, usa los triggers de mat-menu o @angular/cdk/menu, que ya lo hacen.

Escribe una prueba que abra DialogoConfirmar, pulse «Borrar» con harnesses y compruebe que el diálogo devuelve true.

Ver solución
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { firstValueFrom } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { DialogoConfirmar } from './dialogos/dialogo-confirmar';

@Component({ template: '' })
class Anfitrion {}

describe('DialogoConfirmar', () => {
  it('devuelve true al pulsar Borrar', async () => {
    const fixture = TestBed.createComponent(Anfitrion);
    const raiz = TestbedHarnessEnvironment.documentRootLoader(fixture);
    const ref = TestBed.inject(MatDialog).open(DialogoConfirmar, {
      data: { titulo: 'Borrar', mensaje: '¿Seguro?' },
    });
    const cierre = firstValueFrom(ref.afterClosed());

    const dialogo = await raiz.getHarness(MatDialogHarness);
    const boton = await dialogo.getHarness(MatButtonHarness.with({ text: 'Borrar' }));
    await boton.click();

    expect(await cierre).toBe(true);
  });
});

Dos claves. Los diálogos se pintan fuera del componente, de modo que hay que usar documentRootLoader, que busca en todo el documento, y no loader. Y afterClosed emite al terminar la animación de cierre, no al hacer clic. En nuestro primer intento comprobábamos el valor nada más pulsar, y la prueba fallaba: firstValueFrom nos permite esperar al cierre.

Ejercicio 8 (propuesto): Búsqueda en el servidor

Añade a TablaServidor un campo de búsqueda con el parámetro q de la API. Al cambiar el texto, debe volver a la primera página. Pista: source de linkedSignal puede depender de dos señales.

Ejercicio 9 (propuesto): Pedido con pasos

Crea un asistente con mat-stepper para hacer un pedido: elegir cliente con mat-autocomplete, añadir productos, revisar y enviar con POST /api/pedidos. Cada paso debe validar sus datos antes de pasar al siguiente.

Ejercicio 10 (propuesto): Productos en tabla virtual

Muestra el catálogo de productos en cdk-virtual-scroll-viewport, con una cabecera fija y columnas alineadas. La cabecera debe quedar fuera del viewport. Luego, genera 50 000 productos de prueba y mide cuántas filas hay en el DOM.

Ejercicio 11 (propuesto): Componente propio con el CDK

Crea un componente app-etiqueta-flotante reutilizable, con cdkConnectedOverlay, que muestre un texto al pasar el ratón por encima de su contenido. Compáralo con matTooltip: ¿qué hace este que el tuyo no? Piensa en el teclado y en los lectores de pantalla.


Resumen

Concepto API Idea clave
Instalación ng add @angular/material Elige sola la versión compatible; en la LTS, 21.2.14
Tema mat.theme((color, typography, density)) Genera variables CSS --mat-sys-*
Modo oscuro color-scheme Los tokens usan light-dark(); no hace falta recompilar
Personalización mat.theme-overrides, mat.button-overrides Cambia tokens, no clases internas
Paleta propia ng g @angular/material:theme-color Calcula los tonos desde tu color
Estructura mat-toolbar, mat-sidenav, BreakpointObserver Menú side u over según la pantalla
Botones matButton="filled", matIconButton Variante según la importancia; aria-label si solo hay icono
Campos mat-form-field, matInput, mat-error Viste a tu formulario; los errores salen al tocar
Fechas provideNativeDateAdapter, MAT_DATE_LOCALE Cuidado con la conversión a UTC
Tabla en cliente MatTableDataSource, matSort, mat-paginator Para volúmenes pequeños
Tabla en servidor (matSortChange), (page), httpResource Eventos a señales; páginas desde 0 en Material
Diálogos MatDialog.open, MAT_DIALOG_DATA, afterClosed El diálogo devuelve datos; la red va fuera
Avisos MatSnackBar, afterDismissed Confirmar y deshacer
Overlay createOverlayRef, estrategias create* Contenido fuera de tu DOM, colocado con planes
Portales ComponentPortal, TemplatePortal, DomPortal Interfaz como un valor transportable
Arrastrar y soltar cdkDrag, cdkDropList, moveItemInArray Actualiza tú los datos, copiando antes
Lista virtual cdk-virtual-scroll-viewport, *cdkVirtualFor Solo existen las filas visibles; itemSize exacto
Pruebas TestbedHarnessEnvironment, Mat*Harness Sin depender del HTML interno