Front-end

- Angular hostDirectives : composer ses directives

Angular Host-Directives Directive-Composition-Api Directives Composition Inputs Outputs Standalone Reutilisabilite Accessibilite Angular-15 Front-End Bonnes-Pratiques Typescript
Angular hostDirectives : composer ses directives

Composez des comportements Angular réutilisables avec la Directive Composition API : attachez inputs, outputs et logique via hostDirectives sans héritage.

Le problème : héritage vs composition

Pendant des années, la seule façon de partager du comportement entre composants Angular était l'héritage de classe. Vous créiez un BaseButtonComponent, puis PrimaryButtonComponent extends BaseButtonComponent. Cette approche semble propre au début, mais elle devient rapidement un piège dès que plusieurs comportements orthogonaux doivent cohabiter.

À retenir : l'héritage impose une relation « est un » (is-a). La composition, elle, décrit une relation « a un comportement » (has-a). Un bouton n'est pas un tracker analytics : il a un comportement de tracking, un comportement d'accessibilité et un comportement de style. C'est exactement ce que hostDirectives modélise.

Le principal défaut de l'héritage en TypeScript est qu'il est mono-parent : une classe ne peut étendre qu'une seule autre classe. Si vous voulez qu'un composant soit à la fois « accessible », « traqué » et « surligné », l'héritage vous force à empiler ces responsabilités dans une seule classe de base fourre-tout.

// ❌ Héritage : une hiérarchie rigide et fragile
class AccessibleBase {
    // logique ARIA...
}
// On veut AUSSI du tracking → on l'entasse dans la base
class AccessibleTrackableBase extends AccessibleBase {
    // logique analytics...
}
// On veut AUSSI du highlight → encore une couche
class AccessibleTrackableHighlightBase extends AccessibleTrackableBase {
    // logique de style...
}

// Le composant final hérite d'une base monolithique
@Component({ selector: 'app-fancy-button', standalone: true, template: '...' })
class FancyButtonComponent extends AccessibleTrackableHighlightBase {
    // impossible de retirer UN seul comportement sans casser la chaîne
}

Chaque nouveau besoin gonfle la classe de base. Retirer un comportement d'un seul composant devient impossible sans dupliquer toute la hiérarchie. C'est le fameux problème de la classe de base obèse (fat base class).

La composition renverse le modèle : chaque comportement vit dans sa propre directive standalone, testable et réutilisable isolément. Le composant hôte déclare simplement la liste des comportements qu'il souhaite adopter.

// ✅ Composition : chaque comportement est une brique indépendante
@Component({
    selector: 'app-fancy-button',
    standalone: true,
    template: '<ng-content></ng-content>',
    // On assemble les comportements comme des LEGO
    hostDirectives: [AccessibleDirective, TrackingDirective, HighlightDirective]
})
class FancyButtonComponent {
    // aucune classe de base, aucune duplication
}

On peut ajouter, retirer ou réordonner les comportements sans toucher à la logique interne du composant. C'est précisément la promesse de la Directive Composition API, que nous détaillons dans la section suivante.

Le pourquoi profond de ce changement tient à un principe de conception éprouvé : favoriser la composition à l'héritage (composition over inheritance). L'héritage crée un couplage fort entre parent et enfant : modifier la classe de base peut casser silencieusement tous ses descendants. La composition, à l'inverse, assemble des unités indépendantes qui ne se connaissent pas entre elles. Chaque directive ignore l'existence des autres, ce qui rend le système résilient au changement : ajouter un comportement ne peut pas régresser les autres.

Dans l'écosystème Angular, ce basculement a été rendu naturel par l'arrivée des composants et directives standalone. Avant standalone, partager une directive imposait de jongler avec les declarations et exports des NgModules. Désormais, une directive est une unité autoportante que l'on importe et compose à la demande. hostDirectives est l'aboutissement logique de cette évolution : la brique de comportement la plus fine possible, réutilisable partout.

La Directive Composition API : concept et syntaxe

Introduite en preview dans Angular v14 et stabilisée en v15, la Directive Composition API repose sur une seule propriété du décorateur : hostDirectives. Elle est disponible aussi bien sur @Component que sur @Directive.

