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 addy 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-overridesy cada componente con susoverrides - 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-selecty 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 queng add @angular/materialelige 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/materialson 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.
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:
cdkDragno 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-ltsde 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:
matListItemIcones, para el compilador, un atributo HTML desconocido pero legal. El síntoma es visual. Al preparar este módulo, importamosMatNavListyMatListItempero noMatListItemIcon: compilaba sin avisos y los iconos salían descolocados respecto al texto. Si algo de Material se ve raro, revisa primero losimports. ConMatListModuledesapareció el problema.
💡 Consejo: Para arrancar deprisa, Material trae generadores de pantallas completas:
ng generate @angular/material:navigation,:table,:dashboard,:treey: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.
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
#1565c0en 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.

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

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. Conside, el menú empuja el contenido; conover, flota encima con una capa oscura detrás. El valor sale de una señal que depende del tamaño de pantalla.BreakpointObserveres una pieza del CDK que observa media queries desde TypeScript. Lo envolvemos contoSignal, que viste en el módulo 15.routerLinkActivesin clases, junto con#activo="routerLinkActive", te da la propiedadisActive. 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 enmat-sidenav-content. Este elemento tieneheight: 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.

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-labeles obligatorio enmatIconButton.mat-iconse marca solo conaria-hidden="true", y por eso en la insignia lo ponemos afalse: 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-itemdepende demat-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 conmat-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-fieldno sabe qué lleva dentro. Se comunica con una interfaz llamadaMatFormFieldControl, que implementanmatInput,mat-selecty 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%;
}

Figura 6. Al pulsar «Guardar» sin datos, markAllAsTouched hace que aparezcan todos los errores.
Conviene entender cuatro detalles:
mat-errorsolo 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 esoguardarllama amarkAllAsTouched. Cuando hay un error, sustituye amat-hint.- La etiqueta es
mat-label, noplaceholder. 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,matIconPrefixymatIconSuffix. - El asterisco de la etiqueta aparece solo: Material ve
Validators.requireden 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.

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, porqueJSON.stringifyytoISOStringconvierten 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:
MatTableDataSourcees 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 />

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.requiredda una señal, y uneffecthace la conexión cuando tiene valor. mat-sort-headerusa el nombre de la columna para ordenar: la columnaoficioordena pore.oficio. La nuestra llamadanombreno coincide con lo que queremos, porque ordenamos por apellidos. Para eso sirvesortingDataAccessor, que devuelve el valor por el que ordenar cada fila.filterPredicatedecide si una fila pasa el filtro. El de serie concatena todos los campos, incluidos los que no se ven: un filtro de100encontraría el identificador, y también100dentro del teléfono.firstPage(). Si estás en la página 4 y filtras, puede que queden solo dos resultados.*matNoDataRowse muestra cuando el filtro no deja ninguna fila.- Una cabecera fija.
sticky: truela 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
@ifen un envoltorio de la tabla o del paginador con este patrón sin pensarlo: si elMatSortno existe todavía,viewChild.requiredlanza un error al leerlo. Si tienen que aparecer más tarde, usaviewChilda secas, que devuelveundefined, y comprueba el valor en eleffect. 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.
httpResourcepasa de experimental a estable. Si usas la LTS: este apartado funciona igual, con Material 21.2.14, perohttpResourcees experimental y puede cambiar. Si prefieres no depender de él, usaHttpClientcontoObservableyswitchMap, 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.
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

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 cambiaorden,paginavuelve a 0 sin que nadie lo programe. La última línea de las peticiones lo demuestra. Y comopaginasigue siendo escribible, el paginador puede cambiarla. [length]="total()". ConMatTableDataSource, 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 enSort.active, y lo que enviamos a la API.directionpuede estar vacío. Un tercer clic en la cabecera quita el orden, ySort.directionvale''. Volvemos al orden por identificador.- La tabla no parpadea. Cuando cambian los parámetros,
httpResourcedejavalue()enundefinedhasta tener datos. Si pasáramos eso directamente, la tabla se vaciaría en cada clic.ultimaconserva 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,
MatTableDataSourcees 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);
}

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
afterClosedemiteundefined. - 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);
}
Un formulario dentro de un diálogo
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;
}

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,
telefonoydeptIdpueden sernull, 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 conformulario.controls, y modela los tipos según los datos reales: por eso aquí sonstring | nullynumber | 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: paraopen, el resultado esanypor 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).
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);
}

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

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
startyend, noleftyright: 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 quedetach()solo quita el contenido para volver a usarla. Si no vas a reutilizarla,dispose(). Olvidarlo deja nodos en el DOM. Por eso cerramos enDestroyRef: si el usuario navega con la capa abierta, no debe quedar flotando.backdropClick()requierehasBackdrop: true. La clasecdk-overlay-transparent-backdrophace 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.
disposeOnNavigationadmite, además detrueyfalse, los valores'url-change'y'pop-state'. Si usas la LTS: en CDK 21 solo es booleano, ytruecierra la capa con los botones Atrás y Adelante. Para cerrarla en cualquier cambio de URL, suscríbete aRouter.eventsy llama adispose().
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);
}
}
}

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:
Plantillamuestra2 visitas: unTemplatePortalconserva el contexto del componente donde se declaró, aunque se pinte en otra parte. Se actualiza con la señal.Elemento DOMmueve el elemento: desaparece de su sitio original y aparece en el panel. Al elegir «Vaciar», vuelve donde estaba. Eso es lo que distingue aDomPortal, porque ni clona ni crea nada.Componentecrea una instancia nueva cada vez y la destruye al sustituirla, de modo que su estado interno se pierde.
💡 Consejo:
@angular/coreofrecengComponentOutletyngTemplateOutlet, 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;
}

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:
moveItemInArrayytransferArrayItemmutan 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 asetal 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. cdkDropListDataes un dato tuyo que el evento te devuelve encontainer.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>.cdkDropListGroupconecta 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-previewno incluye a.tarjetacomo 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-selecten 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.
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);
}

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:
itemSizedebe 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 usamosbox-sizing: border-boxconheight: 48px, y el borde queda dentro.
Algunos detalles:
*cdkVirtualFores una directiva estructural, sintaxis antigua (*), porque@forno sabe de virtualización. Es uno de los pocos sitios donde verás esta sintaxis en código nuevo.trackByes una función, no una expresión como eltrackde@for.- La altura del viewport debe venir de tu CSS. Sin ella no sabe cuántas filas caben.
minBufferPxymaxBufferPxfijan 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.templateCacheSizereutiliza 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 uninputsin 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
disposeOnNavigationya 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()ogetOptions(), 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 buildfunciona, 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());
}
}
}

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;
}
}

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.
Ejercicio 7: Probar un diálogo
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 |