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

La délégation d'événements

14 min de lecture
Dans cette leçon8

« À Jurassic Park », Ray Arnold désigne le panneau de surveillance central, « on n'installe pas un système d'alarme séparé à côté de chaque dinosaure. Ce serait coûteux et inefficace. À la place, on a un système central de surveillance par secteur, qui gère tous les enclos de sa zone. »

Ray ouvre le schéma du système. « La délégation d'événements fonctionne sur le même principe. Au lieu d'attacher des centaines d'écouteurs d'événements à des éléments individuels, on attache un seul écouteur à leur parent commun. Ce parent "délègue" la gestion aux éléments concernés. »

Qu'est-ce que la délégation d'événements ?

La délégation d'événements est une technique qui exploite la propagation des événements (le bouillonnement, ou bubbling) pour gérer les événements de nombreux éléments grâce à un seul écouteur attaché à leur ancêtre commun. C'est l'une des techniques les plus importantes et les plus efficaces de JavaScript.

Le problème : trop d'écouteurs d'événements

« Voyons le problème », dit Ray en montrant un code dans lequel chaque carte de dinosaure reçoit son propre écouteur, dans une boucle forEach.

1// INEFFICACE - un écouteur séparé pour chaque carte
2const dinosaurCards = document.querySelectorAll('.dinosaur-card');
3
4console.log(`Ajout d'écouteurs à ${dinosaurCards.length} cartes de dinosaures`);
5
6dinosaurCards.forEach((card, index) => {
7  card.addEventListener('click', function() {
8    const dinoId = this.dataset.dinoId;
9    const dinoName = this.dataset.dinoName;
10
11    console.log(`Dinosaure cliqué : ${dinoName} (ID : ${dinoId})`);
12    showDinosaurDetails(dinoId);
13  });
14
15  console.log(`Écouteur ${index + 1}/${dinosaurCards.length} ajouté`);
16});
17
18// Problèmes de cette approche :
19// 1. Si on a 100 cartes = 100 écouteurs en mémoire
20// 2. Les cartes ajoutées dynamiquement N'AURONT PAS d'écouteurs
21// 3. Il faut supprimer les écouteurs à la main quand on supprime des cartes
22// 4. Plus de mémoire consommée et une page plus lente

Ça fonctionne, mais cent cartes, ce sont cent écouteurs, et une carte ajoutée plus tard reste sourde, car la boucle ne s'est exécutée qu'une seule fois.

La solution : la délégation d'événements

« Maintenant, regarde », Ray montre la meilleure solution : un seul écouteur sur la galerie détermine lui-même sur quelle carte on a cliqué.

1// EFFICACE - un seul écouteur sur le conteneur
2const dinosaurGallery = document.getElementById('dinosaur-gallery');
3
4dinosaurGallery.addEventListener('click', function(event) {
5  // Trouver la carte de dinosaure la plus proche (même si on a cliqué sur un enfant de la carte)
6  const card = event.target.closest('.dinosaur-card');
7
8  // Si on n'a pas cliqué sur une carte de dinosaure, ignorer
9  if (!card) return;
10
11  // S'assurer que la carte est un enfant de notre galerie
12  if (!dinosaurGallery.contains(card)) return;
13
14  const dinoId = card.dataset.dinoId;
15  const dinoName = card.dataset.dinoName;
16
17  console.log(`Dinosaure cliqué : ${dinoName} (ID : ${dinoId})`);
18  showDinosaurDetails(dinoId);
19});
20
21// Avantages de cette approche :
22// 1. Un SEUL écouteur, quel que soit le nombre de cartes
23// 2. Gère automatiquement les cartes ajoutées dynamiquement
24// 3. Meilleure gestion de la mémoire
25// 4. Maintenance du code plus facile

Un clic sur l'image d'une carte bouillonne jusqu'à la galerie : event.target est l'élément sur lequel on a cliqué, et event.currentTarget l'élément qui porte l'écouteur, c'est-à-dire la galerie. La méthode closest() part de target, remonte l'arbre et renvoie la carte correspondante ou null, tandis que contains() vérifie que la carte se trouve bien dans la galerie. Toute la technique tient en quatre étapes : trouver le conteneur, lui ajouter un écouteur, vérifier event.target et exécuter l'action pour l'élément correspondant.

Exemple pratique - système de cartes de dinosaures

« Construisons un système complet », dit Ray. Voici la structure d'une carte et le début d'un écouteur qui reconnaît les boutons de détails et de nourrissage.

