Cours JavaScript et TypeScript · Module 8: Événements et DOM

Construire des composants interactifs

8 min de lecture
Dans cette leçon4

Au centre de contrôle de Jurassic Park, chaque panneau à l'écran est un composant à part - une carte de dinosaure, une fenêtre d'alerte, un formulaire de rapport. Chacun réagit aux actions de l'opérateur : clics, touches, survol de la souris. Construire de tels composants interactifs est l'un des scénarios les plus fréquents d'utilisation des écouteurs d'événements dans les vraies applications.

Dans cette leçon, tu vas apprendre à créer des composants d'interface autonomes et réutilisables, qui gèrent leurs propres événements - des fenêtres modales aux panneaux au contenu dynamique.

Le problème apparaît quand le composant disparaît. Un panneau fermé par l'opérateur devrait emporter ses capteurs avec lui, sinon, après une heure de travail, le centre de contrôle écoute des centaines de fenêtres que plus personne ne voit.

Composant : la fenêtre modale (Modal)

Une modale est une fenêtre affichée au-dessus de la page, qui bloque l'interaction avec le reste de l'interface. Dans notre parc, nous l'utiliserons pour afficher les détails d'un dinosaure après un clic sur sa carte.

Créer la structure et enregistrer les événements

La fonction createDinosaurModal() prend les données du dinosaure et en construit un élément prêt à l'emploi. Elle commence par la structure HTML.

1function createDinosaurModal(dinosaurData) {
2  // Création de la structure de la modale
3  const modal = document.createElement('div');
4  modal.className = 'dinosaur-modal';
5  modal.innerHTML = `
6    <div class="modal-content">
7      <div class="modal-header">
8        <h2>${dinosaurData.name}</h2>
9        <button class="close-btn">&times;</button>
10      </div>
11      <div class="modal-body">
12        <img src="${dinosaurData.image}" alt="${dinosaurData.name}">
13        <p><strong>Ère :</strong> ${dinosaurData.era}</p>
14        <p><strong>Régime :</strong> ${dinosaurData.diet}</p>
15        <p><strong>Longueur :</strong> ${dinosaurData.length}m</p>
16        <p><strong>Poids :</strong> ${dinosaurData.weight}kg</p>
17        <p>${dinosaurData.description}</p>
18      </div>
19      <div class="modal-footer">
20        <button class="book-tour-btn">Réserver une visite</button>
21        <button class="add-favorite-btn">Ajouter aux favoris</button>
22      </div>
23    </div>
24  `;

L'élément est créé en mémoire et n'est pas encore sur la page. On remplit le gabarit avec les données de la base du laboratoire, mais si elles venaient des utilisateurs, il serait plus sûr de les insérer via textContent, car innerHTML interprète chaque balise. Passons aux premiers écouteurs.

1  // Récupérer les éléments interactifs de la modale
2  const closeBtn = modal.querySelector('.close-btn');
3  const bookTourBtn = modal.querySelector('.book-tour-btn');
4  const addFavoriteBtn = modal.querySelector('.add-favorite-btn');
5
6  // Fermeture de la modale avec le bouton X
7  closeBtn.addEventListener('click', () => {
8    closeModal(modal);
9  });
10
11  // Fermeture par un clic sur le fond (overlay)
12  modal.addEventListener('click', (e) => {
13    if (e.target === modal) {
14      closeModal(modal);
15    }
16  });

L'appel modal.querySelector() ne cherche qu'à l'intérieur de cette modale, il fonctionne donc même avant son insertion dans la page. Un clic sur le contenu bouillonne jusqu'à l'écouteur placé sur modal, mais e.target désigne alors un élément intérieur. La condition e.target === modal n'est vraie que pour un clic sur le fond lui-même. Il reste les actions et le clavier.

1  // Réservation d'une visite
2  bookTourBtn.addEventListener('click', () => {
3    openBookingForm(dinosaurData.id);
4  });
5
6  // Ajout aux favoris avec changement d'état du bouton
7  addFavoriteBtn.addEventListener('click', () => {
8    addToFavorites(dinosaurData.id);
9    addFavoriteBtn.textContent = 'Ajouté aux favoris !';
10    addFavoriteBtn.disabled = true;
11  });
12
13  // Fermeture avec la touche Escape
14  const handleEscape = (e) => {
15    if (e.key === 'Escape') {
16      closeModal(modal);
17      document.removeEventListener('keydown', handleEscape);
18    }
19  };
20  document.addEventListener('keydown', handleEscape);
21
22  return modal;
23}
24
25function closeModal(modal) {
26  modal.classList.add('closing'); // Animation de fermeture
27  setTimeout(() => modal.remove(), 300);
28}