Le principe est simple : lorsqu'Angular instancie l'hôte, il instancie aussi chaque directive listée dans hostDirectives et l'applique sur le même élément DOM. Les directives partagent donc l'ElementRef de l'hôte, ses bindings d'hôte (host) et son cycle de vie.

La propriété accepte deux formes. La forme courte, quand vous n'avez pas besoin d'exposer d'inputs/outputs :

// Forme courte : on liste simplement les classes de directive
@Component({
    selector: 'app-card',
    standalone: true,
    template: '<ng-content></ng-content>',
    // Chaque directive doit être standalone (contrainte obligatoire)
    hostDirectives: [ElevationDirective, RippleDirective]
})
export class CardComponent {}

Et la forme objet, qui permet de re-exposer (mapper) les inputs et outputs de la directive source vers l'API publique de l'hôte :

// Forme objet : contrôle fin sur ce qui est exposé
@Component({
    selector: 'app-card',
    standalone: true,
    template: '<ng-content></ng-content>',
    hostDirectives: [
        {
            directive: ElevationDirective,
            // Rendre l'input "level" accessible sous le nom "elevation"
            inputs: ['level: elevation'],
            // Re-exposer l'output "raised" tel quel
            outputs: ['raised']
        }
    ]
})
export class CardComponent {}
Point important : par défaut, les inputs et outputs d'une host directive sont privés. Ils ne sont exposés au template parent que si vous les listez explicitement dans inputs/outputs. C'est un choix de conception délibéré : l'encapsulation par défaut évite de polluer l'API publique de l'hôte.

Voici comment les deux formes se comparent, pour choisir la bonne selon votre besoin :

Critère Forme courte [Dir] Forme objet { directive: Dir }
Syntaxe Tableau de classes Tableau d'objets de config
Exposer un input Impossible Via inputs: []
Exposer un output Impossible Via outputs: []
Renommer (alias) Non 'src: alias'
Cas d'usage Comportement « boîte noire » Comportement configurable

Retenez la règle : commencez toujours par la forme courte, et passez à la forme objet uniquement quand vous devez rendre configurable un comportement depuis l'extérieur.

Un point souvent mal compris concerne le cycle de vie partagé. Les host directives ne sont pas des composants imbriqués : elles vivent sur le même nœud DOM que l'hôte. Leur ngOnInit, ngOnDestroy et leurs bindings d'hôte s'exécutent en synchronisation avec ceux de l'hôte. Concrètement, quand Angular détruit le composant, il détruit aussi proprement toutes ses host directives — vous n'avez aucune gestion de nettoyage supplémentaire à écrire. Cette symétrie de vie est ce qui rend la composition si sûre comparée à une instanciation manuelle de directive via ViewContainerRef.

Enfin, sachez que les host directives participent pleinement à l'injection de dépendances de l'hôte. Une host directive peut injecter un service, l'ElementRef partagé, ou même une autre directive présente sur l'hôte. C'est ce qui autorise des coordinations fines entre comportements — par exemple une directive de validation qui lit l'état d'une directive de formulaire sur le même élément.

Créer sa première directive composable

Passons à la pratique avec l'exemple canonique : une HighlightDirective qui applique une couleur de fond au survol. Nous allons la construire comme une directive parfaitement autonome, puis la composer sur un composant.

Étape 1 : écrire la directive standalone

// src/app/directives/highlight.directive.ts
import { Directive, ElementRef, HostListener, Input, inject } from '@angular/core';

@Directive({
    selector: '[appHighlight]',
    // OBLIGATOIRE pour être utilisable dans hostDirectives
    standalone: true
})
export class HighlightDirective {
    // Injection de l'élément hôte (partagé avec le composant hôte)
    private el = inject(ElementRef<HTMLElement>);

    // Couleur configurable, valeur par défaut jaune pâle
    @Input() color = '#fff3cd';

    // Listener déclenché à l'entrée de la souris
    @HostListener('mouseenter')
    onEnter(): void {
        // Applique la couleur de fond au survol
        this.el.nativeElement.style.backgroundColor = this.color;
    }

