Cours Vue.js · Module 9: Composables et VueUse

Projet : Lab Manager avec composables

7 min de lecture
Dans cette leçon4

Un système complet de gestion du laboratoire NOVA LAB qui utilise toutes les techniques apprises ! Dr. Nova a besoin d'un panneau qui récupère la liste des expériences depuis le serveur, permet de la rechercher, de la filtrer et de la trier, découpe les résultats en pages et permet de supprimer des entrées. Si tu écrivais tout cela dans un seul composant, tu obtiendrais plusieurs centaines de lignes où la récupération des données se mélange à la pagination. À la place, nous allons construire trois composables, chacun avec une seule tâche, et un composant qui les relie. Les données suivent un seul chemin : useExperiments() récupère et stocke les expériences, useLabFilters(experiments) les filtre, usePagination(filtered) découpe le résultat en pages, et le template affiche paginatedItems.

La couche de données : useExperiments

Le premier fichier, composables/useExperiments.js, s'occupe des données. Il retourne l'état, les indicateurs loading et error ainsi que les fonctions CRUD - de l'anglais Create, Read, Update, Delete, c'est-à-dire créer, lire, mettre à jour et supprimer. Les valeurs dérivées, comme la liste des expériences critiques et le nombre de catégories, sont calculées par computed :

1// composables/useExperiments.js
2import { ref, computed } from 'vue'
3
4export function useExperiments() {
5  const experiments = ref([])
6  const loading = ref(false)
7  const error = ref(null)
8
9  const criticalExperiments = computed(() =>
10    experiments.value.filter(exp => exp.critical)
11  )
12
13  const categoriesCount = computed(() =>
14    new Set(experiments.value.map(exp => exp.category)).size
15  )
16
17  async function fetchExperiments() {
18    loading.value = true
19    error.value = null
20
21    try {
22      const response = await fetch('/api/mars/experiments')
23      if (!response.ok) throw new Error('Erreur de récupération')
24
25      experiments.value = await response.json()
26    } catch (e) {
27      error.value = e.message
28    } finally {
29      loading.value = false
30    }
31  }
32
33  async function addExperiment(experiment) {
34    const response = await fetch('/api/mars/experiments', {
35      method: 'POST',
36      headers: { 'Content-Type': 'application/json' },
37      body: JSON.stringify(experiment)
38    })
39
40    const newExperiment = await response.json()
41    experiments.value.push(newExperiment)
42  }
43
44  async function deleteExperiment(id) {
45    await fetch(`/api/mars/experiments/${id}`, { method: 'DELETE' })
46    experiments.value = experiments.value.filter(exp => exp.id !== id)
47  }
48
49  return {
50    experiments,
51    loading,
52    error,
53    criticalExperiments,
54    categoriesCount,
55    fetchExperiments,
56    addExperiment,
57    deleteExperiment
58  }
59}

Nous avons la lecture, la création et la suppression ; tu ajouteras la mise à jour pour aller plus loin. Il vaut aussi la peine d'ajouter tout de suite deux choses vues dans les leçons précédentes : une vérification de response.ok dans addExperiment et un AbortController qui annule la récupération au démontage.

Le filtrage : useLabFilters

Le deuxième composable prend un paramètre - une ref avec la liste des expériences - et en construit une version filtrée et triée. Il ne sait rien du serveur, donc il fonctionne avec n'importe quelle liste dont les éléments ont les champs name et category :