1// Structure HTML :
2// <div id="dino-gallery">
3//   <div class="dinosaur-card" data-dino-id="1" data-dino-name="T-Rex">
4//     <img src="trex.jpg" alt="T-Rex">
5//     <h3>Tyrannosaurus Rex</h3>
6//     <div class="card-actions">
7//       <button class="view-btn">Détails</button>
8//       <button class="feed-btn">Nourrir</button>
9//       <button class="favorite-btn">★</button>
10//     </div>
11//   </div>
12//   ... d'autres cartes
13// </div>
14
15const gallery = document.getElementById('dino-gallery');
16
17gallery.addEventListener('click', function(event) {
18  const target = event.target;
19
20  // Gérer le bouton de détails
21  if (target.classList.contains('view-btn')) {
22    const card = target.closest('.dinosaur-card');
23    const dinoId = card.dataset.dinoId;
24    const dinoName = card.dataset.dinoName;
25
26    console.log(`Ouverture des détails pour : ${dinoName}`);
27    openDinosaurModal(dinoId);
28    return; // Arrêter la suite des vérifications
29  }
30
31  // Gérer le bouton de nourrissage
32  if (target.classList.contains('feed-btn')) {
33    const card = target.closest('.dinosaur-card');
34    const dinoId = card.dataset.dinoId;
35    const dinoName = card.dataset.dinoName;
36
37    console.log(`Début du nourrissage : ${dinoName}`);
38    feedDinosaur(dinoId);
39    return;
40  }

On lit l'attribut data-dino-id sous la forme dataset.dinoId, car les tirets se transforment en camelCase. Chaque branche se termine par return, donc un clic déclenche une seule action. Viennent ensuite les favoris et la carte elle-même.

1  // Gérer le bouton des favoris
2  if (target.classList.contains('favorite-btn')) {
3    const card = target.closest('.dinosaur-card');
4    const dinoId = card.dataset.dinoId;
5    const dinoName = card.dataset.dinoName;
6
7    // Basculer l'état de favori
8    target.classList.toggle('active');
9
10    if (target.classList.contains('active')) {
11      console.log(`Ajouté aux favoris : ${dinoName}`);
12      addToFavorites(dinoId);
13      target.textContent = '★';
14    } else {
15      console.log(`Retiré des favoris : ${dinoName}`);
16      removeFromFavorites(dinoId);
17      target.textContent = '☆';
18    }
19    return;
20  }
21
22  // Gérer le clic sur la carte elle-même (mais pas sur les boutons)
23  const card = target.closest('.dinosaur-card');
24  if (card && gallery.contains(card)) {
25    const dinoId = card.dataset.dinoId;
26    const dinoName = card.dataset.dinoName;
27
28    console.log(`Carte cliquée : ${dinoName}`);
29    highlightCard(card);
30  }
31});

On vérifie la carte en dernier, sinon un clic sur un bouton la mettrait au passage en surbrillance. La vérification classList.contains suffit, car les boutons ne contiennent que du texte. Une icône à l'intérieur intercepterait le clic, et la condition échouerait. Pour finir, les fonctions utilitaires.

1function openDinosaurModal(dinoId) {
2  console.log(`Chargement des données du dinosaure ID : ${dinoId}`);
3  // Logique d'ouverture de la modale
4}
5
6function feedDinosaur(dinoId) {
7  console.log(`Activation du système de nourrissage pour le dinosaure ID : ${dinoId}`);
8  console.log('Vérification de la sécurité...');
9  console.log('Ouverture du distributeur...');
10  console.log('Nourrissage terminé');
11}
12
13function addToFavorites(dinoId) {
14  const favorites = JSON.parse(localStorage.getItem('favoriteDinosaurs') || '[]');
15  if (!favorites.includes(dinoId)) {
16    favorites.push(dinoId);
17    localStorage.setItem('favoriteDinosaurs', JSON.stringify(favorites));
18    console.log('Enregistré dans les favoris');
19  }
20}
21
22function removeFromFavorites(dinoId) {
23  let favorites = JSON.parse(localStorage.getItem('favoriteDinosaurs') || '[]');
24  favorites = favorites.filter(id => id !== dinoId);
25  localStorage.setItem('favoriteDinosaurs', JSON.stringify(favorites));
26  console.log('Retiré des favoris');
27}
28
29function highlightCard(card) {
30  // Retirer la surbrillance des autres cartes
31  document.querySelectorAll('.dinosaur-card.highlighted').forEach(c => {
32    c.classList.remove('highlighted');
33  });
34
35  // Mettre en surbrillance la carte cliquée
36  card.classList.add('highlighted');
37  console.log('Carte mise en surbrillance');
38}

Les fonctions utilitaires ne savent rien des événements. L'écriture || '[]' sauve la toute première visite, quand getItem() renvoie null.

Ajouter des éléments dynamiquement

« Le plus grand avantage de la délégation », Ray ajoute de nouveaux éléments à l'interface, « c'est la gestion automatique des éléments ajoutés dynamiquement. »

1const gallery = document.getElementById('dino-gallery');
2
3// Délégation d'événements - fonctionne pour toutes les cartes, même futures
4gallery.addEventListener('click', function(event) {
5  const card = event.target.closest('.dinosaur-card');
6  if (!card) return;
7
8  const dinoName = card.dataset.dinoName;
9  console.log(`Cliqué : ${dinoName}`);
10});
11
12// Fonction qui ajoute dynamiquement de nouvelles cartes
13function addNewDinosaur(dinosaur) {
14  const newCard = document.createElement('div');
15  newCard.className = 'dinosaur-card';
16  newCard.dataset.dinoId = dinosaur.id;
17  newCard.dataset.dinoName = dinosaur.name;
18
19  newCard.innerHTML = `
20    <img src="${dinosaur.image}" alt="${dinosaur.name}">
21    <h3>${dinosaur.name}</h3>
22    <p>${dinosaur.species}</p>
23    <div class="card-actions">
24      <button class="view-btn">Détails</button>
25      <button class="feed-btn">Nourrir</button>
26      <button class="favorite-btn">☆</button>
27    </div>
28  `;
29
30  gallery.appendChild(newCard);
31  console.log(`Nouveau dinosaure ajouté : ${dinosaur.name}`);
32
33  // PAS BESOIN d'ajouter des écouteurs d'événements !
34  // La délégation gère automatiquement la nouvelle carte !
35}
36
37// Exemple d'utilisation
38setTimeout(() => {
39  addNewDinosaur({
40    id: '15',
41    name: 'Spinosaurus',
42    species: 'Spinosaurus aegyptiacus',
43    image: 'spinosaurus.jpg'
44  });
45
46  // La nouvelle carte réagit déjà aux clics !
47}, 3000);