    // Listener déclenché à la sortie de la souris
    @HostListener('mouseleave')
    onLeave(): void {
        // Réinitialise le fond
        this.el.nativeElement.style.backgroundColor = '';
    }
}

Cette directive fonctionne déjà seule : vous pourriez l'appliquer directement dans un template avec <div appHighlight>. Mais l'intérêt de la composition est de la rendre implicite sur un composant, sans que le consommateur ait à connaître son sélecteur.

Étape 2 : composer la directive sur un composant

// src/app/components/highlight-card.component.ts
import { Component } from '@angular/core';
import { HighlightDirective } from '../directives/highlight.directive';

@Component({
    selector: 'app-highlight-card',
    standalone: true,
    // Le comportement de surlignage est "cousu" dans le composant
    hostDirectives: [HighlightDirective],
    template: `
        <div class="card-body">
            <ng-content></ng-content>
        </div>
    `
})
export class HighlightCardComponent {}

Résultat : tout élément <app-highlight-card> se surligne au survol, sans que le template parent ajoute quoi que ce soit. Le comportement est encapsulé dans le composant.

<!-- Utilisation : le surlignage est automatique -->
<app-highlight-card>
    Passez la souris ici, le fond se colore.
</app-highlight-card>
Ce qui se passe en coulisses : Angular instancie HighlightCardComponent ET HighlightDirective sur le même élément DOM. La directive partage l'ElementRef de l'hôte, donc this.el.nativeElement pointe vers le <app-highlight-card> lui-même.

Notez qu'ici l'input color n'est pas exposé : le consommateur ne peut pas changer la couleur depuis le template. C'est voulu pour l'instant. Nous verrons dans la section suivante comment l'exposer proprement.

Exposer inputs et outputs des host directives

Par défaut, les inputs d'une host directive sont invisibles depuis l'extérieur. Pour les rendre accessibles, on utilise le tableau inputs de la forme objet. La syntaxe supporte deux variantes : passage direct ou avec alias.

Exposer un input sous son nom d'origine :

// L'input "color" de HighlightDirective devient public tel quel
@Component({
    selector: 'app-highlight-card',
    standalone: true,
    hostDirectives: [
        {
            directive: HighlightDirective,
            // "color" est maintenant bindable depuis le parent
            inputs: ['color']
        }
    ],
    template: '<ng-content></ng-content>'
})
export class HighlightCardComponent {}
<!-- Le parent peut désormais piloter la couleur -->
<app-highlight-card color="#d1e7dd">Fond vert au survol</app-highlight-card>

Exposer un input sous un alias (renommage) : c'est la syntaxe la plus utile en pratique. Elle évite les collisions de noms et clarifie l'API publique.

@Component({
    selector: 'app-highlight-card',
    standalone: true,
    hostDirectives: [
        {
            directive: HighlightDirective,
            // Syntaxe 'nomSource: aliasPublic'
            // "color" (interne) est exposé comme "highlightColor" (public)
            inputs: ['color: highlightColor']
        }
    ],
    template: '<ng-content></ng-content>'
})
export class HighlightCardComponent {}
<!-- L'API publique utilise l'alias, pas le nom interne -->
<app-highlight-card highlightColor="#f8d7da">
    Fond rouge pâle au survol
</app-highlight-card>
Attention au sens du mapping : dans 'color: highlightColor', la partie gauche (color) est le nom dans la directive source, la partie droite (highlightColor) est le nom exposé au monde extérieur. C'est l'inverse d'un alias d'@Input classique — une source d'erreur fréquente.

Exposer un output : le principe est identique. Prenons une directive qui émet un événement lorsqu'elle est cliquée.

// src/app/directives/clickable.directive.ts
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';

@Directive({ selector: '[appClickable]', standalone: true })
export class ClickableDirective {
    // Output émis à chaque clic sur l'hôte
    @Output() activated = new EventEmitter<MouseEvent>();

