Créer un composant Tabs avec Angular
Récemment j'ai eu besoin de créer un composant Tabs pour une vue avec des onglets. Ce n'est pas très compliqué, mais il y a quelques trucs intéressants à faire pour avoir des éléments très simples à manipuler en tant que consommateur du composant, avec peu d'effort de maintenance ensuite.
Note : pour cet article j'ai créé un projet Angular en version 21 et j'ai décidé de suivre la nouvelle convention de nommage des fichiers (sans les suffixes
.component,.service, etc.), vous êtes évidemment libre de suivre l'ancienne convention.
Tout le code montré ici est disponible dans un projet prêt à lancer ici : https://github.com/kuroidoruido/ng-tabs-sample
Le style🔗
Je ne vais pas m'attarder sur la partie style. Pas que ça n'ait aucun intérêt, mais il y a pléthore d'exemples et finalement ce n'est pas très intéressant à détailler ici à mon sens. Je vais donc faire un style assez minimaliste (et un peu incomplet) mais suffisant pour que ça fonctionne !
Le HTML :
<section class="tab-group">
<nav class="tab-headers">
<ul>
<li class="tab-header"><button data-tab="tab-1">Tab 1</button></li>
<li class="tab-header active"><button data-tab="tab-2">Tab 2</button></li>
<li class="tab-header"><button data-tab="tab-3">Tab 3</button></li>
</ul>
</nav>
<section class="tab-content" id="tab-1">
...
</section>
<section class="tab-content active" id="tab-2">
...
</section>
<section class="tab-content" id="tab-3">
...
</section>
</section>HTML
Le style qui va avec :
section.tab-group {
--color--gray: #ccc;
--color--gray-dark: #999;
--border--color: var(--color--gray);
--border--width: 1px;
nav.tab-headers {
ul {
display: flex;
list-style: none;
padding: 0;
margin: 0;
border-bottom: var(--border--width) solid var(--border--color);
}
.tab-header {
border: var(--border--width) solid var(--border--color);
border-radius: 0.5rem 0.5rem 0 0;
border-bottom-color: white;
padding: 0;
margin-bottom: calc(-1 * var(--border--width));
button {
padding: 0.5rem 1.5rem;
width: 100%;
height: 100%;
background: none;
border: none;
cursor: pointer;
}
&:hover {
border-color: var(--color--gray-dark);
}
&:not(.active) {
background-color: var(--border--color);
border-bottom-color: var(--border--color);
button {
color: black;
}
}
}
}
.tab-content {
display: none;
border: var(--border--width) solid var(--border--color);
border-top: none;
border-radius: 0 0 0.5rem 0.5rem;
padding: 0.5rem 1rem;
& > :first-child {
margin-top: 0;
padding-top: 0;
}
& > :last-child {
margin-bottom: 0;
padding-bottom: 0;
}
&.active {
display: block;
}
}
}CSS
Maintenant on passe à ce qui est intéressant !
Côté utilisation🔗
J'ai fait quelque chose d'assez simple côté DOM, mais je n'ai pas trop ce genre de code sur le tous les jours en mode composant : ça devient vite un énorme monolithe, avec la logique des onglets perdue au milieu de la logique métier, quand on a pas plusieurs occurrences du composant avec de la duplication… Et bonus : dans 2-3 ans, le style va évoluer et il va falloir repasser sur toutes les occurrences du composant pour changer, en prenant en compte les "quelques petites améliorations/corrections" locales à chaque occurrence…
Bref : si je travaille avec un framework, je n'utilise pas directement le HTML, je vais cacher ça dans un composant avec un DSL propre qui s'adapte bien à mon projet (pas forcément tous les projets). C'est aussi vrai pour pas mal de briques du genre.
Voilà ce que je veux écrire :
<app-tab-group default="tab-2">
<nav app-tab-headers>
<li app-tab="tab-1">Tab 1</li>
<li app-tab="tab-2">Tab 2</li>
<li app-tab="tab-3">Tab 3</li>
</nav>
<section app-tab-content="tab-1">
...
</section>
<section app-tab-content="tab-2">
...
</section>
<section app-tab-content="tab-3">
...
</section>
</app-tab-group>HTML
Ce n'est pas si différent que ça de la version HTML pure. Ça va me permettre de ne pas ajouter d'élément dans le DOM en plus de ce qui est requis, car certains styles d'entreprise utilisent des sélecteurs du type .tab-group > .tab-headers (pour cibler le .tab-headers qui est l'enfant direct de .tab-group), donc vous ne pouvez pas intercaler d'éléments.
On reste un peu proche du DOM d'origine avec les <li> et <section> qui sont toujours là, mais on s'est débarrassé de toutes les classes et on va pouvoir gérer facilement un peu tout proprement chacun de son côté !
Construisons les composants🔗
La première étape c'est de construire les différents composants :
<app-tab-group>: qui sera notre composant parent ;[app-tab-headers]: qui va contenir nos titres d'onglet ;[app-tab]: qui va gérer chaque titre d'onglet individuellement ;[app-tab-content]: qui va gérer le contenu de chaque onglet ;
Les rôles sont bien divisés. Chaque composant a un rôle bien défini et on va pouvoir faire des petits composants !
Personnellement ce genre de brique réutilisable plutôt générique, je les place dans un dossier shared/components (ou quelque chose de similaire en fonction des conventions de l'équipe / du projet), avec un fichier index.ts pour directement pouvoir importer un peu partout nos différentes briques. L'intérêt du index.ts ici c'est qu'on va pouvoir assez facilement refactorer / déplacer nos composants / fichiers sans forcément avoir des changements d'import partout dans l'application (ça évite donc du bruit dans les relectures de code).
// src/app/shared/components/tabs/index.ts
export * from './tab-group'
export * from './tab-headers'
export * from './tab'
export * from './tab-content';TypeScript
Pour le composant app-tab-group c'est simplement un composant conteneur qui ne fait rien (pour l'instant en tout cas) :
// src/app/shared/components/tabs/tab-group.ts
import { Component, input } from "@angular/core";
@Component({
selector: "app-tab-group",
template: `
<section class="tab-group">
<ng-content />
</section>`,
})
export class TabGroup {
default = input.required<string>();
}TypeScript
On voit le paramètre default qui est défini mais on ne s'en sert pas pour l'instant.
Les composants app-tab-headers et app-tab sont assez similaires à part que leur sélecteur se base sur un attribut et pas un élément, et qu'on va retrouver l'utilisation de host pour définir des attributs sur l'élément qui va porter l'attribut :
// src/app/shared/components/tabs/tab-headers.ts
import { Component } from "@angular/core";
@Component({
selector: "[app-tab-headers]",
host: {
"[class.tab-headers]": "true",
},
template: `
<nav class="tab-headers">
<ul>
<ng-content />
</ul>
</nav>`,
})
export class TabHeaders {}TypeScript
// src/app/shared/components/tabs/tab.ts
import { Component, input } from "@angular/core";
@Component({
selector: "[app-tab]",
host: {
"[class.tab-header]": "true",
"[attr.data-tab]": "tab()",
},
template: `
<button [attr.data-tab]="tab()">
<ng-content />
</button>
`,
})
export class Tab {
tab = input.required<string>({ alias: "app-tab" });
}TypeScript
Note : on aurait pu se passer de l'alias sur l'input
tabmais je trouve ça plus simple d'écrire<li app-tab="tab-1">Tab 1</li>que d'écrire<li app-tab tab="tab-1">Tab 1</li>.
On retrouve ensuite app-tab-content qui est assez simple avec rien d'original par rapport aux autres composants :
// src/app/shared/components/tabs/tab-content.ts
import { Component, input } from "@angular/core";
@Component({
selector: "[app-tab-content]",
host: {
"[class.tab-content]": "true",
"[class.active]": "true",
"[attr.id]": "tab()",
},
template: "<ng-content />",
})
export class TabContent {
tab = input.required<string>({ alias: "app-tab-content" });
}TypeScript
En l'état ça ne fonctionne évidemment pas, et on voit à la fois tout le contenu et tous les onglets en version inactive.
Créer un état partagé entre tout le monde🔗
Maintenant on se pose et on réfléchit : on a besoin qu'à chaque clic sur un onglet, on indique aux contenus qu'ils doivent se masquer / s'afficher. Donc il faut que les composants app-tab puissent communiquer avec les composants app-tab-content.
L'option de base serait d'avoir un service global qui gère tous les onglets et tous les groupes d'onglets, garde un identifiant pour chaque, se débrouille pour tout track, et nettoyer quand y'a besoin, etc. Mais non : ça parait simple mais en fait c'est plein de complexités…
On pourrait se dire que quelque chose comme NgRx ou un signal store pourrait être une bonne solution, mais le premier pose une grande partie des mêmes problèmes que le service global, le second n'apportera pas grand-chose de plus…
Alors on fait comment ? On va utiliser un service qu'on provide nous-même avec un scope restreint à notre groupe d'onglet : à la création de <app-tab-group>, on veut une nouvelle instance de notre service, et que cette instance soit partagée uniquement avec les composants enfants. C'est facile, et vraiment : c'est pas compliqué !
Commençons par créer notre service :
// src/app/shared/components/tabs/tab-state.ts
import { computed, Injectable, Signal, signal } from "@angular/core";
@Injectable()
export class TabState {
private _defaultTab = signal<string | null>(null);
private _activeTab = signal<string | null>(null);
public setActiveTab(tabId: string) {
this._activeTab.set(tabId ?? this._defaultTab());
}
public setDefaultTab(tabId: string) {
this._defaultTab.set(tabId);
if (this._activeTab() == null) {
this._activeTab.set(tabId);
}
}
public isActive(tabId: Signal<string>): Signal<boolean> {
return computed(() => this._activeTab() === tabId());
}
}TypeScript
Ici mon service TabState va porter l'état d'un seul groupe d'onglet, donc on a besoin que de deux choses : garder en mémoire l'onglet par défaut et garder en mémoire l'onglet actif. Le reste c'est des méthodes utilitaires pour s'assurer qu'on garde bien encapsulé les signaux.
Vous noterez que contrairement à ce qu'on fait habituellement : je **n'**ai pas mis de { providedIn: 'root' } dans l'annotation @Injectable(). Avoir un { providedIn: 'root' } entrainerait que le service serait global, or ce n'est pas ce qu'on veut, on peut nous-mêmes indiquer le provide.
C'est là que la magie opère :
// src/app/shared/components/tabs/tab-group.ts
@Component({
...
providers: [TabState]
})
export class TabGroup {
private readonly tabState = inject(TabState);
default = input.required<string>();
constructor() {
effect(() => this.tabState.setDefaultTab(this.default()));
}
}TypeScript
Ici on va ajouter notre service comme provider, ce qui va lier une instance du service TabState à chaque instance de TabGroup. Ensuite on peut l'injecter comme n'importe quel autre service. On en profite pour définir notre onglet par défaut via un effect dans le constructor.
Ensuite on peut rendre dynamique nos autres composants :
// src/app/shared/components/tabs/tab.ts
@Component({
selector: "[app-tab]",
host: {
"[class.tab-header]": "true",
"[class.active]": "isActive()",
"[attr.data-tab]": "tab()",
},
template: `
<button [attr.data-tab]="tab()" (click)="tabState.setActiveTab(tab())">
<ng-content />
</button>
`,
})
export class Tab {
protected readonly tabState = inject(TabState);
tab = input.required<string>({ alias: "app-tab" });
protected readonly isActive = this.tabState.isActive(this.tab);
}TypeScript
// src/app/shared/components/tabs/tab.ts
@Component({
selector: "[app-tab-content]",
host: {
"[class.tab-content]": "true",
"[class.active]": "isActive()",
"[attr.id]": "tab()",
},
template: "<ng-content />",
})
export class TabContent {
protected readonly tabState = inject(TabState);
tab = input.required<string>({ alias: "app-tab-content" });
protected readonly isActive = this.tabState.isActive(this.tab);
}TypeScript
Conclusion🔗
Faire des briques dans ce genre implique souvent la création d'une grosse usine compliqué à cause de la méconnaissance du framework. C'est finalement assez facile d'avoir un état partagé à un groupe de composant (encore plus depuis les standalone component !), assez peu verbeux quand on travaille en Single File Component et avec des signaux comme je l'ai fait.
Si finalement le code n'est pas très compliqué, je trouve que c'est le genre de mécanique qui peut se transposer facilement à des briques plus complexes !
Sources :
Crédit photo : Générée via Mistral AI avec le prompt suivant :
Studio Ghibli-inspired magical workshop: a wooden desk with old grimoires and floating holographic screens showing a modern Angular Tabs component with "Sortilèges", "Ingrédients", and "Recettes" tabs. An axolotl in a lab coat watches nearby, surrounded by golden light and maple leaves. Warm, dreamy autumn lighting, soft textures, 8K, detailed.