Dans la fonction addNewDinosaur(), il n'y a pas un seul addEventListener, et pourtant le Spinosaurus ajouté au bout de trois secondes réagit tout de suite aux clics, car ceux-ci bouillonnent jusqu'à l'écouteur qui attend sur la galerie depuis le début.

Délégation avancée - filtres et conditions

« Parfois », explique Ray, « on a besoin d'un contrôle plus précis. » Chaque bouton du panneau indique par data-action ce qu'il doit faire, et l'écouteur répartit les tâches.

1const parkDashboard = document.getElementById('park-dashboard');
2
3parkDashboard.addEventListener('click', function(event) {
4  const target = event.target;
5
6  // Délégation avec l'attribut data-action
7  const action = target.dataset.action;
8
9  if (action) {
10    console.log(`Action : ${action}`);
11
12    switch(action) {
13      case 'open-gate':
14        handleOpenGate(target);
15        break;
16
17      case 'close-gate':
18        handleCloseGate(target);
19        break;
20
21      case 'start-feeding':
22        handleStartFeeding(target);
23        break;
24
25      case 'emergency-protocol':
26        handleEmergencyProtocol(target);
27        break;
28
29      case 'view-stats':
30        handleViewStats(target);
31        break;
32
33      default:
34        console.log('Action inconnue :', action);
35    }
36
37    return;
38  }

Une nouvelle action, c'est un nouveau case, et non un nouvel écouteur. Quand l'élément n'a pas d'action, l'écouteur vérifie les classes avec la méthode matches() et les balises avec tagName, qui renvoie le nom en majuscules, d'où 'A'.

1  // Délégation avec les classes
2  if (target.matches('.enclosure-zone')) {
3    const enclosureId = target.dataset.enclosureId;
4    console.log(`Zone d'enclos cliquée : ${enclosureId}`);
5    showEnclosureDetails(enclosureId);
6    return;
7  }
8
9  // Délégation avec les balises
10  if (target.tagName === 'A' && target.classList.contains('internal-link')) {
11    event.preventDefault();
12    const href = target.getAttribute('href');
13    console.log(`Navigation interne : ${href}`);
14    navigateInternal(href);
15    return;
16  }
17});
18
19function handleOpenGate(button) {
20  const gateId = button.dataset.gateId;
21  const enclosureId = button.dataset.enclosureId;
22
23  console.log(`Ouverture du portail ${gateId} dans l'enclos ${enclosureId}`);
24  console.log('Vérification du statut de sécurité...');
25  console.log('Portail ouvert');
26}
27
28function handleCloseGate(button) {
29  const gateId = button.dataset.gateId;
30  console.log(`Fermeture du portail ${gateId}`);
31  console.log('Portail fermé et sécurisé');
32}
33
34function handleStartFeeding(button) {
35  const enclosureId = button.dataset.enclosureId;
36  const feedType = button.dataset.feedType;
37
38  console.log(`Début du nourrissage dans l'enclos ${enclosureId}`);
39  console.log(`Type de nourriture : ${feedType}`);
40}
41
42function handleEmergencyProtocol(button) {
43  const protocolId = button.dataset.protocolId;
44
45  console.log("ALARME ! Activation du protocole d'urgence");
46  console.log(`Protocole : ${protocolId}`);
47  console.log('Fermeture de tous les portails...');
48  console.log('Notification du personnel...');
49}

L'appel à preventDefault() bloque la navigation normale du lien, pour que le panneau change de vue lui-même. Chaque gestionnaire lit les données du bouton via dataset : le balisage dit quoi faire, les fonctions savent comment.

Performance - comparaison

Ray montre des tests de performance. « Les chiffres ne mentent pas, mais chacun doit les mesurer chez soi. » Les fonctions console.time() et console.timeEnd() mesurent le temps écoulé entre deux appels qui portent la même étiquette.

1// Test 1 : Sans délégation (écouteurs séparés)
2console.time('Ajout sans délégation');
3
4const cards1 = document.querySelectorAll('.card');
5cards1.forEach(card => {
6  card.addEventListener('click', function() {
7    console.log('Cliqué');
8  });
9});
10
11console.timeEnd('Ajout sans délégation');
12// Le temps augmente avec le nombre de cartes : 100 cartes = 100 appels à addEventListener
13
14// Test 2 : Avec délégation (un seul écouteur)
15console.time('Ajout avec délégation');
16
17const container = document.getElementById('container');
18container.addEventListener('click', function(event) {
19  if (event.target.matches('.card')) {
20    console.log('Cliqué');
21  }
22});
23
24console.timeEnd('Ajout avec délégation');
25// Un seul appel à addEventListener, quel que soit le nombre de cartes
26
27// Consommation de mémoire :
28// Sans délégation : un écouteur et une fonction séparés pour chaque carte
29// Avec délégation : un seul écouteur sur le conteneur
30
31console.log('Écouteurs nécessaires pour 100 cartes :');
32console.log('Sans délégation : 100');
33console.log('Avec délégation : 1');
34console.log('Mesure le temps et la mémoire exacts dans les DevTools de ton navigateur');