    @HostListener('click', ['$event'])
    onClick(event: MouseEvent): void {
        // Propage l'événement vers l'extérieur
        this.activated.emit(event);
    }
}
@Component({
    selector: 'app-action-card',
    standalone: true,
    hostDirectives: [
        {
            directive: ClickableDirective,
            // Expose l'output "activated" sous l'alias "cardActivated"
            outputs: ['activated: cardActivated']
        }
    ],
    template: '<ng-content></ng-content>'
})
export class ActionCardComponent {}
<!-- On écoute l'output re-exposé comme un output natif -->
<app-action-card (cardActivated)="onActivate($event)">
    Cliquez sur toute la carte
</app-action-card>

Les inputs re-exposés supportent aussi les signal inputs d'Angular v17+. Une directive qui déclare color = input('#fff3cd') se compose exactement de la même manière : la mécanique inputs: ['color: highlightColor'] reste inchangée.

Composer plusieurs comportements

La vraie puissance de hostDirectives apparaît quand on empile plusieurs comportements orthogonaux sur un même hôte. Construisons un composant qui combine trois préoccupations : tracking analytics, tooltip et accessibilité.

Directive 1 — tracking analytics :

// src/app/directives/track-click.directive.ts
import { Directive, HostListener, Input, inject } from '@angular/core';
import { AnalyticsService } from '../services/analytics.service';

@Directive({ selector: '[appTrackClick]', standalone: true })
export class TrackClickDirective {
    private analytics = inject(AnalyticsService);

    // Nom de l'événement à remonter
    @Input() trackId = 'unknown';

    @HostListener('click')
    onClick(): void {
        // Envoie l'événement au service analytics à chaque clic
        this.analytics.track('click', { id: this.trackId });
    }
}

Directive 2 — tooltip minimaliste :

// src/app/directives/tooltip.directive.ts
import { Directive, ElementRef, HostListener, Input, inject } from '@angular/core';

@Directive({ selector: '[appTooltip]', standalone: true })
export class TooltipDirective {
    private el = inject(ElementRef<HTMLElement>);

    // Texte de l'info-bulle
    @Input() tooltipText = '';

    @HostListener('mouseenter')
    show(): void {
        // Utilise l'attribut title natif (simplifié pour l'exemple)
        this.el.nativeElement.setAttribute('title', this.tooltipText);
    }
}

Directive 3 — accessibilité (rôle + focusabilité) :

// src/app/directives/a11y-button.directive.ts
import { Directive, ElementRef, HostListener, inject } from '@angular/core';

@Directive({
    selector: '[appA11yButton]',
    standalone: true,
    // Les host bindings appliquent role et tabindex sur l'élément
    host: {
        'role': 'button',
        'tabindex': '0'
    }
})
export class A11yButtonDirective {
    private el = inject(ElementRef<HTMLElement>);

    // Rend le composant activable au clavier (Enter/Espace)
    @HostListener('keydown.enter')
    @HostListener('keydown.space')
    onKey(): void {
        // Simule un clic pour l'équivalence clavier/souris
        this.el.nativeElement.click();
    }
}

Composition finale : on assemble les trois briques et on ré-expose uniquement ce dont le consommateur a besoin.

// src/app/components/smart-action.component.ts
import { Component } from '@angular/core';
import { TrackClickDirective } from '../directives/track-click.directive';
import { TooltipDirective } from '../directives/tooltip.directive';
import { A11yButtonDirective } from '../directives/a11y-button.directive';

@Component({
    selector: 'app-smart-action',
    standalone: true,
    hostDirectives: [
        // Accessibilité : aucune config à exposer, forme courte
        A11yButtonDirective,
        // Tracking : on expose l'identifiant analytics
        {
            directive: TrackClickDirective,
            inputs: ['trackId']
        },
        // Tooltip : on renomme pour clarifier l'API
        {
            directive: TooltipDirective,
            inputs: ['tooltipText: hint']
        }
    ],
    template: '<ng-content></ng-content>'
})
export class SmartActionComponent {}
<!-- Un seul élément, trois comportements combinés -->
<app-smart-action trackId="cta-hero" hint="Lance l'inscription">
    S'inscrire
