Un système de tabs en Angular+PrimeNg

J’ai été amené à réaliser en Vue3 un système de tabs avec une zone d’actions (ZA) qui s’affiche à droite des onglets. Ceci n’étant pas proposé par le composant de base de PrimeVue. À nos claviers !

Schéma de principe

Étant sur mon projet OntologyMyAdmin en Angular et donc avec PrimeNg, c’est à l’inverse de certains articles « Angular vers Vue » que je vais réaliser un système de tabs en Angular sur base de l’expérience en Vue que j’ai mené il y a une bonne année.

En Vue, dans les grandes lignes, sur base d’une props contenant la liste des ids des tabs avec leur options (visible, désactivé, classe CSS, …), on boucle dessus pour générer les onglets et pour générer les slots nommé sur base des ids, du coup la magie opère après l’init et les templates se placent bien dans leur slot avec gestion par le composant tabs. La zone action fonctionne de la même manière avec les templates suffixés « -actions » par exemple. Le code est minimaliste et la logique se trouve dans la données préparée et la boucle de traitement.

En Angular ce n’est pas tout à fait la même musique, on ne peut pas avoir de ng-content au nom dynamique. Pour ne pas vous faire languir, il faut passer par l’exploration des enfants de notre composant (@ContentChildren) par ce qu’on appelle la projection de contenu.

Là où ça se corse c’est : comment ? Il vous faudra une directive ou un composant pour mapper l’annotation afin qu’elle trouve quelque chose, et ce, pas avant votre évènement ngAfterContentInit.

@ContentChildren(TabComponent) tabs!: QueryList<TabComponent>
@ContentChildren(TabActionComponent) tabsAction!: QueryList<TabActionComponent>

Grâce à cette façon de faire, au lieu d’avoir une liste de tabs en input comme en Vue, on pilotera nos onglets par leurs attributs directement. Dans l’exemple ci-dessous vous verrez qu’on lui indique son titre et un id personnalisé pour lier son action (les autres récupérant un id automatique).

<oma-tabs>
  <oma-tab title="struct">
    <h2>Tab sans actions</h2>
  </oma-tab>

  <oma-tab title="items" tabId="items">
    <h2>Tab avec action</h2>
  </oma-tab>
  <oma-tab-action tabId="items">
    <p-button label="actionA" />
  </oma-tab-action>

  <oma-tab title="options" tabId="options">
    <h2>Autre tab avec action</h2>
  </oma-tab>
  <oma-tab-action tabId="options">
    <p-button label="actionB" />
  </oma-tab-action>
</oma-tabs>
Exemple de résultat du système de tabs

En positionnant un ng-content avec l’attribut select du type de votre composant, vous garantissez que seuls ceux-là défini se retrouveront dans cet espace, comme un filtre.

<div class="tabs-actions">
  <div class="flex justify-between">
    <ul class="list-none">
      @for (t of tabs; track t.id; let idx = $index) {
        <li class="inline-block" [ngClass]="{ selected: selectedTabId === t.id }">
          <p-button [label]="t.title" (onClick)="changeTab(t)" />
        </li>
      }
    </ul>
    <div class="text-right self-center">
      <ng-content select="oma-tab-action" />
    </div>
  </div>
  <div>
    <ng-content select="oma-tab" />
  </div>
</div>

Il nous reste à initialiser les valeurs par défaut, qui apparait, etc.

@Input()
selected: number = 0

protected selectedTabId: number | string = 0

ngAfterContentInit() {
  let indexing = 0;
  const fnIndexing = (t: TabCommonComponent) => {
    if (!t.id) {
      t.id = indexing++
    }
  }
  this.tabs.forEach(fnIndexing)
  this.tabsAction.forEach(fnIndexing)

  this.selectedTabId = this.selected || this.tabs.first.id || 0
  const tabActive = this.tabs.find(t => this.selectedTabId === t.id)
  if (tabActive) {
    tabActive.active = true
  }
  const tabActionActive = this.tabsAction.find(t => this.selectedTabId === t.id)
  if (tabActionActive) {
    tabActionActive.active = true
  }
}

Le changement de tab fait suite à l’initialisation, c’est la même chose, on clique, on reset l’état et on active l’onglet voulu et sa zone d’action si disponible.

changeTab(tab: TabComponent) {
  if (this.selectedTabId != tab.id) {
    this.selectedTabId = tab.id

    // Change visibility
    const fnChange = (t: TabCommonComponent) => {
      t.active = t.id === this.selectedTabId
    }
    this.tabs.forEach(fnChange)
    this.tabsAction.forEach(fnChange)

    // TODO on change
  }
}

Notez le TODO qui précise qu’il faudra remonter un événement pour l’host dans le cas où ceci peut avoir un intérêt.

C’est tout, il n’y a rien de plus, c’est aussi simple 🙂 Par contre je peux vous parler d’héritage de composant afin de ne pas se répéter inutilement (DRY).

Héritage de composant