1// composables/useLabFilters.js
2import { ref, computed } from 'vue'
3
4export function useLabFilters(experiments) {
5  const searchQuery = ref('')
6  const selectedCategory = ref('')
7  const sortBy = ref('name')
8  const sortOrder = ref('asc')
9
10  const filteredExperiments = computed(() => {
11    let result = experiments.value
12
13    // Search
14    if (searchQuery.value) {
15      const query = searchQuery.value.toLowerCase()
16      result = result.filter(exp =>
17        exp.name.toLowerCase().includes(query) ||
18        exp.category.toLowerCase().includes(query)
19      )
20    }
21
22    // Filter by category
23    if (selectedCategory.value) {
24      result = result.filter(exp => exp.category === selectedCategory.value)
25    }
26
27    // Sort
28    result = [...result].sort((a, b) => {
29      const aVal = a[sortBy.value]
30      const bVal = b[sortBy.value]
31      const order = sortOrder.value === 'asc' ? 1 : -1
32
33      if (typeof aVal === 'string') {
34        return aVal.localeCompare(bVal) * order
35      }
36      return (aVal - bVal) * order
37    })
38
39    return result
40  })
41
42  const uniqueCategories = computed(() =>
43    [...new Set(experiments.value.map(exp => exp.category))].sort()
44  )
45
46  function resetFilters() {
47    searchQuery.value = ''
48    selectedCategory.value = ''
49    sortBy.value = 'name'
50    sortOrder.value = 'asc'
51  }
52
53  return {
54    searchQuery,
55    selectedCategory,
56    sortBy,
57    sortOrder,
58    filteredExperiments,
59    uniqueCategories,
60    resetFilters
61  }
62}

Remarque le [...result].sort : la méthode sort modifie un tableau sur place, donc sans la copie nous trierions la liste d'origine de useExperiments. Les chaînes sont comparées avec localeCompare, et les nombres, comme l'année, par simple soustraction.

La pagination : usePagination

Le troisième composable découpe n'importe quelle liste en pages. Le nombre de pages est calculé par un computed avec Math.ceil, et la tranche courante par slice :

1// composables/usePagination.js
2import { ref, computed } from 'vue'
3
4export function usePagination(items, itemsPerPage = 12) {
5  const currentPage = ref(1)
6  const perPage = ref(itemsPerPage)
7
8  const totalPages = computed(() =>
9    Math.ceil(items.value.length / perPage.value)
10  )
11
12  const paginatedItems = computed(() => {
13    const start = (currentPage.value - 1) * perPage.value
14    const end = start + perPage.value
15    return items.value.slice(start, end)
16  })
17
18  function goToPage(page) {
19    if (page >= 1 && page <= totalPages.value) {
20      currentPage.value = page
21    }
22  }
23
24  function nextPage() {
25    goToPage(currentPage.value + 1)
26  }
27
28  function prevPage() {
29    goToPage(currentPage.value - 1)
30  }
31
32  return {
33    currentPage,
34    perPage,
35    totalPages,
36    paginatedItems,
37    goToPage,
38    nextPage,
39    prevPage
40  }
41}

goToPage ignore les numéros hors limites. Un piège : si tu es sur la page 3 et que tu restreins le filtre à une seule page de résultats, currentPage reste à 3 et la liste s'affiche vide. La correction tient en une ligne dans le composable, plus un import de watch : watch(items, () => { currentPage.value = 1 }).

Le composant LabManager

Il est temps de relier les modules dans le panneau. La première partie du template est l'en-tête avec les compteurs et le panneau de filtres, dans lequel v-model lie les champs du formulaire aux refs de useLabFilters :

1<!-- LabManager.vue -->
2<template>
3  <div class="lab-manager">
4    <!-- Header -->
5    <header>
6      <h1>NOVA LAB - Gestion des expériences</h1>
7      <p>{{ categoriesCount }} catégories, {{ experiments.length }} expériences</p>
8    </header>
9
10    <!-- Filters -->
11    <aside class="filters">
12      <input v-model="searchQuery" placeholder="Rechercher des expériences..." />
13
14      <select v-model="selectedCategory">
15        <option value="">Toutes les catégories</option>
16        <option v-for="category in uniqueCategories" :key="category" :value="category">
17          {{ category }}
18        </option>
19      </select>
20
21      <select v-model="sortBy">
22        <option value="name">Nom</option>
23        <option value="category">Catégorie</option>
24        <option value="year">Année</option>
25      </select>
26
27      <button @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
28        {{ sortOrder === 'asc' ? '↑' : '↓' }}
29      </button>
30
31      <button @click="resetFilters">Réinitialiser</button>
32    </aside>

