Kraken en boîte, ou comment tout réunir dans un seul composant

Suite de l’article Unwrap the kraken, ou comment faire du multicolonne avec des sous composants, où nous allons pousser le bouchon un cran plus loin.

Dans le dernier article nous avions notre composant qui s’occupait de s’extraire et une directive qui s’occupait de le remettre dans une boite, en gros. Mais bon, si le composant pouvait éviter de s’extraire pour être remis en boite ça serait quand même mieux.

Du coup l’idée est de lui rendre la notion « es-tu une ligne ? » via un @Input() row: boolean = true; En considérant qu’il l’est par défaut (au choix). Ensuite quand on crée le composant on agit selon la valeur de row.

À l’initialisation, si nous somme une ligne, nous allons transformer notre template enfant #tref en contenu de notre propre bloc via le ViewContainerRef pour le créer et le Renderer2 qui va l’injecter dans le nœud de notre composant courant. Ensuite on peut signaler à notre bloc courant, le composant en lui-même, que nous aimerions le décorer de quelques classes CSS pour en faire une row css au sens Bootstrap.

if (this.row) {
  // Transform template into inside elements
  Promise.resolve(null).then(() => {
    const view = this.vcRef.createEmbeddedView(this.tpl);
    view.rootNodes.formEach((node) => this.renderer.appendChild(this.el.nativeElement, node));
  });

  // Transform parent to row with classes
  this.renderer.setAttribute(this.el.nativeElement, 'class', "row " + this.classes);
}

Quand l’élément est prêt, et que nous ne sommes pas une ligne, on continue de sortir notre contenu en dehors du bloc par la même mécanique. On ajoute une classe css au bloc courant pour le masquer (bootstrap: d-none -> display: none) et par la même mécanique que le paragraphe précédent on utilise le template et l’ajoute un cran plus haut du coup. Aussi simple que ça.

if (!this.row) {
  // Hide parent
  this.renderer.setAttribute(this.el.nativeElement, 'class', "d-none");

  // Create template elemet outside
  Promise.resolve(null).then(() => this.vcRef.createEmbeddedView(this.tpl));
}

Code de la solution, dans un contexte plus évolué de composant générique parent gérant l’aspect colonne Bootstrap : https://gist.github.com/killan/b621bf2b50e7add8b297493a05c1b949

Unwrap the kraken, ou comment faire du multicolonne avec des sous composants

Dans cet article nous parlerons d’Angular 11, de bootstrap 5, de component, de directive, de sortir le contenu d’un component au niveau du parent et d’encapsuler un contenu, le tout autour du sujet de grille bootstrap (row/col) et d’imbrications posant problème au sujet des alignements.

Vous avez surement déjà utilisé le système de grille de bootstrap et vous connaissez le principe d’un bloc dans un autre tel des poupées Russes et des Legos. Définissons alors un composant vous permettant d’afficher une zone de texte (input) précédé de son nom/étiquette (label).

Exemple d’un label suivi d’un input de type texte

En Angular nous créerons un component pour réutiliser ce système, nous n’aurons qu’à lui passer le libellé du champs, son formGroup si applicable, etc.

<mon-input label="Email" [form]="form"></mon-input>

Nous pouvons alors définir une structure qui serait par exemple :

<div class="row">
  <label class="col-4"></label>
  <input class="col-8">
</div>

Dans notre exemple nous donnons une répartition 1/3 , 2/3.

Ça c’est un cas simple mais imaginons maintenant un cas où nous aurions 2 colonnes contenant chacune le composant mon-input, suivi d’une ligne avec un composant mon-input mais qui voudrait faire la largeur des 2 colonnes.

Schéma de ce que nous voulons atteindre.

Voici ce que donnera la découpe logique une fois mis en bootstrap.

Schéma bootstrap

La première ligne donne le bon résultat : 2 colonnes chacune occupant 1/3 , 2/3 de l’espace parent. Premier problème : utiliser 1/3 , 2/3 sur la seconde ligne ne fonctionne pas.