La fonction renvoie la modale prête, et c'est l'appelant qui l'insère dans la page, par exemple avec document.body.appendChild(). La fonction closeModal() ajoute une classe d'animation et supprime l'élément au bout de 300 millisecondes.

Remarque quelques pratiques clés :

  • Fermeture avec Escape - on ajoute un écouteur sur document et on le retire après la fermeture avec la touche Escape (avec les autres façons de fermer, il faut le retirer séparément)
  • Clic sur le fond - on vérifie e.target === modal pour distinguer un clic sur l'overlay d'un clic sur le contenu de la modale
  • Changement d'état du bouton - après l'ajout aux favoris, on désactive le bouton pour empêcher les clics multiples

Trouve le point faible : si l'opérateur ferme la modale avec la croix ou par un clic sur le fond, l'écouteur handleEscape reste sur document. Il garde dans sa closure la modale supprimée, c'est-à-dire exactement la fuite dont nous avons parlé à propos du nettoyage. Tu découvriras la solution à la fin de la leçon.

Composant : l'interrupteur (Toggle Switch)

Un composant simple pour allumer et éteindre les systèmes du parc. Il prend une étiquette et une fonction onChange, qu'il appelle après chaque changement d'état :

1function createToggle(label, onChange) {
2  const container = document.createElement('div');
3  container.className = 'toggle-container';
4  container.innerHTML = `
5    <span class="toggle-label">${label}</span>
6    <button class="toggle-btn" data-active="false">OFF</button>
7  `;
8
9  const btn = container.querySelector('.toggle-btn');
10
11  btn.addEventListener('click', () => {
12    const isActive = btn.dataset.active === 'true';
13    const newState = !isActive;
14
15    btn.dataset.active = String(newState);
16    btn.textContent = newState ? 'ON' : 'OFF';
17    btn.classList.toggle('active', newState);
18
19    // Appeler le callback avec le nouvel état
20    onChange(newState);
21  });
22
23  return container;
24}
25
26// Utilisation - panneau de commande des clôtures
27const fenceToggle = createToggle('Clôture électrique', (active) => {
28  console.log(`Clôture ${active ? 'ACTIVE' : 'DÉSACTIVÉE'}`);
29});
30document.getElementById('controls').appendChild(fenceToggle);

Les attributs data-* ne stockent que du texte, c'est pourquoi on compare avec 'true' et on enregistre String(newState). Le deuxième argument de classList.toggle() impose l'état de la classe au lieu de l'inverser. L'interrupteur ne sait pas qu'il commande une clôture, il se contente de signaler le changement via le callback, donc le même composant peut gérer les portails et l'éclairage.

Composant : les onglets (Tabs)

Les onglets sont un élément indispensable du panneau de surveillance, car ils permettent de passer d'une vue à l'autre entre les différentes zones du parc. On commence par créer un conteneur en deux parties :

1function createTabs(tabsData) {
2  const container = document.createElement('div');
3  container.className = 'tabs-container';
4
5  // En-têtes des onglets
6  const header = document.createElement('div');
7  header.className = 'tabs-header';
8
9  // Contenus des onglets
10  const body = document.createElement('div');
11  body.className = 'tabs-body';

L'en-tête rassemblera les boutons, et le corps les panneaux de contenu. Dans la boucle, chaque onglet reçoit un bouton, un panneau et son propre écouteur.

1  tabsData.forEach((tab, index) => {
2    // Bouton de l'onglet
3    const tabBtn = document.createElement('button');
4    tabBtn.className = 'tab-btn' + (index === 0 ? ' active' : '');
5    tabBtn.textContent = tab.title;
6    tabBtn.dataset.index = index;
7
8    // Panneau de contenu
9    const tabPanel = document.createElement('div');
10    tabPanel.className = 'tab-panel' + (index === 0 ? ' active' : '');
11    tabPanel.innerHTML = tab.content;
12
13    // Clic sur l'onglet
14    tabBtn.addEventListener('click', () => {
15      // Désactiver tous les onglets
16      header.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
17      body.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
18
19      // Activer l'onglet choisi
20      tabBtn.classList.add('active');
21      tabPanel.classList.add('active');
22    });
23
24    header.appendChild(tabBtn);
25    body.appendChild(tabPanel);
26  });
27
28  container.appendChild(header);
29  container.appendChild(body);
30  return container;
31}

Chaque écouteur garde en mémoire, dans sa closure, sa propre paire tabBtn et tabPanel, donc on n'a besoin d'aucun identifiant. Le premier onglet démarre actif grâce à la condition index === 0. Avec un grand nombre d'onglets, tu peux remplacer ces écouteurs par un seul sur l'en-tête, comme dans la leçon sur la délégation. Voici maintenant l'utilisation :