Chaque modification du champ de recherche recalcule immédiatement filteredExperiments, puis paginatedItems - sans une seule ligne de code de gestion d'événements. La deuxième partie affiche l'état de chargement, la grille de cartes et la pagination. On laisse de côté le composant ExperimentCard - il affiche la carte d'une expérience et émet un événement delete :

1    <!-- Loading/Error -->
2    <div v-if="loading">Chargement des expériences...</div>
3    <div v-else-if="error">Erreur : {{ error }}</div>
4
5    <!-- Lab Dashboard -->
6    <main v-else class="lab-grid">
7      <ExperimentCard
8        v-for="experiment in paginatedItems"
9        :key="experiment.id"
10        :experiment="experiment"
11        @delete="deleteExperiment(experiment.id)"
12      />
13    </main>
14
15    <!-- Pagination -->
16    <footer class="pagination">
17      <button @click="prevPage" :disabled="currentPage === 1">
18        Précédent
19      </button>
20
21      <button
22        v-for="page in totalPages"
23        :key="page"
24        @click="goToPage(page)"
25        :class="{ active: currentPage === page }"
26      >
27        {{ page }}
28      </button>
29
30      <button @click="nextPage" :disabled="currentPage === totalPages">
31        Suivant
32      </button>
33    </footer>
34  </div>
35</template>

v-for="page in totalPages" parcourt un nombre, donc il donne successivement 1, 2, 3 et ainsi de suite jusqu'à totalPages. Enfin, le script, dans lequel tu importes les composables, déstructures l'API retournée et passes le résultat de l'un comme paramètre du suivant :

1<script setup>
2import { onMounted } from 'vue'
3import { useExperiments } from './composables/useExperiments'
4import { useLabFilters } from './composables/useLabFilters'
5import { usePagination } from './composables/usePagination'
6
7// Composables
8const {
9  experiments,
10  loading,
11  error,
12  categoriesCount,
13  fetchExperiments,
14  deleteExperiment
15} = useExperiments()
16
17const {
18  searchQuery,
19  selectedCategory,
20  sortBy,
21  sortOrder,
22  filteredExperiments,
23  uniqueCategories,
24  resetFilters
25} = useLabFilters(experiments)
26
27const {
28  currentPage,
29  totalPages,
30  paginatedItems,
31  goToPage,
32  nextPage,
33  prevPage
34} = usePagination(filteredExperiments, 12)
35
36// Lifecycle
37onMounted(() => {
38  fetchExperiments()
39})
40</script>

L'ordre des appels reflète le flux des données : experiments va dans les filtres, filteredExperiments dans la pagination, et on récupère les données dans onMounted. Le composant ne contient aucune logique métier - il ne fait que l'assembler.

Mon conseil : respecte la règle d'une seule tâche par composable. Grâce à elle, tu pourras utiliser usePagination dans n'importe quelle autre liste de la station, et tester chaque composable séparément, comme dans la leçon sur les tests. Dans l'éditeur ci-dessous t'attend un exemple voisin d'intégration de modules : useTimer, useModuleStatus et useNotifications se combinent en un seul panneau selon la même règle.

Retiens : le Lab Manager est une station construite à partir de modules - chaque composable fait une seule chose, et le composant se contente de les relier.