Première idée du coup changer la répartition, et on peut essayer… ça n’ira pas. Bootstrap nous donne un conteneur row équivalent à 100% de l’espace divisé en 12 colonne théorique.

Dans notre exemple nous avons donc une coupe en 50/50 (soit 2 * 6), sans oublier qu’il y a une gouttière entre les cellule. Nous voudrions donc que la seconde ligne soit dans une répartition 1/12 ou 2/12 pour le label et le reste à l’input.

L’idée de cet article n’est pas le CSS, la mise en page en trichant ou en cassant les mécaniques rencontrées mais de faire avec naturellement et de solutionner par la construction.

Pour ne pas faire durer inutilement, la solution est que la première ligne ne doit pas être en 2 colonnes mais en une seule et de donner des 12ème de la largeur entière au 2 premiers composants. Le hic, car il y a toujours un hic, c’est qu’Angular encapsule ses composants. Au niveau du rendu en 2 colonnes vous auriez quelque chose comme :

<div class="row">
  <div class="col">
    <mon-input>
      <div class="row">
        <label class="col-4"></label>
        <input class="col-8">
      </div>
    </mon-input>
  </div>
  <div class="col">
    <mon-input>
      <div class="row">
        <label class="col-4"></label>
        <input class="col-8">
      </div>
    </mon-input>
  </div>
</div>

Et si on imagine notre solution de tout mettre sur une ligne :

<div class="row">
  <mon-input>
    <div class="row">
      <label class="col-4"></label>
      <input class="col-8">
    </div>
  </mon-input>
  <mon-input>
    <div class="row">
      <label class="col-4"></label>
      <input class="col-8">
    </div>
  </mon-input>
</div>

Il faudra également dire à notre composant de ne plus se traiter comme une ligne individuelle.

<div class="row">
  <mon-input>
    <label class="col-4"></label>
    <input class="col-8">
  </mon-input>
  <mon-input>
    <label class="col-4"></label>
    <input class="col-8">
  </mon-input>
</div>

Nous y voilà, c’est plus clair : le soucis sont les tags <mon-input>, ceux-ci considéré par Bootstrap comme des cellules sans la classe CSS adéquate (col-x). Et non, comme par magie il ne considérera pas les enfants de mon-input. De plus la répartition cumulée des col-4 et col-8 dans cette ligne dépasse 12.

Voici ce que nous voudrions, tout en ayant un composant (!) :

<div class="row">
  <mon-input></mon-input>
  <mon-input></mon-input>
</div>

Qui donnerait en interprété :

<div class="row">
  <label class="col-2"></label>
  <input class="col-4">
  <label class="col-2"></label>
  <input class="col-4">
</div>

C’est là que nous parlons de décapsuler (unwrap) notre composant pour que son host (bloc parent auto créé) ne viennent pas entraver notre volonté. La technique est simple et pourtant pas évidente à trouver, et au travers du temps, beaucoup de chose ne fonctionnent plus (ancienne version d’Angular) ou pas encore (CSS working draft ou roadmap Angular).

La solution théorique est de considérer l’intérieur de son composant comme un template

<ng-template #tref>
  <label class="col-4"></label>
  <input class="col-8">
</ng-template>

et de le cibler et le mettre en dehors avec la commande :

this.vcRef.createEmbeddedView(this.tpl);

Selon votre implémentation vous aurez peut-être comme résultat de dupliquer votre composant, une fois à l’intérieur et une fois à l’extérieur, du coup je suis parti sur une transformation de l’HTML du composant via un <ng-template #tref> et cibler celui-ci dans ma mécanique avec un :

@ViewChild('tref', { static: true }) tpl;

Ainsi dans votre ngAfterViewInit (ou ngOnInit) vous pourrez utiliser la ligne createEmbeddedView du vcRef (ViewContainerRef).

À noter qu’il faut ajouter dans @Component l’attribut host:style pour qu’il n’affecte plus le dom, car il reste présent malgré tout.