</app-smart-action>
Le gain concret : chacune des trois directives reste testable et réutilisable seule. Vous pouvez composer A11yButtonDirective sur dix composants différents sans jamais dupliquer la logique clavier. C'est la réutilisabilité horizontale que l'héritage ne permettait pas.

Pourquoi parle-t-on de comportements « orthogonaux » ? Parce que le tracking, le tooltip et l'accessibilité sont des préoccupations totalement indépendantes : changer la couleur du tooltip n'a aucun impact sur la logique analytics, et inversement. En programmation, deux responsabilités orthogonales sont précisément celles qu'il faut séparer en modules distincts. L'héritage les aurait fusionnées dans une même classe ; la composition les garde isolées tout en les faisant cohabiter sur un seul élément.

Un dernier détail améliore l'ergonomie : dans notre exemple, le consommateur du composant <app-smart-action> n'a jamais besoin de connaître l'existence des trois directives sous-jacentes. Il voit une API simple — trackId et hint — sans savoir qu'elle est alimentée par une composition. Cette encapsulation est essentielle pour un design system : l'implémentation interne peut évoluer (ajouter une directive de logging, remplacer le tooltip) sans jamais casser le contrat public du composant.

hostDirectives : composant vs directive

hostDirectives fonctionne aussi bien sur un @Component que sur un @Directive. Cela ouvre un pattern puissant : créer une directive « agrégat » qui regroupe plusieurs comportements réutilisables, et composer cet agrégat ailleurs.

Composer des directives sur une autre directive :

// Une directive qui agrège tracking + accessibilité
@Directive({
    selector: '[appInteractive]',
    standalone: true,
    // Une directive peut composer d'autres directives
    hostDirectives: [
        A11yButtonDirective,
        { directive: TrackClickDirective, inputs: ['trackId'] }
    ]
})
export class InteractiveDirective {}
// On réutilise l'agrégat sur un composant, en une ligne
@Component({
    selector: 'app-menu-item',
    standalone: true,
    // Compose l'agrégat → hérite de TOUS ses comportements
    hostDirectives: [{ directive: InteractiveDirective, inputs: ['trackId'] }],
    template: '<ng-content></ng-content>'
})
export class MenuItemComponent {}

Ce chaînage est transitif : les comportements de A11yButtonDirective et TrackClickDirective remontent jusqu'à MenuItemComponent. Vous construisez ainsi des presets de comportement réutilisables.

Voici les différences et contraintes selon la cible de la composition :

