Cours Vue.js · Module 6: Composants

Styles scoped et CSS Modules

6 min de lecture
Dans cette leçon5

L'isolation des styles dans les composants fonctionne comme les cloisons étanches entre les modules de l'habitat : ce qui se passe dans un compartiment ne passe pas dans les compartiments voisins.

Deux composants de la base utilisent une classe .card : l'un est une carte de module, l'autre une carte de membre d'équipage. Le CSS ordinaire s'applique à toute la page, donc une règle écrite dans un fichier repeint les cartes des deux. Vue propose plusieurs niveaux d'isolation, et aujourd'hui tu vas découvrir chacun d'eux.

Styles scoped

Tu connais l'attribut scoped depuis la Launch Zone : les règles d'un tel bloc ne s'appliquent qu'aux éléments du composant courant. Commençons par une carte dont les styles ne fuiront pas hors du fichier :

1<template>
2  <div class="card">
3    <h3 class="title">{{ title }}</h3>
4  </div>
5</template>
6
7<style scoped>
8/* Uniquement pour ce composant */
9.card {
10  border: 1px solid #ccc;
11  padding: 1rem;
12}
13
14.title {
15  color: #333;
16}
17</style>

Vue ajoute à chaque élément du composant un attribut comme data-v-7ba5bd90 et transforme le sélecteur .card en .card[data-v-7ba5bd90]. Une carte de membre d'équipage dans un autre fichier reçoit un autre attribut, donc cette règle ne la touchera pas. Il existe une exception voulue : l'élément racine d'un composant enfant subit à la fois les styles de l'enfant et les styles scoped du parent, pour que le parent puisse le placer dans sa mise en page.

:deep() - un regard à l'intérieur de l'enfant

Parfois, pourtant, le parent doit styliser un élément situé plus profondément dans un composant enfant. C'est le rôle du pseudo-sélecteur :deep() :

1<style scoped>
2/* Sélecteur profond - agir sur les composants enfants */
3.card :deep(.child-class) {
4  color: blue;
5}
6</style>

Le compilateur transforme cette règle en .card[data-v-7ba5bd90] .child-class. L'attribut ne reste que sur .card, donc .child-class correspond aussi aux éléments à l'intérieur des enfants. Les anciennes écritures >>> et /deep/ viennent de Vue 2 - le compilateur de Vue 3 les comprend encore, mais il prévient qu'elles sont dépréciées et demande d'utiliser :deep().

:slotted() et :global()

Deux autres pseudo-sélecteurs concernent du contenu venu de l'extérieur du composant. Un slot est un emplacement du template de l'enfant dans lequel le parent place son propre contenu - tu le découvriras à l'Entrepôt des Modules. Ce contenu appartient au parent, donc les styles scoped de l'enfant ne le couvrent pas par défaut :

1<style scoped>
2/* Contenu injecté via slot */
3:slotted(.slot-class) {
4  font-weight: bold;
5}
6
7/* Global dans un style scoped */
8:global(.global-class) {
9  background: yellow;
10}
11</style>

:slotted() dirige une règle vers le contenu inséré par un slot, tandis que :global() désactive l'isolation pour une seule règle, donc .global-class agira sur toute la page. Utilise les règles globales dans un style scoped avec parcimonie, car c'est une porte ouverte dans la cloison.

CSS Modules

La deuxième façon d'isoler les styles, ce sont les CSS Modules. Un bloc <style module> transforme chaque classe en un nom unique et généré, et dans le template tu y accèdes par l'objet $style et la directive :class :

1<template>
2  <div :class="$style.card">
3    <h3 :class="$style.title">{{ title }}</h3>
4    <p :class="[$style.text, $style.highlighted]">Description</p>
5  </div>
6</template>
7
8<style module>
9.card {
10  border: 1px solid #ccc;
11}
12
13.title {
14  color: #333;
15}
16
17.text {
18  font-size: 14px;
19}
20
21.highlighted {
22  background: yellow;
23}
24</style>

Les crochets appliquent deux classes à la fois, car :class accepte aussi un tableau. La classe card de ce fichier n'a rien à voir avec card dans un autre composant - les deux reçoivent des noms différents. La différence avec scoped : l'isolation passe par les noms de classes et non par des attributs, et elle ne couvre que les éléments auxquels tu attribues explicitement une classe de $style. Tu peux aussi lire les noms générés dans le script avec la fonction useCssModule :