En effet, nous avons 2 composants enfant de notre tabs : le tab lui-même et le tab-action (ZA). Ils ont tout deux besoin d’être activé et d’avoir un id. Seul le tab aura un titre par exemple qui permet de générer son onglet.

<div [ngClass]="{ hidden: !active }">
  <ng-content />
</div>
@Component({
  selector: 'oma-tab-common',
  imports: [
    NgClass
  ],
  templateUrl: "tab-common.component.html"
})
export class TabCommonComponent {
  @Input('tabId') id: number | string = 0
  @Input() active: boolean = false
}

Notez que l’attribut « id » est déjà prévu par HTML, il nous faut donc le nommer autrement, mais en interne vous faites ce que vous voulez.

Maintenant que vous avez votre composant commun, héritons-le !

@Component({
  selector: 'oma-tab',
  imports: [
    NgClass
  ],
  templateUrl: "tab-common.component.html"
})
export class TabComponent extends TabCommonComponent {
  @Input() title: string = ""
}
@Component({
  selector: 'oma-tab-action',
  imports: [
    NgClass
  ],
  templateUrl: "tab-common.component.html"
})
export class TabActionComponent extends TabCommonComponent {

}

Et c’est tout ! À nouveau ^^.

Lazy loading

En complément on peut parler de lazy loading, c’est à dire le fait de ne charger le contenu du tab que si on l’affiche. Ceci permettant, par exemple, de ne pas faire de grosses requêtes dans mon second tab inutilement. Ceci en complément d’un cache par exemple si on compte répéter les aller-retours entre les tabs. On peut aussi considérer la mise à jour d’un tab quand on y revient, c’est à dire qu’au lieu de le masquer/afficher (css hidden) on peut utiliser une directive conditionnelle (@if) afin de relancer le tab (son contenu) à son prochain affichage.

@if ((lazy && active) || !lazy) {
  <div [ngClass]="{ hidden: !active }">
    <ng-content />
  </div>
}

Si on active le côté lazy, le @if jouera le rôle de constucteur/destructeur du composant qui repartira à zéro à chaque chargement de l’onglet.

Mais si on veut que ça n’arrive que la première fois, il faudra ajouter une notion de en cours de chargement/chargé. On peut combiner différentes idées. Et, effectivement, ici, c’est intéressant pour ne pas relancer l’ontologie à chaque fois, mais de la charger quand même au besoin. Mais comment le tab sait qu’il est en cours de chargement ou chargé. On peut juste dire que lors de la première activation, on le note pour ne pas rejouer le @if. Mais on pourrait aller plus loin avec notion de chargement, si l’enfant pouvait nous en informer.

Soit via une services partagé avec Id du système d’onglets, soit via le contenu poussé dans l’onglet piloté en extérieur, les 2 sont valables, tout dépend du besoin.

Signal

Vous l’avez remarqué, le code est à l’ancienne, pas de Signal dedans, juste @Input(), et pour une raison toute bête, Signal ne permet pas de faire ainsi. L’usage de input() génère un InputSignal qui ne peut être modifié en tant que propriété. Du coup une alternative est de transformer input() en model(), un ModelSignal(), qui lui le peut. Au moment d’écrire cet article, j’ai eu quelques déboires avec signal et ses contraintes, ce qui a été résolu et donc je vous met en gist la version signal :), et ici quelques exemple de transformations.

  selected = input(0)
  selectedTabId = signal<number | string>(0)

  ngAfterContentInit() {
    let indexing = 0;
    const fnIndexing = (t: TabCommonComponent) => {
      if (!t.id()) {
        t.id.set(indexing++)
      }
    }
    this.tabs.forEach(fnIndexing)
    this.tabsAction.forEach(fnIndexing)

    this.selectedTabId.set(this.selected() || this.tabs.first.id() || 0)
    const tabActive = this.tabs.find(t => this.selectedTabId() === t.id())
    if (tabActive) {
      tabActive.active.set(true)
    }
    const tabActionActive = this.tabsAction.find(t => this.selectedTabId() === t.id())
    if (tabActionActive) {
      tabActionActive.active.set(true)
    }
  }

  changeTab(tab: TabComponent) {
    if (this.selectedTabId() != tab.id()) {
      this.selectedTabId.set(tab.id())

      // Change visibility
      const fnChange = (t: TabCommonComponent) => {
        t.active.set(t.id() === this.selectedTabId())
      }
      this.tabs.forEach(fnChange)
      this.tabsAction.forEach(fnChange)

      // TODO on change
    }
  }
export class TabCommonComponent {
  id = model<number | string>(0, {
    alias: 'tabId'
  })

  active = model(false)
  lazy = model(false)

  constructor() {
    effect(() => {
      const active = this.active()

      active ? this.onActive.emit(active) : this.onInactive.emit(active)

      if (active) {
        this.lazy.set(false)
      }
    });
  }

  onActive = output<boolean>()
  onInactive = output<boolean>()
}