Aspect hostDirectives sur Composant hostDirectives sur Directive
Cible standalone requise Oui pour les directives listées Oui pour les directives listées
Template propre Oui (l'hôte a un template) Non (pas de template)
Créer des presets réutilisables Moins idéal Cas d'usage phare
Partage de l'ElementRef Élément hôte du composant Élément portant la directive
Contrainte non négociable : toute directive référencée dans hostDirectives doit être standalone: true. Une directive déclarée dans un NgModule déclenche l'erreur « NG... hostDirectives must be standalone » à la compilation. C'est la raison pour laquelle cette API est indissociable de l'architecture standalone d'Angular moderne.

Autre limite à connaître : vous ne pouvez pas ajouter dynamiquement des host directives à l'exécution. La liste hostDirectives est statique, résolue à la compilation. Pour du dynamique, il faut recourir à ViewContainerRef et createComponent, ce qui sort du périmètre de la composition déclarative.

Cas réels en entreprise

Au-delà des exemples pédagogiques, voici trois patterns que l'on retrouve fréquemment en production dans des design systems Angular.

Cas 1 — Bouton accessible réutilisable

Dans un design system, tous les composants cliquables (bouton, chip, item de menu, carte cliquable) doivent partager le même socle d'accessibilité : rôle ARIA, focusabilité, gestion clavier, état désactivé. Plutôt que de le réimplémenter, on le compose.

// src/app/directives/accessible-control.directive.ts
import { Directive, HostBinding, Input } from '@angular/core';

@Directive({ selector: '[appAccessibleControl]', standalone: true })
export class AccessibleControlDirective {
    @Input() disabled = false;

    // Reflète l'état désactivé dans aria-disabled
    @HostBinding('attr.aria-disabled')
    get ariaDisabled(): string {
        return String(this.disabled);
    }

    // Retire du flux de tabulation si désactivé
    @HostBinding('attr.tabindex')
    get tabindex(): string {
        return this.disabled ? '-1' : '0';
    }
}
// Tous les contrôles du design system héritent du socle a11y
@Component({
    selector: 'app-ds-button',
    standalone: true,
    hostDirectives: [
        { directive: AccessibleControlDirective, inputs: ['disabled'] }
    ],
    template: '<ng-content></ng-content>'
})
export class DsButtonComponent {}

Cas 2 — Directive analytics transverse

Une équipe growth veut tracker automatiquement les interactions sur des dizaines de composants sans polluer chaque template. Une directive analytics composée offre un point d'instrumentation unique et cohérent.

// La directive analytics reste 100% découplée de l'UI
@Directive({ selector: '[appAnalytics]', standalone: true })
export class AnalyticsDirective {
    private analytics = inject(AnalyticsService);

    @Input() eventName = 'interaction';
    @Input() eventPayload: Record<string, unknown> = {};

    @HostListener('click')
    report(): void {
        // Un seul endroit à maintenir pour toute l'instrumentation
        this.analytics.track(this.eventName, this.eventPayload);
    }
}

// Composée sur un composant produit sans ligne de tracking dans son template
@Component({
    selector: 'app-product-tile',
    standalone: true,
    hostDirectives: [
        { directive: AnalyticsDirective, inputs: ['eventName', 'eventPayload'] }
    ],
    template: '<ng-content></ng-content>'
})
export class ProductTileComponent {}

Cas 3 — Effet ripple (Material-like)

L'effet d'ondulation au clic est un comportement purement visuel, idéal pour la composition car totalement indépendant de la logique métier du composant.

// src/app/directives/ripple.directive.ts
import { Directive, ElementRef, HostListener, inject } from '@angular/core';

@Directive({
    selector: '[appRipple]',
    standalone: true,
    // Position relative nécessaire pour contenir l'onde
    host: { 'style': 'position: relative; overflow: hidden;' }
})
export class RippleDirective {
    private el = inject(ElementRef<HTMLElement>);

    @HostListener('click', ['$event'])
    ripple(event: MouseEvent): void {
        const host = this.el.nativeElement;
        const circle = document.createElement('span');
        const diameter = Math.max(host.clientWidth, host.clientHeight);
        // Positionne l'onde au point de clic
        circle.style.width = circle.style.height = diameter + 'px';
        circle.style.left = (event.offsetX - diameter / 2) + 'px';
        circle.style.top = (event.offsetY - diameter / 2) + 'px';
        circle.className = 'ripple-effect';
        host.appendChild(circle);
        // Nettoie l'élément après l'animation CSS
        circle.addEventListener('animationend', () => circle.remove());
    }
}
// On combine ripple + accessibilité pour un bouton complet
@Component({
    selector: 'app-fab',
    standalone: true,
    hostDirectives: [RippleDirective, AccessibleControlDirective],
    template: '<ng-content></ng-content>'
})
export class FabComponent {}
Retour d'expérience : dans un design system réel, isoler ripple, accessibilité et analytics en directives composables a divisé par trois le volume de code dupliqué entre les composants interactifs, tout en rendant chaque comportement testable indépendamment.

Tests, pièges courants et bonnes pratiques

Tester un composant qui compose des host directives demande une approche spécifique : on ne teste pas la directive à travers son sélecteur, mais à travers le composant hôte réel dans un composant de test.

Tester avec TestBed et un host component

// smart-action.component.spec.ts
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SmartActionComponent } from './smart-action.component';

// Composant hôte de test qui utilise le composant à tester
@Component({
    standalone: true,
    imports: [SmartActionComponent],
    template: `
        <app-smart-action trackId="test-cta" hint="Aide">
            Cliquez
        </app-smart-action>
    `
})
class TestHostComponent {}