Nous ne donnons volontairement ni millisecondes ni kilo-octets, car ils dépendent du navigateur et du matériel. Une chose est sûre : sans délégation, les écouteurs se multiplient avec chaque carte, alors qu'avec la délégation, il n'en reste qu'un seul.

matches() vs closest() - choisir la méthode

« Les deux méthodes les plus importantes pour la délégation », explique Ray. Toutes deux acceptent un sélecteur CSS, mais elles regardent dans des directions différentes de l'arbre.

1const container = document.getElementById('enclosure-list');
2
3container.addEventListener('click', function(event) {
4  const target = event.target;
5
6  // matches() - vérifie si l'élément LUI-MÊME correspond
7  if (target.matches('.enclosure-item')) {
8    console.log('Clic DIRECTEMENT sur .enclosure-item');
9    // Ne fonctionne que si on a cliqué exactement sur l'élément qui porte la classe
10  }
11
12  // closest() - remonte jusqu'au premier élément correspondant
13  const item = target.closest('.enclosure-item');
14  if (item) {
15    console.log('Clic sur .enclosure-item OU sur un de ses enfants');
16    // Fonctionne si on a cliqué sur l'élément ou sur ses enfants
17  }
18});
19
20// HTML:
21// <div class="enclosure-item">
22//   <h3>Enclos du T-Rex</h3>          ← matches() NON, closest() OUI
23//   <p>Statut : Actif</p>             ← matches() NON, closest() OUI
24//   <button>Détails</button>          ← matches() NON, closest() OUI
25// </div>                              ← matches() OUI, closest() OUI
26
27// CONCLUSION : Utilise closest() pour la délégation - c'est plus flexible

La méthode matches() ne vérifie que l'élément lui-même, tandis que closest() commence par lui et remonte ensuite. C'est pourquoi je te recommande closest() comme choix par défaut pour la délégation.

Système complet de gestion du parc

« Réunissons tout dans un système qui fonctionne », dit Ray. Un seul écouteur sur le tableau de bord transmet le clic aux méthodes successives de la classe.

1class JurassicParkManagementSystem {
2  constructor() {
3    this.selectedEnclosures = new Set();
4    this.initialize();
5  }
6
7  initialize() {
8    console.log('Initialisation du système de gestion du parc');
9    this.setupDelegation();
10  }
11
12  setupDelegation() {
13    // Un seul écouteur principal pour tout le tableau de bord
14    const dashboard = document.getElementById('park-dashboard');
15
16    dashboard.addEventListener('click', (event) => {
17      this.handleDashboardClick(event);
18    });
19
20    console.log('Système de délégation actif');
21  }
22
23  handleDashboardClick(event) {
24    const target = event.target;
25
26    // Gérer les différents types de clics
27    this.handleActionButton(target) ||
28    this.handleTabNavigation(target) ||
29    this.handleStatusIndicator(target) ||
30    this.handleEnclosureSelection(target);
31  }

Chaque méthode renvoie true quand elle a pris le clic en charge, et l'opérateur || s'arrête au premier true. C'est pourquoi on interroge d'abord les boutons, et la carte entière en dernier, comme dans la galerie plus haut. La fonction fléchée conserve this comme instance de la classe.

1  handleEnclosureSelection(target) {
2    const enclosure = target.closest('.enclosure-card');
3    if (!enclosure) return false;
4
5    const enclosureId = enclosure.dataset.enclosureId;
6    const enclosureName = enclosure.dataset.enclosureName;
7
8    console.log(`Enclos sélectionné : ${enclosureName} (ID : ${enclosureId})`);
9
10    // Basculer la sélection
11    if (this.selectedEnclosures.has(enclosureId)) {
12      this.selectedEnclosures.delete(enclosureId);
13      enclosure.classList.remove('selected');
14      console.log('Retiré de la sélection');
15    } else {
16      this.selectedEnclosures.add(enclosureId);
17      enclosure.classList.add('selected');
18      console.log('Ajouté à la sélection');
19    }
20
21    this.updateSelectionDisplay();
22    return true;
23  }
24
25  handleActionButton(target) {
26    const action = target.dataset.action;
27    if (!action) return false;
28
29    const enclosure = target.closest('.enclosure-card');
30    const enclosureId = enclosure?.dataset.enclosureId;
31
32    console.log(`Action : ${action} pour l'enclos : ${enclosureId}`);
33
34    const actions = {
35      'feed': () => this.feedDinosaur(enclosureId),
36      'clean': () => this.cleanEnclosure(enclosureId),
37      'maintenance': () => this.scheduleMaintenance(enclosureId),
38      'emergency': () => this.activateEmergency(enclosureId)
39    };
40
41    const handler = actions[action];
42    if (handler) {
43      handler();
44      return true;
45    }
46
47    return false;
48  }

Le Set conserve la sélection sans doublons, l'objet actions remplace un switch, et ?. évite une erreur quand le bouton ne se trouve pas dans une carte. Les onglets et les indicateurs de statut fonctionnent de la même façon.

1  handleTabNavigation(target) {
2    if (!target.classList.contains('tab-button')) return false;
3
4    const tabName = target.dataset.tab;
5    console.log(`Passage à l'onglet : ${tabName}`);
6
7    // Retirer active de tous les onglets
8    document.querySelectorAll('.tab-button').forEach(tab => {
9      tab.classList.remove('active');
10    });
11
12    // Ajouter active à l'onglet cliqué
13    target.classList.add('active');
14
15    // Afficher le contenu correspondant
16    this.showTabContent(tabName);
17    return true;
18  }
19
20  handleStatusIndicator(target) {
21    if (!target.classList.contains('status-indicator')) return false;
22
23    const enclosure = target.closest('.enclosure-card');
24    const enclosureId = enclosure?.dataset.enclosureId;
25
26    console.log(`Affichage des détails du statut pour l'enclos : ${enclosureId}`);
27    this.showStatusDetails(enclosureId);
28    return true;
29  }

Aucun de ces éléments n'a son propre écouteur, car leurs clics arrivent jusqu'au tableau de bord. Pour finir, les méthodes d'exécution et le démarrage du système.

1  feedDinosaur(enclosureId) {
2    console.log(`Nourrissage du dinosaure dans l'enclos : ${enclosureId}`);
3  }
4
5  cleanEnclosure(enclosureId) {
6    console.log(`Nettoyage de l'enclos : ${enclosureId}`);
7  }
8
9  scheduleMaintenance(enclosureId) {
10    console.log(`Planification de la maintenance pour l'enclos : ${enclosureId}`);
11  }
12
13  activateEmergency(enclosureId) {
14    console.log(`ALARME ! Protocole d'urgence pour l'enclos : ${enclosureId}`);
15  }
16
17  showTabContent(tabName) {
18    console.log(`Chargement du contenu de l'onglet : ${tabName}`);
19  }
20
21  showStatusDetails(enclosureId) {
22    console.log(`Détails du statut de l'enclos : ${enclosureId}`);
23  }
24
25  updateSelectionDisplay() {
26    const count = this.selectedEnclosures.size;
27    const display = document.getElementById('selection-counter');
28
29    if (display) {
30      display.textContent = `Sélectionnés : ${count} enclos`;
31    }
32
33    console.log(`Enclos actuellement sélectionnés : ${Array.from(this.selectedEnclosures).join(', ')}`);
34  }
35}
36
37// Initialisation du système
38const parkSystem = new JurassicParkManagementSystem();