Code de cette leçon : App.vue
1<script setup>
2import { ref, computed, watch, onMounted } from 'vue'
3
4// Bonne pratique 1 : responsabilité unique
5function useTimer(initialSeconds = 0) {
6  const seconds = ref(initialSeconds)
7  const isRunning = ref(false)
8  let intervalId = null
9
10  const formattedTime = computed(() => {
11    const mins = Math.floor(seconds.value / 60)
12    const secs = seconds.value % 60
13    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
14  })
15
16  function start() {
17    if (isRunning.value) return
18    isRunning.value = true
19    intervalId = setInterval(() => {
20      seconds.value++
21    }, 1000)
22  }
23
24  function pause() {
25    isRunning.value = false
26    if (intervalId) {
27      clearInterval(intervalId)
28      intervalId = null
29    }
30  }
31
32  function reset() {
33    pause()
34    seconds.value = initialSeconds
35  }
36
37  return {
38    seconds,
39    formattedTime,
40    isRunning,
41    start,
42    pause,
43    reset
44  }
45}
46
47// Bonne pratique 2 : composition de composables
48function useModuleStatus(moduleName) {
49  const isOnline = ref(true)
50  const temperature = ref(22)
51  const powerConsumption = ref(45)
52
53  const status = computed(() => {
54    if (!isOnline.value) return { level: 'offline', color: '#666666' }
55    if (temperature.value > 80) return { level: 'critical', color: '#ff0055' }
56    if (powerConsumption.value > 80) return { level: 'warning', color: '#ffb400' }
57    return { level: 'optimal', color: '#00ff88' }
58  })
59
60  // Utilise le composable timer
61  const uptime = useTimer()
62
63  // Démarre automatiquement le timer d'uptime quand en ligne
64  watch(isOnline, (newValue) => {
65    if (newValue) {
66      uptime.start()
67    } else {
68      uptime.pause()
69    }
70  }, { immediate: true })
71
72  function toggleOnline() {
73    isOnline.value = !isOnline.value
74  }
75
76  function adjustTemperature(delta) {
77    temperature.value = Math.max(0, Math.min(100, temperature.value + delta))
78  }
79
80  function adjustPower(delta) {
81    powerConsumption.value = Math.max(0, Math.min(100, powerConsumption.value + delta))
82  }
83
84  return {
85    moduleName: ref(moduleName),
86    isOnline,
87    temperature,
88    powerConsumption,
89    status,
90    uptime,
91    toggleOnline,
92    adjustTemperature,
93    adjustPower
94  }
95}
96
97// Bonne pratique 3 : options avec valeurs par défaut
98function useNotifications(options = {}) {
99  const {
100    maxNotifications = 10,
101    autoRemove = true,
102    autoRemoveDelay = 5000
103  } = options
104
105  const notifications = ref([])
106  let notificationId = 0
107
108  function add(message, type = 'info') {
109    notificationId++
110    const notification = {
111      id: notificationId,
112      message,
113      type,
114      timestamp: new Date()
115    }
116
117    notifications.value.unshift(notification)
118
119    if (notifications.value.length > maxNotifications) {
120      notifications.value.pop()
121    }
122
123    if (autoRemove) {
124      setTimeout(() => {
125        remove(notification.id)
126      }, autoRemoveDelay)
127    }
128
129    return notification
130  }
131
132  function remove(id) {
133    const index = notifications.value.findIndex(n => n.id === id)
134    if (index !== -1) {
135      notifications.value.splice(index, 1)
136    }
137  }
138
139  function clear() {
140    notifications.value = []
141  }
142
143  return {
144    notifications,
145    add,
146    remove,
147    clear
148  }
149}
150
151// Initialise les modules
152const habitatModule = useModuleStatus('Habitat Module')
153const researchModule = useModuleStatus('Research Module')
154const powerModule = useModuleStatus('Power Module')
155
156// Initialise les notifications
157const notify = useNotifications({
158  maxNotifications: 5,
159  autoRemove: true,
160  autoRemoveDelay: 4000
161})
162
163// Simulation des modules
164function simulateHeatWave(module) {
165  module.adjustTemperature(30)
166  notify.add(`Heat wave detected in ${module.moduleName.value}`, 'warning')
167}
168
169function simulateCooling(module) {
170  module.adjustTemperature(-20)
171  notify.add(`Cooling system activated in ${module.moduleName.value}`, 'info')
172}
173
174function simulatePowerSpike(module) {
175  module.adjustPower(40)
176  notify.add(`Power spike in ${module.moduleName.value}`, 'warning')
177}
178
179onMounted(() => {
180  notify.add('NOVA LAB Module Integration System Online', 'success')
181})
182</script>
183
184<template>
185  <div class="integration-lab">
186    <div class="header">
187      <h1>Intégration des modules - NOVA LAB</h1>
188      <p class="subtitle">Module 6 : bonnes pratiques & modèles de composables</p>
189    </div>
190
191    <!-- Panneau de notifications -->
192    <div class="notifications-panel">
193      <div class="notifications-header">
194        <h3>Notifications système</h3>
195        <button @click="notify.clear()" class="btn btn-sm btn-danger">Tout effacer</button>
196      </div>
197      <div class="notifications-list">
198        <div
199          v-for="notification in notify.notifications.value"
200          :key="notification.id"
201          class="notification"
202          :class="'notification-' + notification.type"
203        >
204          <span class="notification-type">{{ notification.type.toUpperCase() }}</span>
205          <span class="notification-message">{{ notification.message }}</span>
206          <button @click="notify.remove(notification.id)" class="btn-close">×</button>
207        </div>
208      </div>
209    </div>
210
211    <!-- Modules Grid -->
212    <div class="modules-grid">
213      <!-- Module Habitat -->
214      <div class="module-card" :style="{ borderColor: habitatModule.status.value.color }">
215        <div class="module-header">
216          <h3>{{ habitatModule.moduleName.value }}</h3>
217          <div
218            class="status-badge"
219            :style="{ background: habitatModule.status.value.color }"
220          >
221            {{ habitatModule.status.value.level.toUpperCase() }}
222          </div>
223        </div>
224
225        <div class="module-stats">
226          <div class="stat">
227            <span class="stat-label">Température :</span>
228            <span class="stat-value">{{ habitatModule.temperature.value }}°C</span>
229          </div>
230          <div class="stat">
231            <span class="stat-label">Énergie :</span>
232            <span class="stat-value">{{ habitatModule.powerConsumption.value }}%</span>
233          </div>
234          <div class="stat">
235            <span class="stat-label">Disponibilité :</span>
236            <span class="stat-value">{{ habitatModule.uptime.formattedTime.value }}</span>
237          </div>
238        </div>
239
240        <div class="module-controls">
241          <button
242            @click="habitatModule.toggleOnline()"
243            class="btn"
244            :class="habitatModule.isOnline.value ? 'btn-warning' : 'btn-success'"
245          >
246            {{ habitatModule.isOnline.value ? 'Éteindre' : 'Allumer' }}
247          </button>
248        </div>
249
250        <div class="module-simulations">
251          <button @click="simulateHeatWave(habitatModule)" class="btn btn-sm">Vague de chaleur</button>
252          <button @click="simulateCooling(habitatModule)" class="btn btn-sm">Refroidissement</button>
253          <button @click="simulatePowerSpike(habitatModule)" class="btn btn-sm">Pic d'énergie</button>
254        </div>
255      </div>
256
257      <!-- Module Recherche -->
258      <div class="module-card" :style="{ borderColor: researchModule.status.value.color }">
259        <div class="module-header">
260          <h3>{{ researchModule.moduleName.value }}</h3>
261          <div
262            class="status-badge"
263            :style="{ background: researchModule.status.value.color }"
264          >
265            {{ researchModule.status.value.level.toUpperCase() }}
266          </div>
267        </div>
268
269        <div class="module-stats">
270          <div class="stat">
271            <span class="stat-label">Température :</span>
272            <span class="stat-value">{{ researchModule.temperature.value }}°C</span>
273          </div>
274          <div class="stat">
275            <span class="stat-label">Énergie :</span>
276            <span class="stat-value">{{ researchModule.powerConsumption.value }}%</span>
277          </div>
278          <div class="stat">
279            <span class="stat-label">Disponibilité :</span>
280            <span class="stat-value">{{ researchModule.uptime.formattedTime.value }}</span>
281          </div>
282        </div>
283
284        <div class="module-controls">
285          <button
286            @click="researchModule.toggleOnline()"
287            class="btn"
288            :class="researchModule.isOnline.value ? 'btn-warning' : 'btn-success'"
289          >
290            {{ researchModule.isOnline.value ? 'Éteindre' : 'Allumer' }}
291          </button>
292        </div>
293
294        <div class="module-simulations">
295          <button @click="simulateHeatWave(researchModule)" class="btn btn-sm">Vague de chaleur</button>
296          <button @click="simulateCooling(researchModule)" class="btn btn-sm">Refroidissement</button>
297          <button @click="simulatePowerSpike(researchModule)" class="btn btn-sm">Pic d'énergie</button>
298        </div>
299      </div>
300
301      <!-- Module Énergie -->
302      <div class="module-card" :style="{ borderColor: powerModule.status.value.color }">
303        <div class="module-header">
304          <h3>{{ powerModule.moduleName.value }}</h3>
305          <div
306            class="status-badge"
307            :style="{ background: powerModule.status.value.color }"
308          >
309            {{ powerModule.status.value.level.toUpperCase() }}
310          </div>
311        </div>
312
313        <div class="module-stats">
314          <div class="stat">
315            <span class="stat-label">Température :</span>
316            <span class="stat-value">{{ powerModule.temperature.value }}°C</span>
317          </div>
318          <div class="stat">
319            <span class="stat-label">Énergie :</span>
320            <span class="stat-value">{{ powerModule.powerConsumption.value }}%</span>
321          </div>
322          <div class="stat">
323            <span class="stat-label">Disponibilité :</span>
324            <span class="stat-value">{{ powerModule.uptime.formattedTime.value }}</span>
325          </div>
326        </div>
327
328        <div class="module-controls">
329          <button
330            @click="powerModule.toggleOnline()"
331            class="btn"
332            :class="powerModule.isOnline.value ? 'btn-warning' : 'btn-success'"
333          >
334            {{ powerModule.isOnline.value ? 'Éteindre' : 'Allumer' }}
335          </button>
336        </div>
337
338        <div class="module-simulations">
339          <button @click="simulateHeatWave(powerModule)" class="btn btn-sm">Vague de chaleur</button>
340          <button @click="simulateCooling(powerModule)" class="btn btn-sm">Refroidissement</button>
341          <button @click="simulatePowerSpike(powerModule)" class="btn btn-sm">Pic d'énergie</button>
342        </div>
343      </div>
344    </div>
345
346    <div class="info-panel">
347      <h3>Bonnes pratiques pour les composables</h3>
348      <div class="info-grid">
349        <div class="info-section">
350          <h4>1. Responsabilité unique</h4>
351          <ul>
352            <li>Chaque composable a une responsabilité unique</li>
353            <li>useTimer - uniquement la gestion du timer</li>
354            <li>Facile à tester et à maintenir</li>
355          </ul>
356        </div>
357
358        <div class="info-section">
359          <h4>2. Composition de composables</h4>
360          <ul>
361            <li>Combiner des composables pour une logique complexe</li>
362            <li>useModuleStatus utilise useTimer</li>
363            <li>Réutilisabilité et modularité</li>
364          </ul>
365        </div>
366
367        <div class="info-section">
368          <h4>3. Modèle des options</h4>
369          <ul>
370            <li>Accepter des options avec des valeurs par défaut</li>
371            <li>useNotifications({ maxNotifications: 5 })</li>
372            <li>Flexibilité et personnalisation</li>
373          </ul>
374        </div>
375
376        <div class="info-section">
377          <h4>4. Convention de nommage</h4>
378          <ul>
379            <li>Toujours le préfixe « use »</li>
380            <li>Nom descriptif : useModuleStatus</li>
381            <li>Facile à identifier</li>
382          </ul>
383        </div>
384      </div>
385
386      <div class="code-example">
387        <code>
388// Bon : responsabilité unique
389function useTimer() { ... }
390
391// Bon : composition
392function useModule() {
393  const timer = useTimer()
394  return { timer, ... }
395}
396
397// Bon : options avec valeurs par défaut
398function useNotify(options = {}) {
399  const { maxItems = 10 } = options
400  ...
401}
402        </code>
403      </div>
404    </div>
405  </div>
406</template>
407
408<style scoped>
409.integration-lab {
410  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
411  min-height: 100vh;
412  padding: 2rem;
413  color: #ffffff;
414  font-family: 'Courier New', monospace;
415}
416
417.header {
418  text-align: center;
419  margin-bottom: 2rem;
420  padding-bottom: 1rem;
421  border-bottom: 2px solid #00ff88;
422}
423
424.header h1 {
425  font-size: 2.5rem;
426  color: #00ff88;
427  text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
428  margin: 0;
429}
430
431.subtitle {
432  color: #00b4d8;
433  font-size: 1.2rem;
434  margin-top: 0.5rem;
435}
436
437.notifications-panel {
438  background: rgba(0, 180, 216, 0.05);
439  border: 2px solid #00b4d8;
440  border-radius: 10px;
441  padding: 1.5rem;
442  margin-bottom: 2rem;
443}
444
445.notifications-header {
446  display: flex;
447  justify-content: space-between;
448  align-items: center;
449  margin-bottom: 1rem;
450}
451
452.notifications-header h3 {
453  color: #00b4d8;
454  margin: 0;
455}
456
457.notifications-list {
458  display: flex;
459  flex-direction: column;
460  gap: 0.5rem;
461  max-height: 300px;
462  overflow-y: auto;
463}
464
465.notification {
466  display: flex;
467  align-items: center;
468  gap: 1rem;
469  padding: 1rem;
470  border-radius: 5px;
471  border-left: 4px solid;
472  animation: slideIn 0.3s ease;
473}
474
475@keyframes slideIn {
476  from {
477    transform: translateX(-20px);
478    opacity: 0;
479  }
480  to {
481    transform: translateX(0);
482    opacity: 1;
483  }
484}
485
486.notification-success {
487  background: rgba(0, 255, 136, 0.1);
488  border-color: #00ff88;
489}
490
491.notification-info {
492  background: rgba(0, 180, 216, 0.1);
493  border-color: #00b4d8;
494}
495
496.notification-warning {
497  background: rgba(255, 180, 0, 0.1);
498  border-color: #ffb400;
499}
500
501.notification-type {
502  color: #00b4d8;
503  font-size: 0.8rem;
504  font-weight: bold;
505  min-width: 80px;
506}
507
508.notification-message {
509  flex: 1;
510  color: #ffffff;
511}
512
513.btn-close {
514  background: rgba(255, 0, 85, 0.3);
515  color: #ff0055;
516  border: none;
517  width: 30px;
518  height: 30px;
519  border-radius: 50%;
520  font-size: 1.5rem;
521  cursor: pointer;
522  display: flex;
523  align-items: center;
524  justify-content: center;
525  transition: all 0.3s ease;
526}
527
528.btn-close:hover {
529  background: #ff0055;
530  color: #ffffff;
531}
532
533.modules-grid {
534  display: grid;
535  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
536  gap: 2rem;
537  margin-bottom: 2rem;
538}
539
540.module-card {
541  background: rgba(0, 255, 136, 0.05);
542  border: 3px solid;
543  border-radius: 10px;
544  padding: 1.5rem;
545  transition: all 0.3s ease;
546}
547
548.module-header {
549  display: flex;
550  justify-content: space-between;
551  align-items: center;
552  margin-bottom: 1.5rem;
553}
554
555.module-header h3 {
556  color: #00ff88;
557  margin: 0;
558}
559
560.status-badge {
561  padding: 0.5rem 1rem;
562  border-radius: 20px;
563  font-size: 0.8rem;
564  font-weight: bold;
565  color: #0a0e27;
566}
567
568.module-stats {
569  background: rgba(0, 0, 0, 0.3);
570  border-radius: 8px;
571  padding: 1rem;
572  margin-bottom: 1rem;
573}
574
575.stat {
576  display: flex;
577  justify-content: space-between;
578  padding: 0.5rem 0;
579  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
580}
581
582.stat:last-child {
583  border-bottom: none;
584}
585
586.stat-label {
587  color: #00b4d8;
588}
589
590.stat-value {
591  color: #00ff88;
592  font-weight: bold;
593}
594
595.module-controls {
596  margin: 1rem 0;
597}
598
599.module-simulations {
600  display: flex;
601  gap: 0.5rem;
602  flex-wrap: wrap;
603}
604
605.btn {
606  padding: 0.75rem 1.5rem;
607  border: none;
608  border-radius: 5px;
609  font-weight: bold;
610  cursor: pointer;
611  font-family: 'Courier New', monospace;
612  transition: all 0.3s ease;
613  width: 100%;
614}
615
616.btn-sm {
617  padding: 0.5rem 1rem;
618  font-size: 0.85rem;
619  width: auto;
620  flex: 1;
621  background: rgba(0, 180, 216, 0.3);
622  color: #00b4d8;
623}
624
625.btn-success {
626  background: #00ff88;
627  color: #0a0e27;
628}
629
630.btn-warning {
631  background: #ffb400;
632  color: #0a0e27;
633}
634
635.btn-danger {
636  background: #ff0055;
637  color: #ffffff;
638}
639
640.btn:hover {
641  transform: scale(1.05);
642  box-shadow: 0 5px 15px rgba(0, 255, 136, 0.3);
643}
644
645.info-panel {
646  background: rgba(0, 255, 136, 0.05);
647  border: 2px solid #00ff88;
648  border-radius: 10px;
649  padding: 2rem;
650}
651
652.info-panel h3 {
653  color: #00ff88;
654  margin-top: 0;
655  margin-bottom: 1.5rem;
656  text-align: center;
657}
658
659.info-grid {
660  display: grid;
661  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
662  gap: 1.5rem;
663  margin-bottom: 2rem;
664}
665
666.info-section {
667  background: rgba(0, 180, 216, 0.1);
668  border: 2px solid #00b4d8;
669  border-radius: 8px;
670  padding: 1.5rem;
671}
672
673.info-section h4 {
674  color: #00b4d8;
675  margin-top: 0;
676  margin-bottom: 1rem;
677}
678
679.info-section ul {
680  list-style: none;
681  padding: 0;
682  margin: 0;
683}
684
685.info-section li {
686  padding: 0.5rem 0;
687  color: #ffffff;
688  font-size: 0.9rem;
689  line-height: 1.6;
690}
691
692.code-example {
693  background: rgba(0, 0, 0, 0.5);
694  border-left: 4px solid #00ff88;
695  border-radius: 5px;
696  padding: 1.5rem;
697}
698
699.code-example code {
700  color: #00ff88;
701  font-size: 0.9rem;
702  white-space: pre;
703  display: block;
704}
705</style>

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. Que doit retourner le composable useExperiments ?

  2. 2. À quoi utilise-t-on computed() dans les composables ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Construis useExperiments avec fetchExperiments, addExperiment, deleteExperiment et des computed.

  • Tri vertical

    Ordonne le flux de travail des composables, de la création à l'utilisation :

  • Clique dans l'ordre

    Ordonne le modèle d'un composable qui gère une liste de données :

  • Tri horizontal

    Ordonne un composable qui accepte un paramètre :

  • Éditeur de code

    Construis useLabFilters avec search, category, sort et un computed filteredItems.

  • Tri vertical

    Ordonne les étapes pour intégrer plusieurs composables dans un seul composant :

  • Tri horizontal

    Ordonne une propriété computed dans un composable :

  • Clique dans l'ordre

    Ordonne le cycle de vie complet d'un composable dans une application :

  • Tri vertical

    Ordonne l'architecture de Lab Manager, des données à la vue :

  • Clique dans l'ordre

    Ordonne un script setup qui intègre des composables :

  • Tri horizontal

    Ordonne le chemin d'import d'un composable :

  • Tri vertical

    Ordonne les étapes d'initialisation d'un composable qui récupère des données depuis un serveur :