Angular CDK : composants UI accessibles avancés

Front-end 03/04/2026 10:00:00 angularforall.com
Angular Cdk Accessibilité Drag Drop Virtual Scroll
Angular CDK : composants UI accessibles avancés

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
À retenir : Le CDK est la brique de base qu'utilise l'équipe Angular elle-même pour construire Angular Material. Si vous créez une bibliothèque de composants maison ou un design system personnalisé, le CDK est votre meilleur allié.

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
            );
        }
    }
}
Accessibilité clavier : Le CDK DragDrop gère nativement la navigation au clavier. Un utilisateur peut sélectionner un item avec Espace, le déplacer avec les flèches directionnelles, et le déposer avec Espace à nouveau. Aucun code supplémentaire requis.

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.

Gain de performance concret : Une liste de 50 000 items rendue normalement crée 50 000 noeuds DOM. Avec le virtual scroll CDK, seuls 20 à 30 noeuds DOM existent à tout moment, quelle que soit la taille de la liste.

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);
            });
    }
}
CSS obligatoire : Le 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;
        }
    }
}
À retenir : L'Overlay CDK place les panneaux dans un conteneur hors du flux normal du document (.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é');
    }
}
Bonne pratique WCAG 2.1 : Le critère 2.4.7 exige que les indicateurs de focus clavier soient visibles. Utilisez 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);
    }
}
Quand utiliser Portal vs Overlay ? Utilisez Portal pour téléporter du contenu vers un point précis du DOM (ex : un slot dédié). Utilisez Overlay quand vous avez besoin de positionnement dynamique relatif à un élément déclencheur (tooltips, dropdowns). Les deux sont souvent utilisés ensemble, Overlay utilisant Portal en interne.

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();
    }
}
À retenir : L'avantage majeur de 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 role et aria-label sur tous les conteneurs CDK
  • Utiliser cdkTrapFocus sur tout dialog ou modal
  • Appeler LiveAnnouncer.announce() après chaque action utilisateur significative
  • Donner une hauteur CSS fixe au cdk-virtual-scroll-viewport
  • Utiliser moveItemInArray ou transferArrayItem après chaque drop
  • Appeler overlayRef.dispose() et focusTrap.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.

À retenir : Le CDK n'est pas réservé aux auteurs de bibliothèques. Tout projet Angular qui dépasse les composants basiques de formulaires gagne à utiliser le CDK directement — DragDrop, VirtualScroll, Overlay et A11y s'intègrent en quelques lignes et couvrent des cas d'usage qui prendraient des semaines à implémenter correctement de zéro.

Partager