Un seul écouteur surveille tout le tableau de bord, quel que soit le nombre d'enclos.

Récapitulatif

« La délégation d'événements », conclut Ray, « n'est pas qu'une optimisation. C'est une technique fondamentale du JavaScript moderne. Un seul écouteur qui gère des milliers d'éléments - c'est comme un système de surveillance central qui gère tout le parc. Efficace, évolutif et facile à maintenir. »

Règles clés :

  • Utilise closest() pour trouver les éléments cibles
  • Vérifie que l'élément est bien un enfant du conteneur qui porte l'écouteur
  • Utilise les attributs data pour les actions et les identifiants
  • Pense au return après avoir géré un événement
  • La délégation fonctionne automatiquement avec les éléments dynamiques
  • Elle économise la mémoire et améliore les performances

Dans le bac à sable, l'écouteur dragstart est attaché à tout le document, donc les dinosaures ajoutés toutes les dix secondes peuvent être glissés immédiatement. Prochaine leçon : les événements souris et clavier.

Retiens bien : un seul capteur au centre de contrôle du secteur entend chaque enclos, y compris celui qu'on a construit hier.

Code de cette leçon : index.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Glisser-déposer - Déplacer les dinosaures</title>
7    <style>
8        body {
9            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
11            margin: 0;
12            padding: 20px;
13            color: white;
14        }
15
16        .container {
17            max-width: 1200px;
18            margin: 0 auto;
19        }
20
21        h1 {
22            text-align: center;
23            margin-bottom: 30px;
24        }
25
26        .instructions {
27            background: rgba(52, 152, 219, 0.2);
28            border: 1px solid #3498db;
29            border-radius: 10px;
30            padding: 20px;
31            margin-bottom: 30px;
32            text-align: center;
33        }
34
35        .enclosures-container {
36            display: grid;
37            grid-template-columns: 1fr 1fr;
38            gap: 30px;
39            margin-bottom: 30px;
40        }
41
42        .enclosure {
43            background: rgba(0, 0, 0, 0.6);
44            border: 3px dashed #7f8c8d;
45            border-radius: 15px;
46            padding: 20px;
47            min-height: 300px;
48            transition: all 0.3s ease;
49        }
50
51        .enclosure.drag-over {
52            border-color: #3498db;
53            background: rgba(52, 152, 219, 0.2);
54            border-style: solid;
55            transform: scale(1.02);
56        }
57
58        .enclosure h3 {
59            text-align: center;
60            margin-bottom: 20px;
61            color: #ecf0f1;
62        }
63
64        .herbivore-enclosure {
65            border-color: #27ae60;
66        }
67
68        .carnivore-enclosure {
69            border-color: #e74c3c;
70        }
71
72        .herbivore-enclosure.drag-over {
73            border-color: #2ecc71;
74            background: rgba(46, 204, 113, 0.2);
75        }
76
77        .carnivore-enclosure.drag-over {
78            border-color: #e74c3c;
79            background: rgba(231, 76, 60, 0.2);
80        }
81
82        .dinosaur-card {
83            background: linear-gradient(45deg, #34495e, #2c3e50);
84            border-radius: 10px;
85            padding: 15px;
86            margin: 10px 0;
87            cursor: grab;
88            transition: all 0.3s ease;
89            border: 2px solid transparent;
90            user-select: none;
91        }
92
93        .dinosaur-card:hover {
94            transform: translateY(-3px);
95            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
96        }
97
98        .dinosaur-card.dragging {
99            opacity: 0.5;
100            transform: rotate(5deg);
101            cursor: grabbing;
102        }
103
104        .dinosaur-card.herbivore {
105            border-color: #27ae60;
106        }
107
108        .dinosaur-card.carnivore {
109            border-color: #e74c3c;
110        }
111
112        .dino-header {
113            display: flex;
114            align-items: center;
115            gap: 10px;
116            margin-bottom: 10px;
117        }
118
119        .dino-emoji {
120            font-size: 24px;
121        }
122
123        .dino-name {
124            font-weight: bold;
125            color: #ecf0f1;
126        }
127
128        .dino-info {
129            font-size: 14px;
130            color: #bdc3c7;
131            margin: 5px 0;
132        }
133
134        .dino-diet {
135            display: inline-block;
136            padding: 3px 8px;
137            border-radius: 12px;
138            font-size: 12px;
139            font-weight: bold;
140        }
141
142        .herbivore .dino-diet {
143            background: #27ae60;
144            color: white;
145        }
146
147        .carnivore .dino-diet {
148            background: #e74c3c;
149            color: white;
150        }
151
152        .drag-instructions {
153            text-align: center;
154            color: #bdc3c7;
155            font-style: italic;
156            margin: 20px 0;
157        }
158
159        .stats-panel {
160            background: rgba(0, 0, 0, 0.8);
161            border-radius: 10px;
162            padding: 20px;
163            margin-top: 30px;
164        }
165
166        .stats-grid {
167            display: grid;
168            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
169            gap: 20px;
170        }
171
172        .stat-item {
173            background: rgba(255, 255, 255, 0.1);
174            padding: 15px;
175            border-radius: 8px;
176            text-align: center;
177        }
178
179        .stat-value {
180            font-size: 24px;
181            font-weight: bold;
182            color: #3498db;
183        }
184
185        .stat-label {
186            color: #bdc3c7;
187            font-size: 14px;
188            margin-top: 5px;
189        }
190
191        .danger-warning {
192            background: #e74c3c;
193            color: white;
194            padding: 10px;
195            border-radius: 8px;
196            margin: 10px 0;
197            text-align: center;
198            font-weight: bold;
199            display: none;
200        }
201
202        @media (max-width: 768px) {
203            .enclosures-container {
204                grid-template-columns: 1fr;
205            }
206        }
207    </style>
208</head>
209<body>
210    <div class="container">
211        <h1>Déplacer les dinosaures entre les enclos</h1>
212
213        <div class="instructions">
214            <strong>Task:</strong> Drag dinosaurs to the appropriate enclosures based on their diet.<br>
215            <span style="color: #27ae60;">■</span> Herbivores → Safe Enclosure |
216            <span style="color: #e74c3c;">■</span> Carnivores → High Security Enclosure
217        </div>
218
219        <div class="enclosures-container">
220            <!-- Enclosure for herbivores -->
221            <div class="enclosure herbivore-enclosure" data-diet="herbivore" id="herbivore-enclosure">
222                <h3>Enclos sécurisé (herbivores)</h3>
223                <div class="drag-instructions">Drag herbivores here</div>
224
225                <!-- Initial herbivore dinosaurs -->
226                <div class="dinosaur-card herbivore" draggable="true" data-dino="triceratops" data-diet="herbivore">
227                    <div class="dino-header">
228                        <span class="dino-emoji">◆</span>
229                        <span class="dino-name">Triceratops</span>
230                    </div>
231                    <div class="dino-info">Length: 9m | Era: Crétacé</div>
232                    <span class="dino-diet">Herbivore</span>
233                </div>
234
235                <div class="dinosaur-card herbivore" draggable="true" data-dino="brachiosaurus" data-diet="herbivore">
236                    <div class="dino-header">
237                        <span class="dino-emoji">◆</span>
238                        <span class="dino-name">Brachiosaurus</span>
239                    </div>
240                    <div class="dino-info">Length: 23m | Era: Jurassique</div>
241                    <span class="dino-diet">Herbivore</span>
242                </div>
243            </div>
244
245            <!-- Enclosure for carnivores -->
246            <div class="enclosure carnivore-enclosure" data-diet="carnivore" id="carnivore-enclosure">
247                <h3>Enclos haute sécurité (carnivores)</h3>
248                <div class="drag-instructions">Drag carnivores here</div>
249                <div class="danger-warning" id="danger-warning">
250                    WARNING! Mixing carnivores and herbivores is dangerous!
251                </div>
252
253                <!-- Initial carnivore dinosaurs -->
254                <div class="dinosaur-card carnivore" draggable="true" data-dino="trex" data-diet="carnivore">
255                    <div class="dino-header">
256                        <span class="dino-emoji">◆</span>
257                        <span class="dino-name">Tyrannosaurus Rex</span>
258                    </div>
259                    <div class="dino-info">Length: 13m | Era: Crétacé</div>
260                    <span class="dino-diet">Carnivore</span>
261                </div>
262
263                <div class="dinosaur-card carnivore" draggable="true" data-dino="velociraptor" data-diet="carnivore">
264                    <div class="dino-header">
265                        <span class="dino-emoji">◆</span>
266                        <span class="dino-name">Velociraptor</span>
267                    </div>
268                    <div class="dino-info">Length: 2m | Era: Crétacé</div>
269                    <span class="dino-diet">Carnivore</span>
270                </div>
271            </div>
272        </div>
273
274        <!-- Panneau de statistiques -->
275        <div class="stats-panel">
276            <h3>Statistiques des enclos</h3>
277            <div class="stats-grid">
278                <div class="stat-item">
279                    <div class="stat-value" id="herbivore-count">2</div>
280                    <div class="stat-label">Herbivores</div>
281                </div>
282                <div class="stat-item">
283                    <div class="stat-value" id="carnivore-count">2</div>
284                    <div class="stat-label">Carnivores</div>
285                </div>
286                <div class="stat-item">
287                    <div class="stat-value" id="total-moves">0</div>
288                    <div class="stat-label">Total transfers</div>
289                </div>
290                <div class="stat-item">
291                    <div class="stat-value" id="safety-status">OK</div>
292                    <div class="stat-label">Security status</div>
293                </div>
294            </div>
295        </div>
296    </div>
297
298    <script>
299        let draggedElement = null;
300        let totalMoves = 0;
301
302        // Get references to elements
303        const herbivoreEnclosure = document.getElementById('herbivore-enclosure');
304        const carnivoreEnclosure = document.getElementById('carnivore-enclosure');
305        const dangerWarning = document.getElementById('danger-warning');
306        const herbivoreCount = document.getElementById('herbivore-count');
307        const carnivoreCount = document.getElementById('carnivore-count');
308        const totalMovesCount = document.getElementById('total-moves');
309        const safetyStatus = document.getElementById('safety-status');
310
311        // Event listeners for dragged element
312        document.addEventListener('dragstart', function(e) {
313            if (e.target.classList.contains('dinosaur-card')) {
314                draggedElement = e.target;
315                e.target.classList.add('dragging');
316
317                console.log(`Started dragging: ${e.target.dataset.dino}`);
318
319                // Configuration des données pour le transfert
320                e.dataTransfer.setData('text/plain', e.target.dataset.dino);
321                e.dataTransfer.effectAllowed = 'move';
322            }
323        });
324
325        document.addEventListener('dragend', function(e) {
326            if (e.target.classList.contains('dinosaur-card')) {
327                e.target.classList.remove('dragging');
328                draggedElement = null;
329            }
330        });
331
332        // Event listeners for drop areas
333        document.querySelectorAll('.enclosure').forEach(enclosure => {
334            enclosure.addEventListener('dragover', function(e) {
335                e.preventDefault(); // Autorisation du drop
336                e.dataTransfer.dropEffect = 'move';
337
338                if (!this.classList.contains('drag-over')) {
339                    this.classList.add('drag-over');
340                }
341            });
342
343            enclosure.addEventListener('dragleave', function(e) {
344                // Check if we're actually leaving the enclosure
345                if (!this.contains(e.relatedTarget)) {
346                    this.classList.remove('drag-over');
347                }
348            });
349
350            enclosure.addEventListener('drop', function(e) {
351                e.preventDefault();
352                this.classList.remove('drag-over');
353
354                if (draggedElement) {
355                    const draggedDiet = draggedElement.dataset.diet;
356                    const targetDiet = this.dataset.diet;
357                    const dinoName = draggedElement.querySelector('.dino-name').textContent;
358
359                    // Vérification si le dinosaur correspond à l'enclosure
360                    if (draggedDiet === targetDiet) {
361                        // Déplacement de l'élément
362                        this.appendChild(draggedElement);
363                        totalMoves++;
364
365                        console.log(`${dinoName} déplacé vers l'enclosure appropriée !`);
366
367                        // Animation de succès
368                        const movedCard = draggedElement; // dragend remet draggedElement à null avant la fin de la seconde
369                        movedCard.style.border = '2px solid #27ae60';
370                        setTimeout(() => {
371                            movedCard.style.border = '';
372                        }, 1000);
373
374                    } else {
375                        // Invalid transfer
376                        console.log(`ERROR: ${dinoName} cannot be in this enclosure!`);
377
378                        // Error animation
379                        const refusedCard = draggedElement;
380                        refusedCard.style.border = '2px solid #e74c3c';
381                        refusedCard.style.background = 'rgba(231, 76, 60, 0.3)';
382
383                        setTimeout(() => {
384                            refusedCard.style.border = '';
385                            refusedCard.style.background = '';
386                        }, 1000);
387
388                        // Show warning
389                        alert(`WARNING!\n\n${dinoName} cannot be placed in this enclosure!\n${draggedDiet === 'carnivore' ? 'Carnivores' : 'Herbivores'} doivent être dans l'enclos approprié pour la sécurité du parc.`);
390                    }
391
392                    updateStats();
393                }
394            });
395        });
396
397        // Fonction de mise à jour des statistiques
398        function updateStats() {
399            const herbivores = document.querySelectorAll('#herbivore-enclosure .dinosaur-card').length;
400            const carnivores = document.querySelectorAll('#carnivore-enclosure .dinosaur-card').length;
401
402            herbivoreCount.textContent = herbivores;
403            carnivoreCount.textContent = carnivores;
404            totalMovesCount.textContent = totalMoves;
405
406            // Vérification de la security
407            const herbivoreEnclosureHasCarnivores = document.querySelectorAll('#herbivore-enclosure .carnivore').length > 0;
408            const carnivoreEnclosureHasHerbivores = document.querySelectorAll('#carnivore-enclosure .herbivore').length > 0;
409
410            const isDangerous = herbivoreEnclosureHasCarnivores || carnivoreEnclosureHasHerbivores;
411
412            if (isDangerous) {
413                safetyStatus.textContent = '!';
414                safetyStatus.style.color = '#e74c3c';
415                dangerWarning.style.display = 'block';
416            } else {
417                safetyStatus.textContent = 'OK';
418                safetyStatus.style.color = '#27ae60';
419                dangerWarning.style.display = 'none';
420            }
421
422            // Check if all dinosaurs are in the correct enclosures
423            const allInCorrectEnclosures = !isDangerous;
424            if (allInCorrectEnclosures && totalMoves > 0) {
425                setTimeout(() => {
426                    alert('Congratulations!\n\nAll dinosaurs are now in the correct enclosures.\nThe park is safe!');
427                }, 500);
428            }
429        }
430
431        // Ajout de nouveaux dinosaurs après quelques secondes
432        function addNewDinosaur() {
433            const newDinos = [
434                {
435                    name: 'Stegosaurus',
436                    emoji: '',
437                    diet: 'herbivore',
438                    length: '9m',
439                    era: 'Jurassique'
440                },
441                {
442                    name: 'Allosaurus',
443                    emoji: '',
444                    diet: 'carnivore',
445                    length: '9m',
446                    era: 'Jurassique'
447                }
448            ];
449
450            const randomDino = newDinos[Math.floor(Math.random() * newDinos.length)];
451
452            const newDinoCard = document.createElement('div');
453            newDinoCard.className = `dinosaur-card ${randomDino.diet}`;
454            newDinoCard.draggable = true;
455            newDinoCard.dataset.dino = randomDino.name.toLowerCase();
456            newDinoCard.dataset.diet = randomDino.diet;
457
458            newDinoCard.innerHTML = `
459                <div class="dino-header">
460                    <span class="dino-emoji">${randomDino.emoji}</span>
461                    <span class="dino-name">${randomDino.name}</span>
462                </div>
463                <div class="dino-info">Length: ${randomDino.length} | Era: ${randomDino.era}</div>
464                <span class="dino-diet">${randomDino.diet === 'herbivore' ? 'Herbivore' : 'Carnivore'}</span>
465            `;
466
467            // Add to incorrect enclosures for challenge
468            if (randomDino.diet === 'herbivore') {
469                carnivoreEnclosure.appendChild(newDinoCard);
470            } else {
471                herbivoreEnclosure.appendChild(newDinoCard);
472            }
473
474            updateStats();
475
476            console.log(`Nouveau dinosaure ajouté: ${randomDino.name}`);
477        }
478
479        // Ajout d'un nouveau dinosaure toutes les 10 secondes
480        setInterval(addNewDinosaur, 10000);
481
482        // Initial statistics update
483        updateStats();
484
485        // Touch events for mobile devices (basic support)
486        let touchItem = null;
487        let touchOffset = { x: 0, y: 0 };
488
489        document.addEventListener('touchstart', function(e) {
490            if (e.target.classList.contains('dinosaur-card')) {
491                touchItem = e.target;
492                const touch = e.touches[0];
493                const rect = touchItem.getBoundingClientRect();
494
495                touchOffset.x = touch.clientX - rect.left;
496                touchOffset.y = touch.clientY - rect.top;
497
498                touchItem.style.position = 'fixed';
499                touchItem.style.zIndex = '1000';
500                touchItem.classList.add('dragging');
501
502                e.preventDefault();
503            }
504        });
505
506        document.addEventListener('touchmove', function(e) {
507            if (touchItem) {
508                const touch = e.touches[0];
509                touchItem.style.left = (touch.clientX - touchOffset.x) + 'px';
510                touchItem.style.top = (touch.clientY - touchOffset.y) + 'px';
511
512                e.preventDefault();
513            }
514        });
515
516        document.addEventListener('touchend', function(e) {
517            if (touchItem) {
518                const touch = e.changedTouches[0];
519                const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
520                const enclosure = elementBelow ? elementBelow.closest('.enclosure') : null;
521
522                if (enclosure) {
523                    draggedElement = touchItem;
524
525                    // Simulation de l'event drop
526                    const dropEvent = new Event('drop');
527                    enclosure.dispatchEvent(dropEvent);
528                }
529
530                // Reset styles
531                touchItem.style.position = '';
532                touchItem.style.left = '';
533                touchItem.style.top = '';
534                touchItem.style.zIndex = '';
535                touchItem.classList.remove('dragging');
536
537                touchItem = null;
538                draggedElement = null;
539            }
540        });
541    </script>
542</body>
543</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. Quels sont les principaux avantages de la délégation d'événements ?

  2. 2. Quelle est la différence entre event.target et event.currentTarget ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Le fichier index.html contient une liste de cartes de dinosaures #dino-list et un bouton qui ajoute une nouvelle carte après le chargement de la page. Utilise la délégation d'événements : remplace ___BLANK1___ par la variable du conteneur qui porte le seul écouteur click, et ___BLANK2___ par la méthode qui part de event.target et cherche l'élément le plus proche correspondant au sélecteur .dino-card. Un clic sur une carte (aussi à l'intérieur et sur une carte ajoutée plus tard) doit afficher le nom du dinosaure dans #selected-dino, et la carte de la réserve, hors de la liste, ne doit pas réagir.

  • Tri vertical

    Place dans l'ordre les étapes de configuration de la délégation d'événements :

  • Tri horizontal

    Place les éléments de la délégation d'événements :

Articles utiles