Angular CDK guide complet : DragDropModule, CdkVirtualScrollViewport, Overlay, A11y FocusTrap et Portal pour des UI accessibles avancées.
Angular CDK vs Angular Material : les primitives comportementales
Le Component Dev Kit (CDK) est la couche fondamentale sur laquelle repose Angular Material. Il fournit des comportements UI réutilisables sans imposer le moindre style visuel. C'est précisément ce qui le rend si puissant : vous obtenez la logique, vous apportez le design.
Là où Angular Material vous donne un bouton mat-button avec des couleurs, des ombres et un style Material Design précis, le CDK vous donne les primitives : comment gérer le focus, comment déplacer des éléments par glisser-déposer, comment positionner un panneau flottant par rapport à son déclencheur. Zéro CSS imposé, comportement intégral.
| Module CDK | Ce qu'il fournit | Usage typique |
|---|---|---|
| DragDropModule | Glisser-déposer natif | Listes triables, kanban, upload |
| ScrollingModule | Virtual scrolling | Listes 10 000+ items sans lag |
| OverlayModule | Positionnement de panneaux flottants | Tooltips, dropdowns, modals |
| A11yModule | Gestion ARIA et focus clavier | Dialogs, menus, composants accessibles |
| PortalModule | Téléportation de contenu DOM | Toasts, modals, sidepanels |
| CdkTable | Table sans style imposé | Grilles de données entièrement custom |
Pour installer le CDK dans votre projet Angular :
# Installer le package @angular/cdk
npm install @angular/cdk
# Vérifier la version installée (doit correspondre à votre version Angular)
ng version
Chaque module CDK est indépendant. Vous n'importez que ce dont vous avez besoin, ce qui préserve la taille de votre bundle :
// app.config.ts — importer uniquement les modules CDK nécessaires
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { OverlayModule } from '@angular/cdk/overlay';
import { A11yModule } from '@angular/cdk/a11y';
export const appConfig: ApplicationConfig = {
providers: [
// Fournir uniquement les modules CDK utilisés dans l'application
importProvidersFrom(DragDropModule, ScrollingModule, OverlayModule, A11yModule)
]
};
DragDropModule : listes triables et kanban boards
Le DragDropModule du CDK permet de créer des interfaces de glisser-déposer accessibles sans dépendance externe. Il gère nativement le clavier (flèches, Espace, Entrée), les événements tactiles mobile, et les animations de réorganisation.
Liste triable simple avec cdkDrag et cdkDropList
La combinaison de la directive cdkDropList sur le conteneur et cdkDrag sur chaque item suffit pour créer une liste réorganisable :
<!-- task-list.component.html -->
<!-- cdkDropList : conteneur de la liste droppable -->
<ul
cdkDropList
[cdkDropListData]="tasks"
(cdkDropListDropped)="onDrop($event)"
class="task-list"
aria-label="Liste de tâches, réorganisable par glisser-déposer"
role="list">
<!-- cdkDrag : chaque élément draggable -->
<li
*ngFor="let task of tasks"
cdkDrag
[cdkDragData]="task"
class="task-item"
role="listitem">
<!-- Poignée optionnelle pour le drag (meilleure UX) -->
<span cdkDragHandle class="drag-handle" aria-hidden="true">☰</span>
{{ task.title }}
</li>
</ul>
// task-list.component.ts
import { Component } from '@angular/core';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { NgFor } from '@angular/common';
interface Task {
id: number;
title: string;
priority: 'low' | 'medium' | 'high';
}
@Component({
selector: 'app-task-list',
standalone: true,
imports: [DragDropModule, NgFor],
templateUrl: './task-list.component.html'
})
export class TaskListComponent {
// Tableau de tâches affiché dans la liste triable
tasks: Task[] = [
{ id: 1, title: 'Concevoir le maquettage UI', priority: 'high' },
{ id: 2, title: 'Implémenter le drag & drop CDK', priority: 'high' },
{ id: 3, title: 'Écrire les tests unitaires', priority: 'medium' },
{ id: 4, title: 'Optimiser les performances', priority: 'low' },
{ id: 5, title: 'Déployer en production', priority: 'medium' },
];
// Appelé quand l'utilisateur dépose un élément après drag
onDrop(event: CdkDragDrop<Task[]>): void {
// moveItemInArray : réorganise le tableau en place
// previousIndex → index de départ, currentIndex → index d'arrivée
moveItemInArray(this.tasks, event.previousIndex, event.currentIndex);
}
}
Kanban board avec transfert entre colonnes
Pour un board kanban, on utilise plusieurs cdkDropList connectés entre eux via cdkDropListConnectedTo, et la fonction transferArrayItem pour déplacer un item d'une colonne à l'autre :
// kanban-board.component.ts
import { Component } from '@angular/core';
import {
CdkDragDrop,
moveItemInArray,
transferArrayItem,
DragDropModule
} from '@angular/cdk/drag-drop';
import { NgFor } from '@angular/common';
@Component({
selector: 'app-kanban-board',
standalone: true,
imports: [DragDropModule, NgFor],
template: `
<div class="kanban-board" role="region" aria-label="Tableau kanban">
<!-- Colonne "À faire" -->
<div class="kanban-column">
<h3 id="col-todo">À faire</h3>
<ul
cdkDropList
id="col-todo-list"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="['col-inprogress-list', 'col-done-list']"
(cdkDropListDropped)="drop($event)"
aria-labelledby="col-todo"
role="list">
<li *ngFor="let item of todo" cdkDrag role="listitem">{{ item }}</li>
</ul>
</div>
<!-- Colonne "En cours" -->
<div class="kanban-column">
<h3 id="col-inprogress">En cours</h3>
<ul
cdkDropList
id="col-inprogress-list"
[cdkDropListData]="inProgress"
[cdkDropListConnectedTo]="['col-todo-list', 'col-done-list']"
(cdkDropListDropped)="drop($event)"
aria-labelledby="col-inprogress"
role="list">
<li *ngFor="let item of inProgress" cdkDrag role="listitem">{{ item }}</li>
</ul>
</div>
<!-- Colonne "Terminé" -->
<div class="kanban-column">
<h3 id="col-done">Terminé</h3>
<ul
cdkDropList
id="col-done-list"
[cdkDropListData]="done"
[cdkDropListConnectedTo]="['col-todo-list', 'col-inprogress-list']"
(cdkDropListDropped)="drop($event)"
aria-labelledby="col-done"
role="list">
<li *ngFor="let item of done" cdkDrag role="listitem">{{ item }}</li>
</ul>
</div>
</div>
`
})
export class KanbanBoardComponent {
// Données des trois colonnes du kanban
todo = ['Concevoir la base de données', 'Créer les maquettes', 'Rédiger les specs'];
inProgress = ['Développer le composant CDK', 'Écrire les tests E2E'];
done = ['Initialiser le projet Angular'];
drop(event: CdkDragDrop<string[]>): void {
if (event.previousContainer === event.container) {
// Réorganisation dans la même colonne
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
// Transfert d'une colonne vers une autre
// previousContainer.data : tableau source
// container.data : tableau destination
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
}
}
Virtual Scroll : afficher 100 000 items sans lag
Rendre 10 000 éléments dans le DOM simultanément paralyse n'importe quel navigateur. Le CdkVirtualScrollViewport résout ce problème avec une approche de windowing : seuls les éléments visibles dans la fenêtre de défilement sont rendus dans le DOM. Les autres sont des espaces réservés vides.
Virtual Scroll avec FixedSizeVirtualScrollStrategy
La stratégie la plus performante est celle à taille fixe (*cdkVirtualFor + itemSize). Chaque item a la même hauteur en pixels, ce que le moteur utilise pour calculer les positions sans avoir à mesurer le DOM :
<!-- large-list.component.html -->
<!-- cdk-virtual-scroll-viewport : le conteneur qui gère le windowing -->
<cdk-virtual-scroll-viewport
itemSize="56"
class="virtual-list-viewport"
role="list"
aria-label="Liste de contacts, défilement virtuel">
<!-- *cdkVirtualFor : remplace *ngFor pour les grandes listes -->
<!-- minBufferPx : pixels de buffer au-dessus/en dessous -->
<!-- maxBufferPx : limite max du buffer pour ne pas sur-rendre -->
<div
*cdkVirtualFor="let contact of contacts; let i = index;
minBufferPx: 200; maxBufferPx: 400"
class="contact-row"
role="listitem"
[attr.aria-label]="'Contact ' + (i + 1) + ' : ' + contact.name">
<img [src]="contact.avatar" [alt]="contact.name" width="40" height="40">
<div class="contact-info">
<strong>{{ contact.name }}</strong>
<span>{{ contact.email }}</span>
</div>
</div>
</cdk-virtual-scroll-viewport>
// large-list.component.ts
import { Component, OnInit } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { NgFor } from '@angular/common';
interface Contact {
id: number;
name: string;
email: string;
avatar: string;
}
@Component({
selector: 'app-large-list',
standalone: true,
imports: [ScrollingModule, NgFor],
templateUrl: './large-list.component.html',
styles: [`
.virtual-list-viewport {
height: 500px; /* Hauteur fixe obligatoire pour le viewport */
width: 100%;
}
.contact-row {
height: 56px; /* Doit correspondre à itemSize dans le template */
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
`]
})
export class LargeListComponent implements OnInit {
contacts: Contact[] = [];
ngOnInit(): void {
// Générer 100 000 contacts fictifs pour la démonstration
this.contacts = Array.from({ length: 100_000 }, (_, i) => ({
id: i + 1,
name: `Contact ${i + 1}`,
email: `contact${i + 1}@example.com`,
// Utiliser un service d'avatars par initiales (pas d'image réelle requise)
avatar: `https://ui-avatars.com/api/?name=C${i + 1}&size=40`
}));
}
}
Virtual Scroll avec DataSource Observable (données chargées à la demande)
Pour les listes dont les données viennent d'une API paginée, le CDK supporte les DataSource observables. Les items sont chargés uniquement quand l'utilisateur fait défiler vers eux :
// contact-datasource.ts
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { ContactService } from './contact.service';
export class ContactDataSource extends DataSource<Contact | undefined> {
// Tableau sparse : seules les pages chargées sont remplies
private cachedContacts: (Contact | undefined)[] = Array(100_000).fill(undefined);
private dataStream = new BehaviorSubject<(Contact | undefined)[]>(this.cachedContacts);
private subscription = new Subscription();
// Taille de page pour les requêtes API
private readonly PAGE_SIZE = 50;
// Set des pages déjà chargées (évite les doublons)
private fetchedPages = new Set<number>();
constructor(private contactService: ContactService) {
super();
}
// Appelé par le viewport CDK quand il s'abonne à la liste
connect(collectionViewer: CollectionViewer): Observable<(Contact | undefined)[]> {
// Observer la plage visible et charger les pages manquantes
this.subscription.add(
collectionViewer.viewChange.subscribe(range => {
const startPage = Math.floor(range.start / this.PAGE_SIZE);
const endPage = Math.floor(range.end / this.PAGE_SIZE);
// Charger toutes les pages visibles non encore récupérées
for (let page = startPage; page <= endPage; page++) {
this.fetchPage(page);
}
})
);
return this.dataStream;
}
// Nettoyage obligatoire pour éviter les fuites mémoire
disconnect(): void {
this.subscription.unsubscribe();
}
private fetchPage(pageIndex: number): void {
// Ne pas recharger une page déjà en cache
if (this.fetchedPages.has(pageIndex)) return;
this.fetchedPages.add(pageIndex);
// Appel API : récupérer 50 contacts à partir de l'offset
this.contactService.getContacts(pageIndex * this.PAGE_SIZE, this.PAGE_SIZE)
.subscribe(contacts => {
// Insérer dans le tableau sparse à la bonne position
this.cachedContacts.splice(pageIndex * this.PAGE_SIZE, this.PAGE_SIZE, ...contacts);
// Émettre le tableau mis à jour pour que le viewport re-render
this.dataStream.next(this.cachedContacts);
});
}
}
cdk-virtual-scroll-viewport doit avoir une hauteur CSS fixe ou calculée. Sans hauteur, le virtual scroll ne peut pas déterminer combien d'items afficher et ne fonctionnera pas.
Overlay : tooltips et modals positionnés intelligemment
Le service Overlay du CDK est le moteur de positionnement derrière tous les panneaux flottants d'Angular Material : mat-tooltip, mat-select, mat-dialog. Il gère l'insertion dans le DOM, le positionnement intelligent (flip automatique si hors écran), et la gestion du focus.
Créer un tooltip custom avec Overlay
// custom-tooltip.service.ts
import { Injectable, TemplateRef } from '@angular/core';
import {
Overlay,
OverlayRef,
OverlayPositionBuilder,
ConnectedPosition
} from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { ViewContainerRef } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CustomTooltipService {
// Référence à l'overlay actuellement affiché
private overlayRef: OverlayRef | null = null;
constructor(
private overlay: Overlay,
private positionBuilder: OverlayPositionBuilder
) {}
// Ouvrir un tooltip positionné sous l'élément déclencheur
open(
origin: HTMLElement, // Élément déclencheur (le bouton, etc.)
template: TemplateRef<any>, // Template du contenu du tooltip
vcr: ViewContainerRef // ViewContainerRef du composant parent
): void {
// Fermer le tooltip précédent s'il existe
this.close();
// Définir les positions préférées (tentatives dans l'ordre)
const positions: ConnectedPosition[] = [
// Préférence 1 : en dessous, aligné à gauche
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 },
// Préférence 2 : au-dessus si pas assez de place en dessous
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 },
// Préférence 3 : à droite si pas de place vertical
{ originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 },
];
// Créer la stratégie de positionnement connectée à l'élément
const positionStrategy = this.positionBuilder
.flexibleConnectedTo(origin)
.withPositions(positions)
.withPush(true); // Pousser dans la viewport si nécessaire
// Créer la configuration de l'overlay
this.overlayRef = this.overlay.create({
positionStrategy,
// Fermer automatiquement si l'utilisateur clique ailleurs
hasBackdrop: false,
// Scroll: repositionner le tooltip quand la page défile
scrollStrategy: this.overlay.scrollStrategies.reposition()
});
// Créer un TemplatePortal depuis le template fourni
const portal = new TemplatePortal(template, vcr);
// Attacher le portal à l'overlay (insère dans le DOM)
this.overlayRef.attach(portal);
}
// Fermer et détruire l'overlay proprement
close(): void {
if (this.overlayRef) {
this.overlayRef.dispose(); // Supprime du DOM et libère les ressources
this.overlayRef = null;
}
}
}
<!-- Utilisation du service de tooltip dans un composant -->
<!-- Bouton déclencheur avec attributs ARIA -->
<button
#triggerEl
type="button"
(mouseenter)="openTooltip(triggerEl)"
(mouseleave)="closeTooltip()"
(focus)="openTooltip(triggerEl)"
(blur)="closeTooltip()"
[attr.aria-describedby]="'tooltip-content'">
En savoir plus
</button>
<!-- Template du tooltip (invisible par défaut, attaché par le service) -->
<ng-template #tooltipTemplate>
<div
id="tooltip-content"
role="tooltip"
class="custom-tooltip">
Angular CDK Overlay positionne ce panneau intelligemment.
</div>
</ng-template>
Modal accessible avec Overlay et FocusTrap
// modal.service.ts
import { Injectable, TemplateRef, ViewContainerRef } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { A11yModule, FocusTrap, FocusTrapFactory } from '@angular/cdk/a11y';
@Injectable({
providedIn: 'root'
})
export class ModalService {
private overlayRef: OverlayRef | null = null;
private focusTrap: FocusTrap | null = null;
constructor(
private overlay: Overlay,
private focusTrapFactory: FocusTrapFactory
) {}
openModal(template: TemplateRef<any>, vcr: ViewContainerRef): void {
// Créer un overlay plein écran avec backdrop semi-transparent
this.overlayRef = this.overlay.create({
// Couvrir tout l'écran
width: '100vw',
height: '100vh',
// Afficher un backdrop sombre derrière la modal
hasBackdrop: true,
backdropClass: 'modal-backdrop',
// Centrer verticalement et horizontalement
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
// Bloquer le scroll du body quand la modal est ouverte
scrollStrategy: this.overlay.scrollStrategies.block()
});
// Fermer la modal si l'utilisateur clique sur le backdrop
this.overlayRef.backdropClick().subscribe(() => this.closeModal());
// Fermer avec la touche Escape (standard ARIA pour les dialogs)
this.overlayRef.keydownEvents().subscribe(event => {
if (event.key === 'Escape') {
this.closeModal();
}
});
// Attacher le contenu
const portal = new TemplatePortal(template, vcr);
const overlayElement = this.overlayRef.overlayElement;
this.overlayRef.attach(portal);
// Créer un FocusTrap DANS la modal (voir section A11y pour les détails)
// Empêche le focus de sortir du dialog tant qu'il est ouvert
this.focusTrap = this.focusTrapFactory.create(overlayElement);
this.focusTrap.focusInitialElementWhenReady();
}
closeModal(): void {
if (this.focusTrap) {
// Libérer le piège de focus avant de fermer
this.focusTrap.destroy();
this.focusTrap = null;
}
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = null;
}
}
}
.cdk-overlay-container inséré dans le <body>). Cela évite les problèmes de z-index et de overflow: hidden sur les parents.
Accessibilité avec A11y : FocusTrap et LiveAnnouncer
Le module A11yModule du CDK est un ensemble d'outils pour créer des composants réellement accessibles. Il ne s'agit pas juste d'ajouter des attributs ARIA — il s'agit de gérer le comportement du clavier, de gérer le focus, et de communiquer les changements aux technologies d'assistance.
FocusTrap : confiner le focus dans un dialog
Quand une modal ou un dialog est ouvert, le focus clavier ne doit pas pouvoir sortir du composant. La directive cdkTrapFocus ou le service FocusTrapFactory gèrent cela automatiquement :
<!-- dialog.component.html -->
<!-- cdkTrapFocus : empêche le focus de sortir du dialog -->
<!-- cdkTrapFocusAutoCapture : capture le focus automatiquement à l'ouverture -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
cdkTrapFocus
cdkTrapFocusAutoCapture
class="dialog-container">
<h2 id="dialog-title">Confirmer la suppression</h2>
<p id="dialog-description">
Cette action est irréversible. Voulez-vous continuer ?
</p>
<!-- Premier élément focusable : le focus ira ici en premier -->
<button
type="button"
class="btn btn-danger"
(click)="confirm()"
cdkFocusInitial>
Supprimer
</button>
<button
type="button"
class="btn btn-secondary"
(click)="cancel()">
Annuler
</button>
</div>
LiveAnnouncer : communiquer avec les lecteurs d'écran
Le service LiveAnnouncer permet d'envoyer des messages aux lecteurs d'écran (NVDA, JAWS, VoiceOver) via une région ARIA live, sans modifier le DOM visible :
// notification.service.ts
import { Injectable } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
@Injectable({
providedIn: 'root'
})
export class NotificationService {
constructor(private liveAnnouncer: LiveAnnouncer) {}
// Annoncer un succès aux lecteurs d'écran
announceSuccess(message: string): void {
// 'polite' : annonce quand l'utilisateur a fini sa tâche courante
this.liveAnnouncer.announce(message, 'polite');
}
// Annoncer une erreur critique immédiatement
announceError(message: string): void {
// 'assertive' : interrompt immédiatement le lecteur d'écran
// À utiliser uniquement pour les erreurs critiques
this.liveAnnouncer.announce(message, 'assertive');
}
// Annoncer la progression d'une opération longue
announceProgress(percent: number): void {
// Éviter de spammer : n'annoncer que les multiples de 25%
if (percent % 25 === 0) {
this.liveAnnouncer.announce(
`Chargement : ${percent}% terminé`,
'polite'
);
}
}
}
// Exemple d'utilisation dans un composant de formulaire
import { Component, inject } from '@angular/core';
import { NotificationService } from './notification.service';
@Component({
selector: 'app-contact-form',
standalone: true,
template: `
<form (ngSubmit)="submit()" aria-label="Formulaire de contact">
<label for="email">Email</label>
<input id="email" type="email" [(ngModel)]="email" name="email"
aria-required="true" [attr.aria-invalid]="hasError">
<button type="submit">Envoyer</button>
</form>
`
})
export class ContactFormComponent {
private notifications = inject(NotificationService);
email = '';
hasError = false;
async submit(): Promise<void> {
try {
// Simuler un envoi API
await this.sendEmail();
this.hasError = false;
// Informer les utilisateurs de lecteurs d'écran du succès
this.notifications.announceSuccess('Email envoyé avec succès !');
} catch {
this.hasError = true;
// Alerte critique pour l'erreur
this.notifications.announceError('Erreur lors de l\'envoi. Veuillez réessayer.');
}
}
private sendEmail(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 500));
}
}
FocusMonitor : détecter l'origine du focus
Le service FocusMonitor permet de détecter si un élément a reçu le focus via le clavier, la souris, ou le tactile. Utile pour afficher les indicateurs de focus uniquement quand pertinent :
// custom-button.component.ts
import { Component, ElementRef, OnDestroy, OnInit, inject } from '@angular/core';
import { FocusMonitor } from '@angular/cdk/a11y';
@Component({
selector: 'app-custom-button',
standalone: true,
template: `
<button
type="button"
[class.keyboard-focused]="isKeyboardFocused"
(click)="onClick()">
<ng-content></ng-content>
</button>
`
})
export class CustomButtonComponent implements OnInit, OnDestroy {
private focusMonitor = inject(FocusMonitor);
private elementRef = inject(ElementRef);
// Indique si le focus vient du clavier (pour afficher l'outline)
isKeyboardFocused = false;
ngOnInit(): void {
// Observer l'origine du focus sur ce composant
this.focusMonitor.monitor(this.elementRef).subscribe(focusOrigin => {
// focusOrigin : 'keyboard' | 'mouse' | 'touch' | 'program' | null
this.isKeyboardFocused = focusOrigin === 'keyboard';
});
}
ngOnDestroy(): void {
// Arrêter l'observation pour éviter les fuites mémoire
this.focusMonitor.stopMonitoring(this.elementRef);
}
onClick(): void {
console.log('Bouton cliqué');
}
}
FocusMonitor pour afficher un outline CSS uniquement lors de la navigation clavier, sans le montrer lors des clics souris. Cela améliore l'expérience pour tous les utilisateurs.
Portal : téléporter des composants dans le DOM
Le PortalModule permet de rendre un composant ou un template Angular à n'importe quel endroit du DOM, indépendamment de la hiérarchie des composants. C'est la mécanique sous-jacente des toasts, des modals et des sidepanels dans Angular Material.
Il existe trois types de portals :
- TemplatePortal : téléporte un
ng-template - ComponentPortal : téléporte un composant entier
- DomPortal : téléporte un élément HTML natif existant
TemplatePortal : afficher un template dans une PortalOutlet
<!-- app.component.html -->
<!-- Source : le contenu à téléporter (invisible par défaut) -->
<ng-template #notificationTemplate>
<div class="toast-notification" role="status" aria-live="polite">
<strong>Succès !</strong> Votre profil a été mis à jour.
</div>
</ng-template>
<!-- Zone principale de l'application -->
<main>
<button type="button" (click)="showNotification()">
Mettre à jour le profil
</button>
</main>
<!-- Destination : où le contenu sera rendu (en dehors du flux normal) -->
<!-- cdkPortalOutlet : point d'injection dans le DOM -->
<div class="notification-container" [cdkPortalOutlet]="activePortal"></div>
// app.component.ts
import { Component, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { Portal, TemplatePortal, PortalModule } from '@angular/cdk/portal';
@Component({
selector: 'app-root',
standalone: true,
imports: [PortalModule],
templateUrl: './app.component.html'
})
export class AppComponent {
// Référence au ng-template dans le template HTML
@ViewChild('notificationTemplate') notificationTemplate!: TemplateRef<any>;
// Portal actif affiché dans la cdkPortalOutlet
activePortal: Portal<any> | null = null;
constructor(private vcr: ViewContainerRef) {}
showNotification(): void {
// Créer un TemplatePortal depuis le template et le ViewContainerRef
this.activePortal = new TemplatePortal(this.notificationTemplate, this.vcr);
// Masquer automatiquement après 3 secondes
setTimeout(() => {
this.activePortal = null;
}, 3000);
}
}
ComponentPortal : téléporter un composant entier
// toast.service.ts — service de toasts avec ComponentPortal
import { Injectable, ComponentRef, ApplicationRef, createComponent, EnvironmentInjector } from '@angular/core';
import { ComponentPortal, DomPortalOutlet } from '@angular/cdk/portal';
import { ToastComponent } from './toast.component';
@Injectable({
providedIn: 'root'
})
export class ToastService {
private portalOutlet: DomPortalOutlet | null = null;
constructor(
private appRef: ApplicationRef,
private injector: EnvironmentInjector
) {
// Créer un outlet dans le body pour les toasts (hors hiérarchie des composants)
const container = document.createElement('div');
container.setAttribute('class', 'toast-outlet');
document.body.appendChild(container);
// DomPortalOutlet : outlet attaché à un élément DOM natif
this.portalOutlet = new DomPortalOutlet(container, null, this.appRef, this.injector);
}
showToast(message: string, type: 'success' | 'error' | 'info' = 'info'): void {
if (!this.portalOutlet) return;
// Créer un ComponentPortal pour ToastComponent
const portal = new ComponentPortal(ToastComponent);
// Attacher le composant à l'outlet (l'insère dans le DOM)
const componentRef = this.portalOutlet.attach(portal);
// Passer les données au composant téléporté
componentRef.instance.message = message;
componentRef.instance.type = type;
// Détacher le composant après 4 secondes
setTimeout(() => {
this.portalOutlet?.detach();
}, 4000);
}
}
CdkTable : tables flexibles et headless
Le CdkTable est une table Angular entièrement sans style. Contrairement à mat-table qui impose le Material Design, cdk-table vous donne uniquement la structure et la logique : colonnes déclaratives, lignes de données, lignes d'en-tête, lignes de pied de page, et support complet des DataSource observables.
C'est la solution idéale pour créer votre propre design system de grilles de données, avec toute la puissance du moteur de tables Angular.
// employees-table.component.ts
import { Component, OnInit } from '@angular/core';
import { CdkTableModule } from '@angular/cdk/table';
import { NgFor, NgClass, DatePipe } from '@angular/common';
interface Employee {
id: number;
name: string;
department: string;
role: string;
startDate: Date;
salary: number;
}
@Component({
selector: 'app-employees-table',
standalone: true,
imports: [CdkTableModule, NgFor, NgClass, DatePipe],
template: `
<!-- cdk-table : la table sans style imposé -->
<cdk-table
[dataSource]="employees"
class="table table-bordered table-hover w-100"
role="grid"
aria-label="Liste des employés">
<!-- Définition de la colonne Nom -->
<ng-container cdkColumnDef="name">
<!-- En-tête de colonne -->
<th cdk-header-cell *cdkHeaderCellDef scope="col">Nom</th>
<!-- Cellule de données -->
<td cdk-cell *cdkCellDef="let employee">
<strong>{{ employee.name }}</strong>
</td>
</ng-container>
<!-- Définition de la colonne Département -->
<ng-container cdkColumnDef="department">
<th cdk-header-cell *cdkHeaderCellDef scope="col">Département</th>
<td cdk-cell *cdkCellDef="let employee">{{ employee.department }}</td>
</ng-container>
<!-- Définition de la colonne Rôle -->
<ng-container cdkColumnDef="role">
<th cdk-header-cell *cdkHeaderCellDef scope="col">Rôle</th>
<td cdk-cell *cdkCellDef="let employee">{{ employee.role }}</td>
</ng-container>
<!-- Définition de la colonne Date d'embauche -->
<ng-container cdkColumnDef="startDate">
<th cdk-header-cell *cdkHeaderCellDef scope="col">Date d'embauche</th>
<td cdk-cell *cdkCellDef="let employee">
{{ employee.startDate | date:'dd/MM/yyyy' }}
</td>
</ng-container>
<!-- Définition de la colonne Salaire -->
<ng-container cdkColumnDef="salary">
<th cdk-header-cell *cdkHeaderCellDef scope="col">Salaire</th>
<td cdk-cell *cdkCellDef="let employee"
[ngClass]="{'text-success fw-bold': employee.salary > 50000}">
{{ employee.salary | number:'1.0-0' }} €
</td>
</ng-container>
<!-- Ligne d'en-tête : déclare quelles colonnes afficher et dans quel ordre -->
<tr cdk-header-row *cdkHeaderRowDef="displayedColumns"></tr>
<!-- Ligne de données : répétée pour chaque item de la dataSource -->
<tr cdk-row *cdkRowDef="let row; columns: displayedColumns;"></tr>
</cdk-table>
`
})
export class EmployeesTableComponent implements OnInit {
// Colonnes affichées dans l'ordre voulu
displayedColumns: string[] = ['name', 'department', 'role', 'startDate', 'salary'];
employees: Employee[] = [];
ngOnInit(): void {
// Données de démonstration
this.employees = [
{ id: 1, name: 'Alice Martin', department: 'Engineering', role: 'Lead Dev Angular', startDate: new Date('2021-03-15'), salary: 68000 },
{ id: 2, name: 'Bob Dupont', department: 'Design', role: 'UX Designer', startDate: new Date('2022-07-01'), salary: 52000 },
{ id: 3, name: 'Claire Bernard', department: 'Engineering', role: 'Dev Full Stack', startDate: new Date('2020-11-10'), salary: 58000 },
{ id: 4, name: 'David Leroy', department: 'DevOps', role: 'Cloud Architect', startDate: new Date('2019-05-20'), salary: 72000 },
{ id: 5, name: 'Emma Rousseau', department: 'Engineering', role: 'Dev Angular', startDate: new Date('2023-01-15'), salary: 45000 },
];
}
}
Tri et filtrage avec MatSort et DataSource
// employees-table-sorted.component.ts
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { CdkTableModule } from '@angular/cdk/table';
import { MatTableDataSource } from '@angular/material/table';
@Component({
selector: 'app-employees-sorted',
standalone: true,
imports: [CdkTableModule, MatSortModule],
template: `
<!-- Champ de filtrage en temps réel -->
<input
type="text"
placeholder="Filtrer les employés..."
(input)="applyFilter($event)"
aria-label="Filtrer le tableau des employés"
class="form-control mb-3">
<cdk-table
[dataSource]="dataSource"
matSort
class="table table-bordered"
role="grid">
<ng-container cdkColumnDef="name">
<!-- mat-sort-header : rend la colonne triable au clic -->
<th cdk-header-cell *cdkHeaderCellDef mat-sort-header="name" scope="col">
Nom
</th>
<td cdk-cell *cdkCellDef="let e">{{ e.name }}</td>
</ng-container>
<ng-container cdkColumnDef="salary">
<th cdk-header-cell *cdkHeaderCellDef mat-sort-header="salary" scope="col">
Salaire
</th>
<td cdk-cell *cdkCellDef="let e">{{ e.salary }} €</td>
</ng-container>
<tr cdk-header-row *cdkHeaderRowDef="columns"></tr>
<tr cdk-row *cdkRowDef="let row; columns: columns;"></tr>
</cdk-table>
`
})
export class EmployeesSortedComponent implements OnInit {
columns = ['name', 'salary'];
// MatTableDataSource supporte le tri et le filtrage out-of-the-box
dataSource = new MatTableDataSource<any>();
// Lier le composant MatSort à la dataSource
@ViewChild(MatSort) set sort(sort: MatSort) {
this.dataSource.sort = sort;
}
ngOnInit(): void {
this.dataSource.data = [
{ name: 'Alice Martin', salary: 68000 },
{ name: 'Bob Dupont', salary: 52000 },
{ name: 'Claire Bernard', salary: 58000 },
];
}
applyFilter(event: Event): void {
// Récupérer la valeur de l'input et l'appliquer comme filtre
const filterValue = (event.target as HTMLInputElement).value;
// MatTableDataSource filtre automatiquement tous les champs de l'objet
this.dataSource.filter = filterValue.trim().toLowerCase();
}
}
cdk-table par rapport à mat-table est la liberté totale de style. Vous pouvez y appliquer Bootstrap, Tailwind, ou votre propre CSS — sans surcharger des styles Material Design.
Checklist CDK : bonnes pratiques à respecter
- Importer uniquement les modules CDK utilisés (pas tout le package)
- Ajouter
roleetaria-labelsur tous les conteneurs CDK - Utiliser
cdkTrapFocussur tout dialog ou modal - Appeler
LiveAnnouncer.announce()après chaque action utilisateur significative - Donner une hauteur CSS fixe au
cdk-virtual-scroll-viewport - Utiliser
moveItemInArrayoutransferArrayItemaprès chaque drop - Appeler
overlayRef.dispose()etfocusTrap.destroy()à la fermeture - Tester la navigation clavier complète (Tab, Shift+Tab, Escape, Espace)
- Vérifier le contraste des indicateurs de focus (ratio minimum 3:1 WCAG AA)
- Tester avec un lecteur d'écran (NVDA + Chrome, ou VoiceOver + Safari)
Conclusion
Le Component Dev Kit Angular est l'infrastructure comportementale sur laquelle tout développeur créant des composants UI avancés devrait s'appuyer. En séparant clairement la logique (CDK) du style (votre design system), vous obtenez des composants accessibles, testables et réutilisables sans aucun compromis sur l'identité visuelle de votre application.
Que vous construisiez un kanban board avec DragDrop, une liste infinie performante avec Virtual Scroll, des tooltips repositionnables avec Overlay, ou des tables de données sur mesure avec CdkTable, le CDK vous fournit les primitives éprouvées utilisées en production par des millions d'applications Angular dans le monde. La prochaine étape : adopter A11yModule systématiquement dans tous vos composants custom, car l'accessibilité n'est pas une option mais une exigence fondamentale.