1// Utilisation
2const monitorTabs = createTabs([
3  { title: 'Zone A', content: '<p>T-Rex : statut OK</p>' },
4  { title: 'Zone B', content: '<p>Raptors : actifs</p>' },
5  { title: 'Zone C', content: '<p>Brachiosaure : calme</p>' },
6]);

La fonction ne fait que renvoyer le conteneur prêt, donc il faut encore insérer monitorTabs dans la page avec appendChild().

Motif : le nettoyage du composant

Chaque composant devrait avoir une méthode destroy() qui supprime tous les écouteurs d'événements et évite les fuites de mémoire :

1class ParkWidget {
2  constructor(element) {
3    this.element = element;
4    this.handlers = [];
5    this.init();
6  }
7
8  addListener(target, event, handler) {
9    target.addEventListener(event, handler);
10    this.handlers.push({ target, event, handler });
11  }
12
13  destroy() {
14    // Suppression de tous les écouteurs enregistrés
15    this.handlers.forEach(({ target, event, handler }) => {
16      target.removeEventListener(event, handler);
17    });
18    this.handlers = [];
19    this.element.remove();
20  }
21
22  init() {
23    this.addListener(this.element, 'click', (e) => {
24      console.log('Widget cliqué');
25    });
26    this.addListener(document, 'keydown', (e) => {
27      if (e.key === 'Escape') this.destroy();
28    });
29  }
30}

Conserver les références de tous les écouteurs et les supprimer tous ensemble dans destroy() est la meilleure pratique quand on construit des composants - particulièrement importante dans les applications dynamiques, où les composants sont souvent créés et détruits.

La méthode addListener() enregistre chaque triplet : la cible, le type et la fonction, donc destroy() peut aussi retirer l'écouteur de document. Voilà la solution au point faible de la modale. Le cycle de vie complet d'un composant comporte quatre étapes : la création de l'élément avec createElement, son ajout au DOM avec appendChild, l'enregistrement des écouteurs pour la fermeture et Escape, et enfin destroy(), qui supprime les écouteurs et l'élément lui-même.

Mon conseil : au lieu d'un tableau, tu peux utiliser un seul AbortController par composant et passer son signal à chaque écouteur. destroy() se résume alors à abort() et à la suppression de l'élément. Dans le bac à sable ci-dessous, tu ouvriras et fermeras la modale d'un dinosaure. Dans la prochaine leçon, nous réunirons les bonnes pratiques de gestion des événements en une seule liste.

Retiens bien : un composant est comme un enclos du parc, que tu ouvres avec ses capteurs et que tu ne refermes qu'après avoir éteint le dernier d'entre eux.

Code de cette leçon : index.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <meta charset="UTF-8">
5    <title>Créer des composants interactifs</title>
6    <style>
7        body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
8        .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; }
9        .modal.show { display: flex; align-items: center; justify-content: center; }
10        .modal-content { background: #34495e; padding: 30px; border-radius: 15px; max-width: 500px; width: 90%; }
11        .close-btn { float: right; font-size: 24px; cursor: pointer; }
12        button { padding: 12px 24px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 10px; }
13        button:hover { background: #2980b9; }
14    </style>
15</head>
16<body>
17    <h1>Composants interactifs</h1>
18    <button id="open-modal">Ouvrir la modale du dinosaure</button>
19    <div id="modal" class="modal">
20        <div class="modal-content">
21            <span class="close-btn">&times;</span>
22            <h2>T-Rex</h2>
23            <p>Tyrannosaurus Rex - le roi des dinosaures !</p>
24            <button id="like-btn">J'aime</button>
25        </div>
26    </div>
27    <script>
28        const modal = document.getElementById('modal');
29        const openBtn = document.getElementById('open-modal');
30        const closeBtn = document.querySelector('.close-btn');
31
32        function openModal() {
33            modal.classList.add('show');
34            console.log('Modal ouvert');
35        }
36
37        function closeModal() {
38            modal.classList.remove('show');
39            console.log('Modal closed');
40        }
41
42        openBtn.addEventListener('click', openModal);
43        closeBtn.addEventListener('click', closeModal);
44        modal.addEventListener('click', (e) => {
45            if (e.target === modal) closeModal();
46        });
47
48        document.addEventListener('keydown', (e) => {
49            if (e.key === 'Escape') closeModal();
50        });
51
52        document.getElementById('like-btn').addEventListener('click', () => {
53            alert('You liked T-Rex!');
54        });
55
56        console.log('Système modal prêt');
57    </script>
58</body>
59</html>

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Dans une modale, comment distingue-t-on un clic sur l'arrière-plan (overlay) d'un clic sur le contenu ?

  2. 2. Pourquoi un composant doit-il supprimer ses écouteurs d'événement dans la méthode destroy() ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes du cycle de vie d'une modale (de haut en bas) :

Articles utiles