1<script setup>
2import { useCssModule } from 'vue'
3
4const styles = useCssModule()
5console.log(styles.card) // par ex. "_card_1a2b3_2" - le nom est généré par Vite
6</script>

Le format du nom dépend de la configuration de l'outil de build - par défaut, Vite combine le nom de la classe, une empreinte du contenu et le numéro de ligne. Un bloc peut aussi recevoir son propre nom, par exemple <style module="classes">, et tu écris alors classes.card dans le template.

Styles dynamiques

Parfois, une couleur doit dépendre des données, par exemple du type de module. La directive :style accepte un objet, et ses clés peuvent aussi être des variables CSS, c'est-à-dire des propriétés qui commencent par deux tirets :

1<template>
2  <div
3    class="card"
4    :style="{
5      '--primary-color': primaryColor,
6      '--border-width': borderWidth + 'px'
7    }"
8  >
9    <h3>{{ title }}</h3>
10  </div>
11</template>
12
13<script setup>
14import { ref } from 'vue'
15
16const primaryColor = ref('#8b7355')
17const borderWidth = ref(2)
18</script>
19
20<style scoped>
21.card {
22  border: var(--border-width) solid var(--primary-color);
23  color: var(--primary-color);
24}
25</style>

Le composant définit les variables --primary-color et --border-width sur son élément, et la règle de <style scoped> les lit avec la fonction var(). La feuille de style ne change pas - seules les valeurs des variables changent quand les conteneurs ref() changent.

v-bind() dans le CSS

Vue sait faire la même chose plus simplement : la fonction v-bind() écrite directement dans une règle CSS relie une valeur à un conteneur de <script setup> :

1<template>
2  <div class="card">
3    <input v-model="color" type="color" />
4    <p>Ce texte change de couleur !</p>
5  </div>
6</template>
7
8<script setup>
9import { ref } from 'vue'
10
11const color = ref('#8b7355')
12</script>
13
14<style scoped>
15p {
16  /* Liaison réactive ! */
17  color: v-bind(color);
18}
19</style>

Choisis une couleur dans le champ, et le texte la prend aussitôt. Le compilateur transforme v-bind(color) en une variable CSS au nom unique, que Vue place sur l'élément racine du composant et met à jour à chaque changement, si bien que la feuille de style elle-même reste statique.

Les niveaux d'isolation

De la portée la plus large à la plus étroite : les styles globaux sans scoped s'appliquent à toute la page, <style scoped> aux éléments d'un seul composant, les CSS Modules uniquement aux éléments qui portent une classe de $style, et un style inline via :style à un seul élément précis. Au NOVA LAB, nous écrivons <style scoped> par défaut et nous passons aux CSS Modules quand les noms de classes sont nécessaires dans le script - c'est le point de départ que je te recommande.

Dans l'éditeur ci-dessous, chaque composant du panneau de réglage des matériaux a son propre bloc <style scoped>. Change une couleur dans un fichier et vérifie que l'autre reste inchangé. Le panneau utilise aussi v-model sur des composants, que tu découvriras à la Station de Communication.

Retiens ceci : scoped pose des cloisons autour d'un module, les CSS Modules donnent aux classes des noms uniques, et v-bind() dans le CSS branche l'apparence directement sur les données de la mission.