@Component({
  selector: 'mon-input',
  template: '',
  host: { style: "display: none" }
})

Vous me direz que l’on perd la notion de gouttière mais en fait non, on reste en contextes de colonnes, mais vous perdrez le cumul inutile, pour autant qu’il se marque.

Maintenant parlons de cette seconde ligne, toujours en utilisant notre composant mon-input. Vous me direz suffit de lui mettre un <div class= »row »> autour et le c’est fait. C’est pas tout à fait faux, en considérant que l’on lui envoie la nouvelle répartition des colonnes par @Input(), ce que j’ai fait chez moi, pour démo :

<div class="row">
  <mon-input label="Email" [form]="form" [distribution]="[2, 10]"></mon-input>
</div>

Et dans notre HTML nous l’utiliserions ainsi

<ng-template #tref>
  <label class="col-{{ distribution[0] }}"></label>
  <input class="col-{{ distribution[1] }}">
</ng-template>
Nouvelle répartition de la seconde ligne.

Ceci étant dit, nous ne voulons pas mettre à la main le <div class= »row »>, nous voulons que cela se fasse tout seul. Vous allez rire mais nous allons réencapsuler ce que nous venons de décapsuler. À ce stade je n’ai pas trouvé mieux pour le moment.

Pour ce faire c’est plus compliqué, étant un composant nous ne pouvons pas utiliser un sélecteur [mon-selecteur] d’un composant (component), qui se prendrait pour une directive, pour jouer sur le <ng-content> d’un template : <div class= »row »><ng-content></ng-content></div>.

Nous allons devoir utiliser une directive et ce qui est amusant c’est que la directive va appeler un composant pour « composer » notre sortie.

L’idée est que la directive considère le tag (mon-input dans notre cas) sur laquelle elle est comme le template à injecter dans le composant d’encapsulation. Ainsi, nous créons l’encapsulation, on récupère le template, on l’injecte, on peut même le paramétrer et le tour est joué. On passerait de :

<mon-input label="Email" [form]="form" [distribution]="[2, 10]" *bs-row></mon-input>

À ce type de rendu :

<bs-row-container class="row">
  <label class="col-2"></label>
  <input class="col-10">
</bs-row-container>

C’est terrible ! Exactement ce que l’on souhaite avoir.

On peut voir ici en comparaison l’ensemble.

Côté technique vous aurez la directive [bs-row] initiant l’encapsulation, et le component ‘bs-row-container’ ne contenant qu’un template rudimentaire :

@Component({
  selector: 'bs-row-container',
  template: '<ng-container *ngTemplateOutlet="tpl"></ng-container>'
})

Où tpl est renvoyé en attribut @Input par la directive.

On peut ainsi schématiser la solution finale ainsi :

<div class="row">
  <mon-input [distribution]="[2, 4]"></mon-input>
  <mon-input [distribution]="[2, 4]"></mon-input>
</div>
<mon-input [distribution]="[2, 10]" *bs-row></mon-input>

On a donc une ligne de 2 x 6 et une qui totalise 12, c’est propre, minimaliste et conforme 🙂 !


Code complet de l’encapsulation : https://gist.github.com/killan/f88e7723833bd4bb9f8798c178f5cc0b

Sources :

  • https://github.com/angular/angular/issues/7289
  • https://blog.cloudboost.io/creating-structural-directives-in-angular-ff17211c7b28
  • https://stackoverflow.com/questions/35932074/no-provider-for-templateref-ngif-templateref
  • https://stackoverflow.com/questions/41165448/building-a-wrapper-directive-wrap-some-content-component-in-angular2
  • https://stackoverflow.com/questions/60482442/multiple-components-match-node-with-tagname-app-lobby
  • https://stackblitz.com/edit/angular-eumnmt?file=src%2Fapp.ts
  • https://github.com/angular/angular/issues/18877
  • https://www.freecodecamp.org/news/everything-you-need-to-know-about-ng-template-ng-content-ng-container-and-ngtemplateoutlet-4b7b51223691/