describe('SmartActionComponent (host directives)', () => {
    let fixture: ComponentFixture<TestHostComponent>;

    beforeEach(async () => {
        await TestBed.configureTestingModule({
            imports: [TestHostComponent]
        }).compileComponents();

        fixture = TestBed.createComponent(TestHostComponent);
        fixture.detectChanges();
    });

    it('applique role="button" via la host directive a11y', () => {
        // On vérifie l'effet DOM produit par A11yButtonDirective
        const el = fixture.debugElement.query(By.css('app-smart-action'));
        expect(el.nativeElement.getAttribute('role')).toBe('button');
        expect(el.nativeElement.getAttribute('tabindex')).toBe('0');
    });

    it('expose le tooltip via l\'input aliasé "hint"', () => {
        const el = fixture.debugElement.query(By.css('app-smart-action'));
        // mouseenter déclenche l'ajout de l'attribut title
        el.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
        fixture.detectChanges();
        expect(el.nativeElement.getAttribute('title')).toBe('Aide');
    });
});

On peut aussi récupérer une instance de host directive via l'injecteur du composant, pour des tests unitaires plus ciblés :

it('permet d\'injecter la host directive depuis l\'hôte', () => {
    const fixture = TestBed.createComponent(SmartActionComponent);
    // La host directive est disponible dans l'injecteur de l'hôte
    const tracking = fixture.debugElement.injector.get(TrackClickDirective);
    expect(tracking).toBeInstanceOf(TrackClickDirective);
});

Pièges courants

Piège 1 — l'ordre d'application compte. Les host directives s'appliquent dans l'ordre du tableau, avant l'hôte lui-même. Si deux directives modifient le même attribut ou le même host binding, la dernière de la liste — puis l'hôte — l'emporte.

// L'ordre détermine qui gagne sur un même attribut/binding
hostDirectives: [
    SetBlueBackgroundDirective,   // s'applique en premier
    SetRedBackgroundDirective     // écrase → le rouge gagne
]
// Puis un host binding de l'hôte lui-même écraserait encore ces deux-là

Piège 2 — la DI et les providers. Une host directive partage l'injecteur de l'hôte. Si la directive déclare des providers, ceux-ci sont ajoutés à l'injecteur du composant hôte. Attention aux collisions : deux directives fournissant le même token provoquent un écrasement silencieux.

Piège 3 — inputs privés par défaut. Oublier de lister un input dans inputs: [] est l'erreur la plus fréquente : le binding parent est alors silencieusement ignoré, sans erreur. Vérifiez toujours l'exposition explicite.

Piège 4 — sens du mapping inversé. Comme vu plus haut, 'source: alias' se lit interne → public, à l'inverse de l'alias d'@Input. Relisez toujours vos mappings.

Bonne pratique : gardez chaque host directive mono-responsabilité. Une directive = un comportement. C'est ce qui rend la composition lisible et évite les collisions de host bindings. Si une directive fait trop de choses, découpez-la avant de la composer.

Checklist avant d'adopter hostDirectives

  • Toutes les directives composées sont standalone: true
  • Chaque directive porte une seule responsabilité claire
  • Les inputs à piloter de l'extérieur sont listés dans inputs: []
  • Le sens des alias 'source: public' est vérifié
  • L'ordre du tableau hostDirectives est intentionnel (host bindings)
  • Aucune collision de providers entre directives composées
  • Les tests passent par un host component réel via TestBed
  • La composition remplace un héritage, pas une simple fonction utilitaire
  • Pas de besoin d'ajout dynamique (sinon ViewContainerRef)

La Directive Composition API marque un tournant dans la manière de concevoir les composants Angular : on passe d'une pensée hiérarchique à une pensée modulaire. En isolant chaque comportement dans une directive standalone testable, vous obtenez des composants plus légers, un design system plus cohérent et une base de code où l'ajout d'un comportement se fait en une ligne, sans jamais casser l'existant. Commencez petit — une HighlightDirective, un socle d'accessibilité — puis étendez la composition à mesure que vos patterns se stabilisent.

Partager