Code de cette leçon : App.vue
1<script setup>
2import { ref } from 'vue'
3import MaterialSettings from './MaterialSettings.vue'
4
5const printSettings = ref({
6  material: 'PLA',
7  temperature: 210,
8  speed: 50,
9  layerHeight: 0.2
10})
11
12const extruderSettings = ref({
13  flowRate: 100,
14  retraction: 5
15})
16
17function savePrintSettings() {
18  console.log("Paramètres d'impression enregistrés :", printSettings.value)
19  alert('Paramètres enregistrés !')
20}
21
22function saveExtruderSettings() {
23  console.log("Paramètres de l'extrudeuse enregistrés :", extruderSettings.value)
24  alert("Paramètres de l'extrudeuse enregistrés !")
25}
26</script>
27
28<template>
29  <div class="settings-lab">
30    <div class="header">
31      <h1>Material Settings - NOVA LAB</h1>
32      <p class="subtitle">Component v-model</p>
33    </div>
34
35    <div class="info-panel">
36      <h3>v-model dans les composants</h3>
37      <p><strong>v-model</strong> est un raccourci pour :modelValue + @update:modelValue</p>
38      <p>Tu peux utiliser plusieurs v-model avec des noms différents : v-model:customName</p>
39      <code class="code-example">
40<!-- Dans le composant parent -->
41v-model="value"
42v-model:customName="otherValue"
43
44<!-- Dans le composant enfant -->
45defineProps(['modelValue', 'customName'])
46emit('update:modelValue', newValue)
47emit('update:customName', newValue)
48      </code>
49    </div>
50
51    <div class="settings-grid">
52      <!-- v-model unique -->
53      <div class="settings-card">
54        <h2>Paramètres d'impression de base</h2>
55        <MaterialSettings v-model="printSettings" />
56
57        <div class="preview-section">
58          <h4>Aperçu de la valeur :</h4>
59          <pre>{{ JSON.stringify(printSettings, null, 2) }}</pre>
60          <button @click="savePrintSettings" class="btn btn-save">
61            Enregistrer les paramètres
62          </button>
63        </div>
64      </div>
65
66      <!-- v-model multiples -->
67      <div class="settings-card">
68        <h2>Paramètres de l'extrudeuse</h2>
69        <MaterialSettings
70          v-model="extruderSettings"
71          :show-advanced="true"
72        />
73
74        <div class="preview-section">
75          <h4>Aperçu de la valeur :</h4>
76          <pre>{{ JSON.stringify(extruderSettings, null, 2) }}</pre>
77          <button @click="saveExtruderSettings" class="btn btn-save">
78            Enregistrer les paramètres de l'extrudeuse
79          </button>
80        </div>
81      </div>
82
83      <!-- Saisie directe avec v-model -->
84      <div class="settings-card">
85        <h2>Édition manuelle (JSON)</h2>
86        <textarea
87          v-model="printSettings.material"
88          class="json-editor"
89          placeholder="Modifier le nom du matériau..."
90        ></textarea>
91        <p class="info-text">
92          Les modifications du champ texte mettent automatiquement à jour l'objet printSettings
93        </p>
94      </div>
95    </div>
96
97    <div class="summary-panel">
98      <h2>Récapitulatif de tous les paramètres</h2>
99      <div class="summary-grid">
100        <div class="summary-item">
101          <h4>Paramètres d'impression</h4>
102          <ul>
103            <li>Matériau : <strong>{{ printSettings.material }}</strong></li>
104            <li>Température : <strong>{{ printSettings.temperature }}°C</strong></li>
105            <li>Vitesse : <strong>{{ printSettings.speed }}%</strong></li>
106            <li>Hauteur de couche : <strong>{{ printSettings.layerHeight }}mm</strong></li>
107          </ul>
108        </div>
109        <div class="summary-item">
110          <h4>Paramètres de l'extrudeuse</h4>
111          <ul>
112            <li>Débit : <strong>{{ extruderSettings.flowRate }}%</strong></li>
113            <li>Rétraction : <strong>{{ extruderSettings.retraction }}mm</strong></li>
114          </ul>
115        </div>
116      </div>
117    </div>
118  </div>
119</template>
120
121<style scoped>
122.settings-lab {
123  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
124  min-height: 100vh;
125  padding: 2rem;
126  color: #ffffff;
127  font-family: 'Courier New', monospace;
128}
129
130.header {
131  text-align: center;
132  margin-bottom: 2rem;
133  padding-bottom: 1rem;
134  border-bottom: 2px solid #00ff88;
135}
136
137.header h1 {
138  font-size: 2.5rem;
139  color: #00ff88;
140  text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
141  margin: 0;
142}
143
144.subtitle {
145  color: #00b4d8;
146  font-size: 1.2rem;
147  margin-top: 0.5rem;
148}
149
150.info-panel {
151  background: rgba(0, 180, 216, 0.1);
152  border: 2px solid #00b4d8;
153  border-radius: 10px;
154  padding: 1.5rem;
155  margin-bottom: 2rem;
156}
157
158.info-panel h3 {
159  color: #00b4d8;
160  margin-top: 0;
161}
162
163.info-panel strong {
164  color: #00ff88;
165}
166
167.code-example {
168  display: block;
169  background: rgba(0, 0, 0, 0.5);
170  border-left: 4px solid #00ff88;
171  padding: 1rem;
172  border-radius: 5px;
173  color: #00ff88;
174  margin-top: 1rem;
175  white-space: pre;
176  overflow-x: auto;
177}
178
179.settings-grid {
180  display: grid;
181  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
182  gap: 1.5rem;
183  margin-bottom: 2rem;
184}
185
186.settings-card {
187  background: rgba(0, 255, 136, 0.05);
188  border: 2px solid #00ff88;
189  border-radius: 10px;
190  padding: 1.5rem;
191}
192
193.settings-card h2 {
194  color: #00ff88;
195  margin-top: 0;
196  margin-bottom: 1.5rem;
197}
198
199.preview-section {
200  margin-top: 1.5rem;
201  padding-top: 1.5rem;
202  border-top: 2px solid rgba(0, 255, 136, 0.2);
203}
204
205.preview-section h4 {
206  color: #00b4d8;
207  margin-top: 0;
208}
209
210.preview-section pre {
211  background: rgba(0, 0, 0, 0.5);
212  padding: 1rem;
213  border-radius: 5px;
214  color: #00ff88;
215  overflow-x: auto;
216  border-left: 4px solid #00ff88;
217}
218
219.json-editor {
220  width: 100%;
221  min-height: 150px;
222  background: rgba(0, 0, 0, 0.3);
223  border: 2px solid #00b4d8;
224  border-radius: 5px;
225  padding: 1rem;
226  color: #ffffff;
227  font-family: 'Courier New', monospace;
228  resize: vertical;
229}
230
231.json-editor:focus {
232  outline: none;
233  border-color: #00ff88;
234  box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
235}
236
237.info-text {
238  color: #00b4d8;
239  font-size: 0.9rem;
240  margin-top: 1rem;
241}
242
243.btn-save {
244  background: #00ff88;
245  color: #0a0e27;
246  border: none;
247  padding: 0.75rem 1.5rem;
248  border-radius: 5px;
249  font-weight: bold;
250  cursor: pointer;
251  transition: all 0.3s ease;
252  font-family: 'Courier New', monospace;
253  margin-top: 1rem;
254}
255
256.btn-save:hover {
257  transform: scale(1.05);
258  box-shadow: 0 5px 15px rgba(0, 255, 136, 0.4);
259}
260
261.summary-panel {
262  background: rgba(0, 180, 216, 0.1);
263  border: 2px solid #00b4d8;
264  border-radius: 10px;
265  padding: 1.5rem;
266}
267
268.summary-panel h2 {
269  color: #00b4d8;
270  margin-top: 0;
271}
272
273.summary-grid {
274  display: grid;
275  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
276  gap: 1.5rem;
277}
278
279.summary-item {
280  background: rgba(0, 0, 0, 0.3);
281  padding: 1.5rem;
282  border-radius: 8px;
283  border-left: 4px solid #00ff88;
284}
285
286.summary-item h4 {
287  color: #00ff88;
288  margin-top: 0;
289}
290
291.summary-item ul {
292  list-style: none;
293  padding: 0;
294}
295
296.summary-item li {
297  padding: 0.5rem 0;
298  color: #ffffff;
299  border-bottom: 1px solid rgba(0, 255, 136, 0.1);
300}
301
302.summary-item li:last-child {
303  border-bottom: none;
304}
305
306.summary-item strong {
307  color: #00b4d8;
308}
309</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 fait l'attribut scoped dans <style> ?

  2. 2. Comment styliser les composants enfants dans des styles scoped ?

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

    Ajoute des styles scoped à MarsPanel.vue avec des classes pour le panneau (.panel), le titre (.title) et le statut (.status).

  • Clique dans l'ordre

    Range les éléments d'une déclaration de style scoped :

  • Tri vertical

    Range les étapes pour créer un composant, de la création à l'utilisation :