Aller au contenu

Vue.js 3 — Cours complet pour développeur.euse.s front-end

Composition API · Design System · Spring Boot · Symfony


Sommaire


1. Introduction à Vue.js 3

1.1. Qu’est-ce que Vue.js ?

Vue.js (prononcé “view”) est un framework JavaScript progressif pour la construction d’interfaces utilisateur. Créé par Evan You en 2014, il est aujourd’hui l’un des trois frameworks front-end dominants avec React et Angular.

Vue est dit “progressif” parce qu’il peut être adopté graduellement :

Si vous venez de React : Vue partage les mêmes concepts fondamentaux — composants, réactivité, state, props — mais avec une syntaxe souvent plus concise et un outillage plus intégré. Si vous venez d’Angular : Vue est bien plus léger, sans injection de dépendances obligatoire ni modules complexes.

1.2. Vue 3 vs Vue 2 — Pourquoi Vue 3 ?

Ce cours couvre Vue 3, sorti en septembre 2020 et devenu la version par défaut en 2022. Les différences clés avec Vue 2 :

Aspect Vue 2 Vue 3
API principale Options API Composition API (+ Options API supportée)
TypeScript Support partiel Support natif complet
Performance Bonne Meilleure (Virtual DOM réécrit)
Bundle size ~20 KB ~10 KB (tree-shaking amélioré)
Réactivité Object.defineProperty Proxy (plus puissant)
Fragments Un seul nœud racine ✅ Plusieurs nœuds racines
Teleport  

Vue 2 a atteint sa fin de vie le 31 décembre 2023. Tous les nouveaux projets doivent utiliser Vue 3.

1.3. Vue dans l’écosystème front-end

L'écosystème Vue 3 officiel
├── Vue 3              → Framework UI
├── Vue Router 4       → Navigation / Routing (SPA)
├── Pinia              → Gestion d'état global
├── Vite               → Bundler / serveur de dev ultra-rapide
├── VitePress          → Sites de documentation statiques
├── Nuxt 3             → Framework full-stack (SSR, SSG)
└── Ionic / Quasar     → Applications mobiles / Desktop

Outils tiers populaires
├── VueUse             → Librairie de Composables (>200 utilitaires)
├── Vitest             → Tests unitaires (intégré à Vite)
├── Cypress / Playwright → Tests end-to-end
├── Tailwind CSS       → Utilitaires CSS
├── PrimeVue           → Bibliothèque de composants UI
└── Vuetify / Element+ → Design systems

1.4. Comparatif Vue ↔ React ↔ Angular

Critère Vue 3 React 18 Angular 17
Courbe d’apprentissage Faible Moyenne Élevée
Syntaxe templates HTML enrichi JSX HTML + directives
Taille bundle ~10 KB ~40 KB ~130 KB
Gestion d’état Pinia (officiel) Redux / Zustand NgRx
TypeScript Natif Via config Natif
Routing Vue Router React Router @angular/router
SSR Nuxt 3 Next.js Angular Universal
Popularité (2024) 2ème 1er 3ème
Maintenance Communauté + Evan You Meta Google

2. Installation et environnement Windows

2.1. Prérequis

Prérequis obligatoires
├── Node.js 20 LTS ou supérieur
│   └── https://nodejs.org → choisir "LTS"
├── npm (inclus avec Node.js) ou pnpm (recommandé, plus rapide)
├── Git
│   └── https://git-scm.com/download/win
└── VS Code
    └── https://code.visualstudio.com

2.2. Vérification et installation Node.js

# Ouvrir PowerShell ou CMD et vérifier les versions
node --version
# → v20.x.x (ou supérieur)

npm --version
# → 10.x.x

git --version
# → git version 2.x.x

Si Node.js n’est pas installé, téléchargez la version LTS sur nodejs.org et suivez l’installateur Windows. Redémarrez votre terminal après l’installation.

pnpm est une alternative à npm plus rapide et économe en espace disque. Pour l’installer : npm install -g pnpm. Ce cours utilise npm mais toutes les commandes fonctionnent avec pnpm en remplaçant npm par pnpm.

2.3. Extensions VS Code indispensables

Installez ces extensions via Ctrl+Shift+X dans VS Code :

Extensions obligatoires
├── Vue - Official (ex-Volar)     ← Syntaxe .vue, autocomplétion TypeScript
├── ESLint                         ← Qualité et cohérence du code
└── Prettier                       ← Formatage automatique

Extensions très recommandées
├── Vue VSCode Snippets             ← Raccourcis de code Vue
├── Auto Import                    ← Import automatique des composants
├── Path IntelliSense              ← Autocomplétion des chemins
├── Thunder Client                 ← Tester vos API REST (comme Postman)
├── GitLens                        ← Visualisation Git avancée
└── Tailwind CSS IntelliSense      ← Autocomplétion Tailwind (si utilisé)

Désinstallez l’ancienne extension Vetur si vous l’avez — elle est incompatible avec Vue 3 et l’extension “Vue - Official”.

2.4. Configuration VS Code pour Vue

Créez un fichier .vscode/settings.json à la racine de vos projets :

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": {
    "editor.defaultFormatter": "Vue.volar"
  },
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "typescript.tsdk": "node_modules/typescript/lib",
  "vue.inlayHints.missingProps": true,
  "vue.inlayHints.inlineHandlerLeading": true
}

2.5. Créer un projet Vue avec Vite

Vite est le bundler officiel recommandé pour Vue 3. Il démarre en moins d’une seconde et propose un Hot Module Replacement (HMR) quasi-instantané.

# Créer un projet Vue 3 avec TypeScript
npm create vue@latest

# L'assistant vous pose ces questions :

# ✔ Project name: mon-projet-vue
# ✔ Add TypeScript? → Yes         ← Fortement recommandé
# ✔ Add JSX Support? → No
# ✔ Add Vue Router for Single Page Application development? → Yes
# ✔ Add Pinia for state management? → Yes
# ✔ Add Vitest for Unit Testing? → Yes
# ✔ Add an End-to-End Testing Solution? → No (pour l'instant)
# ✔ Add ESLint for code quality? → Yes
# ✔ Add Prettier for code formatting? → Yes
# ✔ Add Vue DevTools 7 extension for debugging? → Yes
# Se placer dans le dossier créé
cd mon-projet-vue

# Installer les dépendances
npm install

# Lancer le serveur de développement
npm run dev
# → http://localhost:5173

2.6. Structure du projet généré

mon-projet-vue/
├── src/
│   ├── assets/              ← Images, polices, CSS globaux
│   ├── components/          ← Composants réutilisables
│   │   └── icons/
│   ├── router/              ← Configuration Vue Router
│   │   └── index.ts
│   ├── stores/              ← Stores Pinia (état global)
│   │   └── counter.ts
│   ├── views/               ← Pages de l'application
│   │   ├── HomeView.vue
│   │   └── AboutView.vue
│   ├── App.vue              ← Composant racine
│   └── main.ts              ← Point d'entrée
├── public/                  ← Fichiers statiques (favicon, robots.txt)
├── .vscode/                 ← Config VS Code
├── env.d.ts                 ← Déclarations TypeScript
├── index.html               ← Page HTML principale
├── package.json
├── tsconfig.json            ← Configuration TypeScript
└── vite.config.ts           ← Configuration Vite

La structure views/ pour les pages et components/ pour les composants réutilisables est une convention Vue. Ce n’est pas une obligation technique, mais c’est la pratique universellement adoptée.

2.7. Les scripts npm disponibles

npm run dev        # Lancer le serveur de développement (HMR)
npm run build      # Compiler pour la production → dossier dist/
npm run preview    # Prévisualiser le build de production
npm run test:unit  # Lancer les tests unitaires (Vitest)
npm run lint       # Vérifier la qualité du code (ESLint)
npm run format     # Formater le code (Prettier)

3. Votre première application Vue

3.1. Comprendre main.ts — Le point d’entrée

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

// Créer l'application Vue
const app = createApp(App)

// Brancher les plugins
app.use(createPinia())  // Gestion d'état
app.use(router)         // Routing

// Monter l'application sur l'élément #app dans index.html
app.mount('#app')

3.2. Comprendre App.vue — Le composant racine

<!-- src/App.vue -->
<template>
  <!-- Vue 3 permet plusieurs éléments racines (pas comme Vue 2 !) -->
  <header>
    <nav>
      <!-- RouterLink génère un <a> sans rechargement de page -->
      <RouterLink to="/">Accueil</RouterLink>
      <RouterLink to="/about">À propos</RouterLink>
    </nav>
  </header>

  <!-- RouterView affiche le composant de la route active -->
  <RouterView />
</template>

<script setup lang="ts">
// Avec <script setup>, pas besoin d'exporter un objet — tout est auto-exposé
import { RouterLink, RouterView } from 'vue-router'
</script>

<style scoped>
/* scoped = ce CSS ne s'applique QU'À CE composant */
header {
  background-color: #1a1a2e;
  padding: 1rem;
}
</style>

3.3. Anatomie d’un fichier .vue

Un Single File Component (SFC) Vue regroupe trois blocs dans un seul fichier :

<!-- MonComposant.vue -->

<!-- ① TEMPLATE : Ce qui est affiché -->
<template>
  <div class="conteneur">
    <h1>{{ titre }}</h1>
    <p>{{ description }}</p>
    <button @click="changerTitre">Changer le titre</button>
  </div>
</template>

<!-- ② SCRIPT : La logique -->
<script setup lang="ts">
import { ref } from 'vue'

// Variables réactives
const titre      = ref('Mon composant Vue')
const description = ref('Modifiez ce fichier et observez le rechargement.')

// Fonctions
function changerTitre() {
  titre.value = 'Titre modifié ! ' + new Date().toLocaleTimeString()
}
</script>

<!-- ③ STYLE : Le CSS (optionnel) -->
<style scoped>
.conteneur {
  max-width: 600px;
  margin:    0 auto;
  padding:   2rem;
}

h1 {
  color:     #42b883; /* Le vert de Vue ! */
  font-size: 2rem;
}
</style>

Les 3 blocs sont optionnels (un composant peut n’avoir qu’un <template> ou qu’un <script>), mais en pratique vous aurez toujours au moins les deux premiers.

3.4. Votre premier composant interactif

Remplacez le contenu de src/views/HomeView.vue :

<template>
  <main class="home">

    <section class="hero">
      <h1>Bienvenue sur <span class="accent">Vue 3</span></h1>
      <p>{{ sloganDynamique }}</p>
      <button class="btn-primary" @click="changerSlogan">
        ✨ Nouveau slogan
      </button>
    </section>

    <section class="compteur">
      <h2>Compteur interactif</h2>
      <div class="compteur-controls">
        <button @click="decrementer" :disabled="compteur <= 0"></button>
        <span class="valeur" :class="{ 'valeur--haute': compteur > 10 }">
          {{ compteur }}
        </span>
        <button @click="incrementer">+</button>
      </div>
      <p>{{ messageCompteur }}</p>
    </section>

    <section class="liste">
      <h2>Liste de technologies</h2>
      <input
        v-model="nouvelleTech"
        placeholder="Ajouter une technologie..."
        @keyup.enter="ajouterTech"
      />
      <ul>
        <li
          v-for="tech in technologies"
          :key="tech.id"
          @click="supprimerTech(tech.id)"
          title="Cliquer pour supprimer"
        >
          {{ tech.nom }} ×
        </li>
      </ul>
    </section>

  </main>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

// ── SECTION SLOGAN ──────────────────────────────────────────────────────────
const slogans = [
  'Le framework progressif pour vos interfaces.',
  'Simple, puissant, performant.',
  'Réactivité par design.',
  'Du web au mobile, avec une seule base de code.',
]
const indexSlogan   = ref(0)
const sloganDynamique = computed(() => slogans[indexSlogan.value])

function changerSlogan() {
  indexSlogan.value = (indexSlogan.value + 1) % slogans.length
}

// ── SECTION COMPTEUR ────────────────────────────────────────────────────────
const compteur = ref(0)

const messageCompteur = computed(() => {
  if (compteur.value === 0)  return 'Prêt à compter !'
  if (compteur.value < 5)    return 'On commence...'
  if (compteur.value < 10)   return 'Bien !'
  return '🔥 Vous êtes en feu !'
})

function incrementer() { compteur.value++ }
function decrementer()  { if (compteur.value > 0) compteur.value-- }

// ── SECTION LISTE ────────────────────────────────────────────────────────────
interface Tech { id: number; nom: string }

const technologies = ref<Tech[]>([
  { id: 1, nom: 'Vue 3' },
  { id: 2, nom: 'TypeScript' },
  { id: 3, nom: 'Vite' },
])
const nouvelleTech = ref('')

function ajouterTech() {
  if (!nouvelleTech.value.trim()) return
  technologies.value.push({
    id:  Date.now(),
    nom: nouvelleTech.value.trim(),
  })
  nouvelleTech.value = ''
}

function supprimerTech(id: number) {
  technologies.value = technologies.value.filter(t => t.id !== id)
}
</script>

<style scoped>
.home        { max-width: 800px; margin: 0 auto; padding: 2rem; }
.accent      { color: #42b883; }
.btn-primary {
  background: #42b883; color: white; border: none;
  padding: .6rem 1.4rem; border-radius: 6px; cursor: pointer;
}
.compteur-controls { display: flex; align-items: center; gap: 1rem; }
.valeur            { font-size: 2.5rem; font-weight: bold; min-width: 3rem; text-align: center; }
.valeur--haute     { color: #ff6b6b; }
button             { padding: .4rem .9rem; font-size: 1.2rem; cursor: pointer; border-radius: 4px; }
button:disabled    { opacity: .4; cursor: not-allowed; }
input              { padding: .5rem; width: 100%; margin-bottom: .5rem; border-radius: 4px; border: 1px solid #ccc; }
ul                 { list-style: none; padding: 0; }
li                 { background: #f4f4f4; margin: .3rem 0; padding: .5rem 1rem;
                     border-radius: 4px; cursor: pointer; transition: background .2s; }
li:hover           { background: #ffd2d2; }
section            { margin-bottom: 3rem; }
</style>

Lancez npm run dev et observez le résultat. Chaque modification du fichier est répercutée instantanément dans le navigateur.


4. La Composition API — Le cœur de Vue 3

4.1. Options API vs Composition API

Vue 3 propose 2 façons d’écrire les composants. Ce cours utilise exclusivement la Composition API avec <script setup>, qui est le standard moderne.

<!--  Options API — Vue 2 style, encore supporté mais non recommandé pour les nouveaux projets -->
<script>
export default {
  data() {
    return { compteur: 0, nom: '' }
  },
  computed: {
    message() { return `Bonjour ${this.nom}` }
  },
  methods: {
    incrementer() { this.compteur++ }
  },
  mounted() {
    console.log('Composant monté')
  }
}
</script>
<!--  Composition API avec <script setup> — Vue 3 moderne -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const compteur = ref(0)
const nom      = ref('')

const message = computed(() => `Bonjour ${nom.value}`)

function incrementer() { compteur.value++ }

onMounted(() => {
  console.log('Composant monté')
})
</script>

Avantages de la Composition API :

4.2. ref() — Réactivité pour les valeurs primitives

<script setup lang="ts">
import { ref } from 'vue'

//  ref() encapsule une valeur dans un objet réactif
// Accès à la valeur : variable.value (dans le script)
// Dans le template : {{ variable }} — Vue déroule .value automatiquement

const message    = ref('Bonjour Vue !')
const compteur   = ref(0)
const estVisible = ref(true)
const notes      = ref<number[]>([])

//  Modifier la valeur — toujours via .value dans le script
function reinitialiser() {
  message.value    = 'Valeur réinitialisée'
  compteur.value   = 0
  estVisible.value = false
  notes.value      = []
}

//  ref() pour les objets aussi (mais reactive() est préférable)
const utilisateur = ref({ nom: 'Alice', age: 28 })
// Modification :
utilisateur.value.nom = 'Bob'
// Remplacement complet :
utilisateur.value = { nom: 'Charlie', age: 35 }
</script>

<template>
  <!-- Dans le template : pas de .value ! Vue le gère automatiquement -->
  <p>{{ message }}</p>
  <p>{{ compteur }}</p>
  <p v-if="estVisible">Ce paragraphe est visible</p>
  <p>Notes : {{ notes.join(', ') }}</p>
  <p>Utilisateur : {{ utilisateur.nom }}, {{ utilisateur.age }} ans</p>
</template>

4.3. reactive() — Réactivité pour les objets

<script setup lang="ts">
import { reactive } from 'vue'

//  reactive() — pour les objets, la syntaxe est plus naturelle (pas de .value)
const formulaire = reactive({
  nom:          '',
  email:        '',
  age:          0,
  newsletter:   false,
  adresse: {
    rue:  '',
    ville: '',
    cp:   '',
  }
})

//  Modification directe des propriétés (pas de .value !)
function reinitialiser() {
  formulaire.nom        = ''
  formulaire.email      = ''
  formulaire.age        = 0
  formulaire.newsletter = false
  // Les objets imbriqués sont réactifs automatiquement
  formulaire.adresse.ville = ''
}

//  NE PAS remplacer l'objet entier — la réactivité serait perdue !
// formulaire = {} ← ❌ INTERDIT
// Object.assign(formulaire, {}) ←  OK pour réinitialiser
function reinitialiserProprement() {
  Object.assign(formulaire, {
    nom: '', email: '', age: 0, newsletter: false
  })
}
</script>

<template>
  <!-- v-model fonctionne directement avec les propriétés reactive -->
  <input v-model="formulaire.nom"   placeholder="Nom" />
  <input v-model="formulaire.email" placeholder="Email" />
  <input v-model.number="formulaire.age" type="number" placeholder="Âge" />
  <label>
    <input v-model="formulaire.newsletter" type="checkbox" />
    Je m'abonne à la newsletter
  </label>
  <p>Résumé : {{ formulaire.nom }}{{ formulaire.email }}</p>
</template>

4.4. computed() — Valeurs dérivées

<script setup lang="ts">
import { ref, computed } from 'vue'

const prix      = ref(100)
const quantite  = ref(3)
const tauxTva   = ref(0.20)
const codePromo = ref('')

//  computed() — recalculé uniquement quand les dépendances changent
// Équivalent de useMemo() en React
const sousTotal       = computed(() => prix.value * quantite.value)
const montantTva      = computed(() => sousTotal.value * tauxTva.value)
const remise          = computed(() => codePromo.value === 'VUE2024' ? 0.10 : 0)
const totalFinal      = computed(() => sousTotal.value * (1 + tauxTva.value) * (1 - remise.value))
const messageRemise   = computed(() =>
  remise.value > 0 ? ` Code promo appliqué ! -${remise.value * 100}%` : ''
)

//  computed() en lecture/écriture (getter + setter)
const prenomNom = computed({
  get() {
    return `${prenom.value} ${nom.value}`.trim()
  },
  set(valeurComplete: string) {
    const parties = valeurComplete.split(' ')
    prenom.value  = parties[0] ?? ''
    nom.value     = parties[1] ?? ''
  }
})

const prenom = ref('Alice')
const nom    = ref('Martin')
</script>

<template>
  <div>
    <p>Sous-total :  {{ sousTotal.toFixed(2) }}</p>
    <p>TVA (20%) :   {{ montantTva.toFixed(2) }}</p>
    <p v-if="messageRemise" style="color: green">{{ messageRemise }}</p>
    <p><strong>Total : {{ totalFinal.toFixed(2) }}</strong></p>

    <input v-model="codePromo" placeholder="Code promo" />

    <!-- Écriture dans un computed writable -->
    <input v-model="prenomNom" placeholder="Prénom Nom" />
    <p>Prénom : {{ prenom }} — Nom : {{ nom }}</p>
  </div>
</template>

4.5. watch() et watchEffect() — Réagir aux changements

<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'

const recherche    = ref('')
const resultats    = ref<string[]>([])
const enChargement = ref(false)
const utilisateur  = ref({ nom: 'Alice', role: 'admin' })

//  watch() — observe UNE source précise, similaire à useEffect([dep]) en React
watch(recherche, async (nouvelleValeur, ancienneValeur) => {
  console.log(`Recherche : "${ancienneValeur}" → "${nouvelleValeur}"`)

  if (!nouvelleValeur.trim()) {
    resultats.value = []
    return
  }

  enChargement.value = true
  // Simuler un appel API
  await new Promise(r => setTimeout(r, 300))
  resultats.value    = [`Résultat pour "${nouvelleValeur}" 1`, `Résultat 2`, `Résultat 3`]
  enChargement.value = false
})

//  watch() avec options
watch(recherche, (val) => {
  console.log('Déclenchement immédiat :', val)
}, {
  immediate: true,  // Déclencher dès le montage (pas seulement au changement)
  deep:      false, // true = observer les propriétés imbriquées
})

//  Observer plusieurs sources en même temps
watch([recherche, enChargement], ([nouvelleRecherche, estCharge]) => {
  console.log('Changement détecté :', nouvelleRecherche, estCharge)
})

//  Observer un objet en profondeur (deep: true)
watch(
  () => utilisateur.value.role,  // Getter — observer une propriété précise
  (nouveauRole) => {
    console.log('Rôle changé :', nouveauRole)
  }
)

//  watchEffect() — observe TOUTES les dépendances utilisées dans la fonction
// Plus simple que watch() quand on n'a pas besoin de l'ancienne valeur
watchEffect(() => {
  // Toute ref ou reactive utilisée ici est automatiquement observée
  if (recherche.value.length > 0) {
    document.title = `Recherche : ${recherche.value}`
  } else {
    document.title = 'Mon application Vue'
  }
})
</script>

4.6. Le cycle de vie des composants

<script setup lang="ts">
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
} from 'vue'

//  onMounted — Le plus utilisé : composant dans le DOM, lancer les appels API
onMounted(async () => {
  console.log(' Composant monté — lancer les appels API ici')
  // await chargerDonnees()
})

//  onUnmounted — Nettoyage : timers, listeners, abonnements
let timer: ReturnType<typeof setInterval>

onMounted(() => {
  timer = setInterval(() => console.log('tick'), 1000)
})

onUnmounted(() => {
  clearInterval(timer)  // ← TOUJOURS nettoyer pour éviter les fuites mémoire
  console.log('🗑️ Composant détruit — nettoyage effectué')
})

// Les autres hooks (utilisés moins fréquemment)
onBeforeMount(()  => console.log('Avant le montage'))
onBeforeUpdate(() => console.log('Avant mise à jour du DOM'))
onUpdated(()      => console.log('Après mise à jour du DOM'))
onBeforeUnmount(() => console.log('Avant destruction'))
</script>

Ordre d’exécution :

Création du composant
    ↓
onBeforeMount()      ← DOM pas encore créé
    ↓
[DOM créé]
    ↓
onMounted()          ← DOM prêt, accès aux éléments, appels API ici 
    ↓
[Changement de données → Vue met à jour le DOM]
    ↓
onBeforeUpdate()
    ↓
onUpdated()
    ↓
[Composant retiré du DOM]
    ↓
onBeforeUnmount()
    ↓
onUnmounted()        ← Nettoyage ici 

4.7. toRefs() et toRef() — Déstructurer sans perdre la réactivité

<script setup lang="ts">
import { reactive, toRefs, toRef } from 'vue'

const etat = reactive({
  nom:   'Alicia',
  email: 'alicia@exemple.com',
  age:   28,
})

//  Déstructuration normale — perd la réactivité !
// const { nom, email } = etat  ← nom et email ne sont plus réactifs

//  toRefs() — convertit chaque propriété reactive en ref individuelle
const { nom, email, age } = toRefs(etat)
// Maintenant nom, email, age sont des refs (avec .value) ET réactifs

//  toRef() — pour une seule propriété
const nomSeul = toRef(etat, 'nom')

// Modifier via la ref modifie l'objet reactive original
nom.value = 'Boby'
console.log(etat.nom) // → 'Bob'
</script>

5. Composants — Architecture et communication

5.1. Créer et utiliser un composant

<!-- src/components/CarteUtilisateur.vue -->
<template>
  <div class="carte">
    <div class="carte__avatar">
      <img v-if="avatar" :src="avatar" :alt="nom" />
      <span v-else class="initiales">{{ initiales }}</span>
    </div>
    <div class="carte__info">
      <h3>{{ nom }}</h3>
      <p>{{ email }}</p>
      <span class="badge" :class="`badge--${role}`">{{ role }}</span>
    </div>
    <div class="carte__actions">
      <button @click="$emit('modifier', { nom, email, role })">✏️ Modifier</button>
      <button @click="$emit('supprimer', email)" class="btn-danger">🗑️ Supprimer</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

//  Définition des props avec TypeScript
interface Props {
  nom:    string
  email:  string
  role:   'admin' | 'user' | 'moderateur'
  avatar?: string  // Optionnel
}

const props = defineProps<Props>()

//  Définition des événements émis
const emit = defineEmits<{
  modifier:  [utilisateur: { nom: string; email: string; role: string }]
  supprimer: [email: string]
}>()

//  Props accessible dans computed
const initiales = computed(() =>
  props.nom
    .split(' ')
    .map(p => p[0]?.toUpperCase() ?? '')
    .join('')
    .slice(0, 2)
)
</script>

<style scoped>
.carte {
  display:       flex;
  align-items:   center;
  gap:           1rem;
  padding:       1rem;
  border:        1px solid #e2e8f0;
  border-radius: 8px;
  margin-bottom: .5rem;
}
.initiales {
  background: #42b883; color: white;
  width: 48px; height: 48px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-weight: bold;
}
.badge {
  display: inline-block; padding: .2rem .6rem;
  border-radius: 12px; font-size: .8rem; font-weight: 600;
}
.badge--admin      { background: #fee2e2; color: #991b1b; }
.badge--moderateur { background: #fef3c7; color: #92400e; }
.badge--user       { background: #d1fae5; color: #065f46; }
.btn-danger        { background: #fee2e2; color: #991b1b; border: none; cursor: pointer; padding: .3rem .6rem; border-radius: 4px; }
.carte__actions    { margin-left: auto; display: flex; gap: .5rem; }
</style>
<!-- src/views/UtilisateursView.vue — Utilisation du composant -->
<template>
  <main class="conteneur">
    <h1>Gestion des utilisateurs</h1>

    <!--  Utilisation du composant avec passage de props et écoute d'événements -->
    <CarteUtilisateur
      v-for="user in utilisateurs"
      :key="user.email"
      :nom="user.nom"
      :email="user.email"
      :role="user.role"
      :avatar="user.avatar"
      @modifier="surModification"
      @supprimer="surSuppression"
    />

    <p v-if="utilisateurs.length === 0" class="vide">
      Aucun utilisateur. <a href="#" @click.prevent="ajouterExemple">Ajouter un exemple</a>
    </p>
  </main>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import CarteUtilisateur from '@/components/CarteUtilisateur.vue'

const utilisateurs = ref([
  { nom: 'Alicie Martini',  email: 'alicia@exemple.com',  role: 'admin' as const,      avatar: undefined },
  { nom: 'Boby Lapointe',    email: 'boby@exemple.com',    role: 'moderateur' as const, avatar: undefined },
  { nom: 'Clara Bernardino', email: 'clara@exemple.com', role: 'user' as const,       avatar: undefined },
])

function surModification(utilisateur: { nom: string; email: string; role: string }) {
  console.log('Modifier :', utilisateur)
  // Ouvrir un formulaire de modification...
}

function surSuppression(email: string) {
  utilisateurs.value = utilisateurs.value.filter(u => u.email !== email)
}

function ajouterExemple() {
  utilisateurs.value.push({ nom: 'Exemple', email: 'ex@exemple.com', role: 'user', avatar: undefined })
}
</script>

5.2. Props — Communication parent → enfant

<script setup lang="ts">
//  Approche 1 : TypeScript pur (recommandée)
interface Props {
  titre:       string
  description?: string        // Optionnel
  nombre:      number
  actif:       boolean
  couleur:     'primaire' | 'secondaire' | 'danger'
  tags:        string[]
  config:      { taille: number; visible: boolean }
}

const props = defineProps<Props>()

//  Valeurs par défaut avec withDefaults
const propsAvecDefauts = withDefaults(
  defineProps<{
    titre:      string
    taille?:    'sm' | 'md' | 'lg'
    desactive?: boolean
  }>(),
  {
    taille:     'md',
    desactive:  false,
  }
)

//  Approche 2 : Objet avec validation (style Vue classique)
const propsValidees = defineProps({
  nom: {
    type:     String,
    required: true,
  },
  age: {
    type:     Number,
    default:  18,
    validator: (valeur: number) => valeur >= 0 && valeur <= 150,
  },
})
</script>

Règle fondamentale : Les props sont en lecture seule. Un enfant ne doit jamais modifier directement une prop.

<script setup lang="ts">
import { ref, watch } from 'vue'

const props = defineProps<{ valeur: string }>()
const emit  = defineEmits<{ 'update:valeur': [val: string] }>()

//  NE JAMAIS faire ça
// props.valeur = 'nouveau' ← Vue affichera un avertissement

//  Pattern correct : copier la prop dans un état local, réémettre vers le parent
const valeurLocale = ref(props.valeur)

watch(() => props.valeur, (nouvelleValeur) => {
  valeurLocale.value = nouvelleValeur
})

function surChangement(event: Event) {
  const valeur = (event.target as HTMLInputElement).value
  emit('update:valeur', valeur) // Informer le parent
}
</script>

5.3. v-model sur les composants — Le two-way binding

<!-- src/components/ChampSaisie.vue — Composant input personnalisé -->
<template>
  <div class="champ">
    <label :for="id" v-if="label">{{ label }}</label>
    <input
      :id="id"
      :value="modelValue"
      :type="type"
      :placeholder="placeholder"
      :disabled="disabled"
      @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
    />
    <span v-if="erreur" class="champ__erreur">{{ erreur }}</span>
  </div>
</template>

<script setup lang="ts">
//  Pour que v-model fonctionne sur un composant :
// - prop : modelValue
// - event : update:modelValue

defineProps<{
  modelValue: string
  label?:     string
  type?:      string
  placeholder?: string
  disabled?:  boolean
  erreur?:    string
  id?:        string
}>()

defineEmits<{ 'update:modelValue': [valeur: string] }>()
</script>
<!-- Utilisation dans un parent -->
<template>
  <!--  v-model sur le composant — comme sur un <input> natif ! -->
  <ChampSaisie
    v-model="email"
    label="Adresse email"
    type="email"
    placeholder="vous@exemple.fr"
    :erreur="erreurEmail"
  />
  <p>Valeur actuelle : {{ email }}</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ChampSaisie from '@/components/ChampSaisie.vue'

const email      = ref('')
const erreurEmail = ref('')
</script>

5.4. Provide / Inject — Communication indirecte

<!-- Composant ancêtre (ex: App.vue ou un layout) -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const theme          = ref<'clair' | 'sombre'>('clair')
const utilisateurActif = ref({ nom: 'Alice', role: 'admin' })

//  provide() — rendre disponible pour tous les descendants
// Sans avoir à passer les props à travers tous les composants intermédiaires
provide('theme', theme)
provide('utilisateur', utilisateurActif)

//  Bonne pratique : exporter les clés comme constantes typées
</script>
<!-- Composant descendant (peu importe la profondeur) -->
<script setup lang="ts">
import { inject, ref } from 'vue'

//  inject() — récupérer ce qui a été fourni par un ancêtre
const theme       = inject<Ref<string>>('theme', ref('clair'))  // 2ème arg = valeur par défaut
const utilisateur = inject<{ nom: string; role: string }>('utilisateur')

console.log(theme.value)         // → 'clair'
console.log(utilisateur?.nom)    // → 'Alice'
</script>

5.5. Slots — Composition de composants

<!-- src/components/Carte.vue — Composant avec slots -->
<template>
  <div class="carte" :class="`carte--${variante}`">

    <!--  Slot nommé : en-tête optionnel -->
    <header v-if="$slots.entete" class="carte__entete">
      <slot name="entete" />
    </header>

    <!--  Slot par défaut : contenu principal -->
    <div class="carte__corps">
      <slot>
        <!-- Contenu par défaut si aucun contenu passé -->
        <p style="color: #999">Aucun contenu.</p>
      </slot>
    </div>

    <!--  Slot nommé + contenu par défaut -->
    <footer v-if="$slots.pied" class="carte__pied">
      <slot name="pied" />
    </footer>

  </div>
</template>

<script setup lang="ts">
defineProps<{ variante?: 'defaut' | 'elevee' | 'borderee' }>()
</script>
<!-- Utilisation avec des slots -->
<template>
  <!-- Carte simple -->
  <Carte>
    <p>Contenu basique dans le slot par défaut.</p>
  </Carte>

  <!-- Carte complète avec slots nommés -->
  <Carte variante="elevee">
    <!--  Utiliser template + #nom pour les slots nommés -->
    <template #entete>
      <h2>🏆 Titre de la carte</h2>
      <span class="badge">Nouveau</span>
    </template>

    <!-- Contenu principal (slot par défaut) -->
    <p>Ceci est le corps de la carte. Il peut contenir n'importe quoi.</p>
    <ul>
      <li>Élément 1</li>
      <li>Élément 2</li>
    </ul>

    <template #pied>
      <button>Action principale</button>
      <button>Annuler</button>
    </template>
  </Carte>
</template>

6. Directives et rendu conditionnel

6.1. v-if, v-else-if, v-else — Rendu conditionnel

<template>
  <div>

    <!--  v-if — retire complètement l'élément du DOM -->
    <div v-if="statut === 'charge'">
      <Spinner />
    </div>
    <div v-else-if="statut === 'erreur'">
      <MessageErreur :message="messageErreur" />
    </div>
    <div v-else-if="elements.length === 0">
      <EtatVide texte="Aucun élément trouvé." />
    </div>
    <div v-else>
      <ListeElements :elements="elements" />
    </div>

    <!--  v-show — masque avec display:none, l'élément reste dans le DOM -->
    <!-- Préférer v-show quand le toggle est fréquent -->
    <ModalChargement v-show="enSauvegarde" />

    <!--  <template> — grouper plusieurs éléments sans créer de div -->
    <template v-if="estAdmin">
      <BoutonSupprimer />
      <BoutonModifier />
      <MenuAdministration />
    </template>

  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

type Statut = 'charge' | 'erreur' | 'vide' | 'ok'

const statut       = ref<Statut>('ok')
const messageErreur = ref('')
const elements     = ref([1, 2, 3])
const enSauvegarde = ref(false)
const estAdmin     = ref(true)
</script>

Règle de choix : utilisez v-if quand l’élément est rarement affiché (coût de rendu économisé), et v-show quand il bascule fréquemment (coût du toggle réduit).

6.2. v-for — Rendu de listes

<template>
  <!--  v-for sur un tableau d'objets — toujours avec :key unique ! -->
  <ul>
    <li
      v-for="utilisateur in utilisateurs"
      :key="utilisateur.id"
    >
      {{ utilisateur.nom }}{{ utilisateur.email }}
    </li>
  </ul>

  <!--  v-for avec index -->
  <ol>
    <li v-for="(item, index) in items" :key="item.id">
      {{ index + 1 }}. {{ item.titre }}
    </li>
  </ol>

  <!--  v-for sur un objet (clé, valeur, index) -->
  <dl>
    <template v-for="(valeur, cle, index) in configuration" :key="cle">
      <dt>{{ index + 1 }}. {{ cle }}</dt>
      <dd>{{ valeur }}</dd>
    </template>
  </dl>

  <!--  v-for avec une plage numérique -->
  <span v-for="n in 5" :key="n"></span>

  <!--  v-for + v-if combinés : TOUJOURS sur des éléments différents -->
  <!-- Mettre v-for sur le parent, v-if sur l'enfant -->
  <ul>
    <template v-for="tache in taches" :key="tache.id">
      <li v-if="!tache.archivee">{{ tache.titre }}</li>
    </template>
  </ul>

  <!--  NE PAS mettre v-for et v-if sur le même élément -->
  <!-- <li v-for="t in taches" v-if="!t.archivee"> ← Priorité ambiguë -->

</template>

<script setup lang="ts">
import { ref } from 'vue'

const utilisateurs = ref([
  { id: 1, nom: 'Alice',  email: 'alice@ex.com' },
  { id: 2, nom: 'Bob',    email: 'bob@ex.com'   },
])
const items        = ref([{ id: 1, titre: 'Vue' }, { id: 2, titre: 'Pinia' }])
const configuration = ref({ theme: 'sombre', langue: 'fr', version: '3.4' })
const taches       = ref([{ id: 1, titre: 'Apprendre Vue', archivee: false }])
</script>

6.3. Gestion des événements

<template>
  <!--  Gestion d'événements basique -->
  <button @click="direBonjour">Bonjour</button>

  <!--  Événement avec l'objet Event natif -->
  <input @input="surSaisie" />

  <!--  Passer des arguments ET l'événement natif avec $event -->
  <button @click="surClic('premier', $event)">Premier</button>

  <!--  Modificateurs d'événements — très utiles ! -->
  <form @submit.prevent="soumettre">        <!-- preventDefault() automatique -->
    <a @click.stop="action">Lien</a>        <!-- stopPropagation() -->
    <div @click.self="surClic">             <!-- Seulement si clic direct (pas enfant) -->
      <button>Bouton interne</button>
    </div>
  </form>

  <!--  Modificateurs de touches -->
  <input
    @keyup.enter="valider"      <!-- Touche Entrée -->
    @keyup.escape="annuler"     <!-- Touche Échap -->
    @keydown.ctrl.s.prevent="sauvegarder"  <!-- Ctrl+S sans comportement navigateur -->
  />

  <!--  Modificateurs de boutons souris -->
  <div
    @click.left="surClicGauche"
    @click.right.prevent="surClicDroit"
    @click.middle="surClicMilieu"
  />

  <!--  Modifier la valeur à la perte du focus (au lieu de chaque frappe) -->
  <input v-model.lazy="texte" />

  <!--  Conversion automatique en nombre -->
  <input v-model.number="age" type="number" />

  <!--  Supprimer les espaces en début/fin -->
  <input v-model.trim="nom" />
</template>

<script setup lang="ts">
import { ref } from 'vue'

const texte = ref('')
const age   = ref(0)
const nom   = ref('')

function direBonjour()  { alert('Bonjour !') }
function surSaisie(e: Event) { console.log((e.target as HTMLInputElement).value) }
function surClic(id: string, event: MouseEvent) { console.log(id, event.clientX) }
function soumettre()    { console.log('Formulaire soumis') }
function action()       { console.log('Action') }
function valider()      { console.log('Validé') }
function annuler()      { console.log('Annulé') }
function sauvegarder()  { console.log('Sauvegardé') }
function surClicGauche()  { console.log('Gauche') }
function surClicDroit()   { console.log('Droit') }
function surClicMilieu()  { console.log('Milieu') }
</script>

6.4. Liaison de classes et de styles

<template>
  <!--  :class avec objet — les clés sont ajoutées si leur valeur est truthy -->
  <div :class="{
    'actif':      estActif,
    'desactive':  estDesactive,
    'charge':     enChargement,
    'erreur':     aUneErreur,
  }">Contenu</div>

  <!--  :class avec tableau -->
  <div :class="['base', estActif ? 'actif' : 'inactif', classeSupplementaire]">
    Contenu
  </div>

  <!--  :class avec computed (recommandé pour la logique complexe) -->
  <button :class="classesBouton" @click="estActif = !estActif">
    Toggle
  </button>

  <!--  :style avec objet -->
  <div :style="{
    color:           couleurTexte,
    fontSize:        taillePolicePx,     // camelCase en Vue
    'background-color': '#f0f0f0',       // kebab-case entre guillemets
    transform:       `rotate(${angle}deg)`,
  }">Contenu stylisé</div>

  <!--  :style avec tableau (pour combiner plusieurs objets de style) -->
  <div :style="[styleBase, styleTheme, styleDynamique]">Contenu</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const estActif             = ref(false)
const estDesactive         = ref(false)
const enChargement         = ref(true)
const aUneErreur           = ref(false)
const classeSupplementaire = ref('ma-classe')
const couleurTexte         = ref('#3366ff')
const angle                = ref(0)

const taillePolicePx = computed(() => `${14 + (estActif.value ? 4 : 0)}px`)

//  Logique de classes dans un computed — propre et lisible
const classesBouton = computed(() => ({
  'btn':           true,
  'btn--primaire': !estActif.value,
  'btn--actif':    estActif.value,
  'btn--desactive': estDesactive.value,
  'btn--lg':       true,
}))

const styleBase   = { padding: '1rem', borderRadius: '8px' }
const styleTheme  = { background: '#1a1a2e', color: '#ffffff' }
const styleDynamique = computed(() => ({ opacity: estActif.value ? 1 : 0.5 }))
</script>

6.5. Directives personnalisées

// src/directives/focus.ts
import type { Directive } from 'vue'

//  Directive v-focus : met le focus automatiquement sur l'élément
export const vFocus: Directive = {
  mounted(el: HTMLElement) {
    el.focus()
  }
}
// src/directives/tooltip.ts
import type { Directive } from 'vue'

//  Directive v-tooltip : affiche une infobulle personnalisée
export const vTooltip: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    el.setAttribute('title', binding.value)
    el.style.position = 'relative'
    el.style.cursor   = 'help'
  },
  updated(el, binding) {
    el.setAttribute('title', binding.value)
  },
  unmounted(el) {
    el.removeAttribute('title')
  }
}
// src/main.ts — Enregistrement global
import { vFocus }   from './directives/focus'
import { vTooltip } from './directives/tooltip'

app.directive('focus',   vFocus)
app.directive('tooltip', vTooltip)
<template>
  <!--  Utilisation des directives personnalisées -->
  <input v-focus type="text" placeholder="Autofocus ici" />
  <span v-tooltip="'Ceci est une explication détaillée'">ℹ️ Aide</span>
</template>

7. Vue Router — Navigation et routing

7.1. Configuration des routes

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes: RouteRecordRaw[] = [

  //  Route simple
  {
    path:      '/',
    name:      'accueil',
    component: () => import('@/views/HomeView.vue'), // Lazy loading
  },

  //  Route avec paramètre dynamique
  {
    path:      '/utilisateurs/:id',
    name:      'utilisateur-detail',
    component: () => import('@/views/UtilisateurDetailView.vue'),
    // Passer les params comme props au composant (recommandé)
    props:     true,
  },

  //  Routes imbriquées (layout partagé)
  {
    path:      '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta:      { requiresAuth: true, roles: ['admin'] }, // Métadonnées
    children: [
      { path: '',           name: 'admin',            component: () => import('@/views/admin/DashboardView.vue') },
      { path: 'utilisateurs', name: 'admin-utilisateurs', component: () => import('@/views/admin/UtilisateursView.vue') },
      { path: 'parametres', name: 'admin-parametres', component: () => import('@/views/admin/ParametresView.vue') },
    ],
  },

  //  Redirection
  {
    path:     '/home',
    redirect: '/',
  },

  //  Route 404 — toujours en dernier !
  {
    path:      '/:pathMatch(.*)*',
    name:      '404',
    component: () => import('@/views/NotFoundView.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,

  //  Scroll en haut de page à chaque navigation
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition      // Restaurer la position (bouton retour)
    }
    if (to.hash) {
      return { el: to.hash }    // Ancre dans la page
    }
    return { top: 0 }           // Haut de page par défaut
  },
})

//  Guard de navigation global — protection des routes
router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore()

  if (to.meta.requiresAuth && !authStore.estConnecte) {
    // Sauvegarder la destination pour rediriger après connexion
    next({ name: 'login', query: { redirect: to.fullPath } })
    return
  }

  if (to.meta.roles && !authStore.aLeRole(to.meta.roles as string[])) {
    next({ name: 'acces-refuse' })
    return
  }

  next()
})

export default router

7.2. Navigation programmatique

<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()  // Pour naviguer
const route  = useRoute()   // Pour lire la route courante

//  Lire les paramètres de la route
console.log(route.params.id)       // Paramètre :id
console.log(route.query.recherche) // ?recherche=vue
console.log(route.meta)            // Métadonnées de la route
console.log(route.name)            // Nom de la route courante

//  Naviguer vers une route
function allerAccueil() {
  router.push('/')
}

function allerUtilisateur(id: number) {
  // Par path
  router.push(`/utilisateurs/${id}`)

  // OU par nom (plus robuste face aux changements d'URL)
  router.push({ name: 'utilisateur-detail', params: { id } })
}

function allerRecherche(terme: string) {
  // Avec query string
  router.push({ path: '/recherche', query: { q: terme, page: 1 } })
}

//  Remplacer sans ajouter à l'historique (pour les redirections après login)
function allerApresLogin() {
  const redirect = route.query.redirect as string ?? '/'
  router.replace(redirect)
}

//  Navigation dans l'historique
function retour()   { router.back() }
function suivant()  { router.forward() }
function reculer(n: number) { router.go(-n) }
</script>

<template>
  <!--  Navigation déclarative dans le template -->
  <RouterLink to="/">Accueil</RouterLink>

  <!-- Navigation par nom de route -->
  <RouterLink :to="{ name: 'utilisateur-detail', params: { id: 42 } }">
    Voir utilisateur
  </RouterLink>

  <!-- RouterLink avec classes actives personnalisées -->
  <RouterLink
    to="/admin"
    active-class="lien--actif"
    exact-active-class="lien--exact"
  >
    Administration
  </RouterLink>
</template>

7.3. Layouts partagés

<!-- src/layouts/MainLayout.vue -->
<template>
  <div class="layout">

    <!-- Sidebar de navigation -->
    <aside class="layout__sidebar">
      <nav>
        <RouterLink to="/">🏠 Accueil</RouterLink>
        <RouterLink to="/utilisateurs">👥 Utilisateurs</RouterLink>
        <RouterLink to="/projets">📁 Projets</RouterLink>
        <RouterLink to="/admin" v-if="authStore.estAdmin">⚙️ Admin</RouterLink>
      </nav>
    </aside>

    <!-- Contenu principal -->
    <div class="layout__contenu">
      <header class="layout__header">
        <h1>{{ route.meta.titre as string ?? 'Application' }}</h1>
        <div class="layout__utilisateur">
          <span>{{ authStore.nomComplet }}</span>
          <button @click="authStore.deconnecter">Déconnexion</button>
        </div>
      </header>

      <main class="layout__main">
        <!--  RouterView avec transition -->
        <RouterView v-slot="{ Component }">
          <Transition name="page" mode="out-in">
            <component :is="Component" :key="route.path" />
          </Transition>
        </RouterView>
      </main>
    </div>

  </div>
</template>

<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const route     = useRoute()
const authStore = useAuthStore()
</script>

<style scoped>
.layout           { display: flex; min-height: 100vh; }
.layout__sidebar  { width: 240px; background: #1a1a2e; padding: 1rem; }
.layout__contenu  { flex: 1; display: flex; flex-direction: column; }
.layout__header   { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #e2e8f0; }
.layout__main     { flex: 1; padding: 2rem; }

/* Transitions de page */
.page-enter-active, .page-leave-active { transition: all .2s ease; }
.page-enter-from  { opacity: 0; transform: translateX(20px); }
.page-leave-to    { opacity: 0; transform: translateX(-20px); }
</style>

8. Pinia — Gestion d’état global

8.1. Pourquoi Pinia ?

Pinia est le gestionnaire d’état officiel pour Vue 3. Il remplace Vuex (qui reste fonctionnel mais n’est plus maintenu activement). Par rapport à Vuex :

Aspect Vuex 4 Pinia
Mutations Obligatoires Supprimées
Modules Complexes à imbriquer Stores plats
TypeScript Difficile Natif
DevTools Partielles Complètes
Bundle size ~10 KB ~1 KB
Syntaxe Verbose Concise

8.2. Store d’authentification

// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService } from '@/services/authService'
import type { Utilisateur } from '@/types'

export const useAuthStore = defineStore('auth', () => {

  // ═══════════════════════════════════════════════════════
  // STATE
  // ═══════════════════════════════════════════════════════
  const token       = ref<string | null>(localStorage.getItem('token'))
  const utilisateur = ref<Utilisateur | null>(null)
  const chargement  = ref(false)
  const erreur      = ref('')

  // ═══════════════════════════════════════════════════════
  // GETTERS (computed)
  // ═══════════════════════════════════════════════════════
  const estConnecte = computed(() => !!token.value)
  const estAdmin    = computed(() => utilisateur.value?.roles.includes('ROLE_ADMIN') ?? false)
  const nomComplet  = computed(() =>
    utilisateur.value
      ? `${utilisateur.value.prenom} ${utilisateur.value.nom}`
      : 'Invité'
  )

  function aLeRole(roles: string[]): boolean {
    return roles.some(role => utilisateur.value?.roles.includes(role))
  }

  // ═══════════════════════════════════════════════════════
  // ACTIONS
  // ═══════════════════════════════════════════════════════
  async function connecter(email: string, motDePasse: string) {
    chargement.value = true
    erreur.value     = ''
    try {
      const data        = await authService.connecter({ email, password: motDePasse })
      token.value       = data.token
      utilisateur.value = data.user
      localStorage.setItem('token', data.token)
      localStorage.setItem('user',  JSON.stringify(data.user))
    } catch (e: any) {
      erreur.value = e.response?.data?.message ?? 'Identifiants incorrects.'
      throw e
    } finally {
      chargement.value = false
    }
  }

  function deconnecter() {
    token.value       = null
    utilisateur.value = null
    localStorage.removeItem('token')
    localStorage.removeItem('user')
  }

  function initialiserDepuisStorage() {
    const userJson = localStorage.getItem('user')
    if (token.value && userJson) {
      try { utilisateur.value = JSON.parse(userJson) }
      catch { deconnecter() }
    }
  }

  return {
    token, utilisateur, chargement, erreur,
    estConnecte, estAdmin, nomComplet,
    aLeRole, connecter, deconnecter, initialiserDepuisStorage,
  }
})

8.3. Store de données métier

// src/stores/projets.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { projetService } from '@/services/projetService'
import type { Projet, CreerProjetDTO } from '@/types'

export const useProjetsStore = defineStore('projets', () => {

  // STATE
  const projets     = ref<Projet[]>([])
  const chargement  = ref(false)
  const erreur      = ref('')
  const filtreStatut = ref<string>('tous')
  const recherche   = ref('')

  // GETTERS
  const projetsFiltres = computed(() => {
    let liste = projets.value

    if (filtreStatut.value !== 'tous') {
      liste = liste.filter(p => p.statut === filtreStatut.value)
    }

    if (recherche.value.trim()) {
      const terme = recherche.value.toLowerCase()
      liste = liste.filter(p =>
        p.nom.toLowerCase().includes(terme) ||
        p.description?.toLowerCase().includes(terme)
      )
    }

    return liste
  })

  const nombreParStatut = computed(() => ({
    total:     projets.value.length,
    actifs:    projets.value.filter(p => p.statut === 'actif').length,
    termines:  projets.value.filter(p => p.statut === 'termine').length,
    pauses:    projets.value.filter(p => p.statut === 'pause').length,
  }))

  // ACTIONS
  async function charger() {
    chargement.value = true
    erreur.value     = ''
    try {
      projets.value = await projetService.trouverTous()
    } catch (e: any) {
      erreur.value = 'Impossible de charger les projets.'
    } finally {
      chargement.value = false
    }
  }

  async function creer(dto: CreerProjetDTO) {
    const nouveau = await projetService.creer(dto)
    projets.value.unshift(nouveau)  // Ajouter en tête de liste
    return nouveau
  }

  async function mettreAJour(id: number, dto: Partial<CreerProjetDTO>) {
    const maj   = await projetService.mettreAJour(id, dto)
    const index = projets.value.findIndex(p => p.id === id)
    if (index !== -1) projets.value[index] = maj
    return maj
  }

  async function supprimer(id: number) {
    await projetService.supprimer(id)
    projets.value = projets.value.filter(p => p.id !== id)
  }

  return {
    projets, chargement, erreur, filtreStatut, recherche,
    projetsFiltres, nombreParStatut,
    charger, creer, mettreAJour, supprimer,
  }
})

8.4. Persistance du store

// Installation du plugin de persistance
// npm install pinia-plugin-persistedstate

// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// Dans le store — activer la persistance
export const useAuthStore = defineStore('auth', () => {
  // ... state, getters, actions
}, {
  persist: {
    key:      'auth',           // Clé dans localStorage
    storage:  localStorage,
    paths:    ['token', 'utilisateur'],  // Seulement ces champs
  },
})

8.5. Utiliser plusieurs stores ensemble

<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore }   from '@/stores/auth'
import { useProjetsStore } from '@/stores/projets'
import { useUiStore }     from '@/stores/ui'

const authStore    = useAuthStore()
const projetsStore = useProjetsStore()
const uiStore      = useUiStore()

onMounted(async () => {
  // Charger les données si l'utilisateur est connecté
  if (authStore.estConnecte) {
    await projetsStore.charger()
  }
})
</script>

<template>
  <div>
    <p>Bienvenue, {{ authStore.nomComplet }}</p>
    <p>{{ projetsStore.nombreParStatut.total }} projets</p>

    <!-- Filtres liés au store -->
    <select v-model="projetsStore.filtreStatut">
      <option value="tous">Tous</option>
      <option value="actif">Actifs</option>
      <option value="termine">Terminés</option>
    </select>

    <input v-model="projetsStore.recherche" placeholder="Rechercher..." />

    <!-- Liste filtrée automatiquement -->
    <ProjetCard
      v-for="projet in projetsStore.projetsFiltres"
      :key="projet.id"
      :projet="projet"
    />
  </div>
</template>

9. Formulaires et validation

9.1. Formulaire complet avec validation manuelle

<template>
  <form @submit.prevent="soumettre" novalidate>

    <div class="champ" :class="{ 'champ--erreur': erreurs.nom }">
      <label for="nom">Nom complet *</label>
      <input
        id="nom"
        v-model.trim="form.nom"
        @blur="validerNom"
        autocomplete="name"
      />
      <span class="erreur" v-if="erreurs.nom">{{ erreurs.nom }}</span>
    </div>

    <div class="champ" :class="{ 'champ--erreur': erreurs.email }">
      <label for="email">Email *</label>
      <input
        id="email"
        v-model.trim="form.email"
        type="email"
        @blur="validerEmail"
        autocomplete="email"
      />
      <span class="erreur" v-if="erreurs.email">{{ erreurs.email }}</span>
    </div>

    <div class="champ" :class="{ 'champ--erreur': erreurs.motDePasse }">
      <label for="mdp">Mot de passe *</label>
      <div class="input-group">
        <input
          id="mdp"
          v-model="form.motDePasse"
          :type="visible ? 'text' : 'password'"
          @blur="validerMotDePasse"
        />
        <button type="button" @click="visible = !visible">
          {{ visible ? '🙈' : '👁️' }}
        </button>
      </div>
      <div class="force-mdp">
        <div
          class="force-mdp__barre"
          :style="{ width: forcePct + '%' }"
          :class="`force-mdp__barre--${niveauForce}`"
        />
      </div>
      <span class="erreur" v-if="erreurs.motDePasse">{{ erreurs.motDePasse }}</span>
    </div>

    <div class="champ">
      <label for="role">Rôle</label>
      <select id="role" v-model="form.role">
        <option value="">Sélectionnez un rôle...</option>
        <option value="user">Utilisateur</option>
        <option value="moderateur">Modérateur</option>
        <option value="admin">Administrateur</option>
      </select>
    </div>

    <div class="champ">
      <label>
        <input type="checkbox" v-model="form.cgv" @change="validerCgv" />
        J'accepte les <a href="#" @click.prevent>conditions générales</a> *
      </label>
      <span class="erreur" v-if="erreurs.cgv">{{ erreurs.cgv }}</span>
    </div>

    <button type="submit" :disabled="!formulaireValide || enChargement">
      <span v-if="enChargement">⏳ Création en cours...</span>
      <span v-else>✅ Créer le compte</span>
    </button>

  </form>
</template>

<script setup lang="ts">
import { reactive, ref, computed } from 'vue'

const visible     = ref(false)
const enChargement = ref(false)

const form = reactive({
  nom:       '',
  email:     '',
  motDePasse: '',
  role:      '',
  cgv:       false,
})

const erreurs = reactive({
  nom:       '',
  email:     '',
  motDePasse: '',
  cgv:       '',
})

// ── Validation individuelle ───────────────────────────────────────────────────
function validerNom() {
  if (!form.nom)               { erreurs.nom = 'Le nom est obligatoire.'; return }
  if (form.nom.length < 2)     { erreurs.nom = 'Minimum 2 caractères.';  return }
  if (form.nom.length > 100)   { erreurs.nom = 'Maximum 100 caractères.'; return }
  erreurs.nom = ''
}

function validerEmail() {
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!form.email)             { erreurs.email = 'L\'email est obligatoire.'; return }
  if (!re.test(form.email))    { erreurs.email = 'Format d\'email invalide.'; return }
  erreurs.email = ''
}

function validerMotDePasse() {
  if (!form.motDePasse)                { erreurs.motDePasse = 'Obligatoire.'; return }
  if (form.motDePasse.length < 8)      { erreurs.motDePasse = 'Minimum 8 caractères.'; return }
  if (!/[A-Z]/.test(form.motDePasse))  { erreurs.motDePasse = 'Au moins une majuscule.'; return }
  if (!/[0-9]/.test(form.motDePasse))  { erreurs.motDePasse = 'Au moins un chiffre.'; return }
  erreurs.motDePasse = ''
}

function validerCgv() {
  erreurs.cgv = form.cgv ? '' : 'Vous devez accepter les CGV.'
}

// ── Force du mot de passe ────────────────────────────────────────────────────
const scoreForce = computed(() => {
  let score = 0
  const mdp = form.motDePasse
  if (mdp.length >= 8)  score++
  if (mdp.length >= 12) score++
  if (/[A-Z]/.test(mdp)) score++
  if (/[0-9]/.test(mdp)) score++
  if (/[^A-Za-z0-9]/.test(mdp)) score++
  return score
})

const niveauForce = computed(() => {
  if (scoreForce.value <= 1) return 'faible'
  if (scoreForce.value <= 3) return 'moyen'
  return 'fort'
})

const forcePct = computed(() => (scoreForce.value / 5) * 100)

// ── Validation globale ────────────────────────────────────────────────────────
const formulaireValide = computed(() =>
  !erreurs.nom && !erreurs.email && !erreurs.motDePasse && !erreurs.cgv &&
  form.nom && form.email && form.motDePasse && form.cgv
)

function toutValider(): boolean {
  validerNom(); validerEmail(); validerMotDePasse(); validerCgv()
  return formulaireValide.value
}

async function soumettre() {
  if (!toutValider()) return
  enChargement.value = true
  try {
    // await authService.inscrire(form)
    await new Promise(r => setTimeout(r, 1500))
    console.log('Inscription réussie :', form)
  } finally {
    enChargement.value = false
  }
}
</script>

<style scoped>
.champ              { margin-bottom: 1.2rem; }
.champ label        { display: block; font-weight: 600; margin-bottom: .3rem; }
.champ input,
.champ select       { width: 100%; padding: .5rem; border: 1px solid #ccc; border-radius: 6px; }
.champ--erreur input,
.champ--erreur select { border-color: #e53e3e; }
.erreur             { color: #e53e3e; font-size: .8rem; }
.input-group        { display: flex; }
.input-group input  { flex: 1; border-radius: 6px 0 0 6px; }
.input-group button { border-radius: 0 6px 6px 0; padding: .5rem; border: 1px solid #ccc; cursor: pointer; }
.force-mdp          { height: 4px; background: #e2e8f0; border-radius: 2px; margin-top: .3rem; }
.force-mdp__barre   { height: 100%; border-radius: 2px; transition: width .3s, background .3s; }
.force-mdp__barre--faible { background: #fc8181; }
.force-mdp__barre--moyen  { background: #f6ad55; }
.force-mdp__barre--fort   { background: #68d391; }
</style>

9.2. Validation avec VeeValidate + Zod

Pour les formulaires complexes, utilisez une bibliothèque de validation :

npm install vee-validate @vee-validate/zod zod
<template>
  <form @submit="onSubmit">

    <div class="champ" :class="{ 'champ--erreur': errors.email }">
      <label>Email</label>
      <input v-bind="emailField" type="email" placeholder="email@exemple.fr" />
      <span class="erreur">{{ errors.email }}</span>
    </div>

    <div class="champ" :class="{ 'champ--erreur': errors.motDePasse }">
      <label>Mot de passe</label>
      <input v-bind="motDePasseField" type="password" />
      <span class="erreur">{{ errors.motDePasse }}</span>
    </div>

    <button type="submit" :disabled="!meta.valid">Connexion</button>

  </form>
</template>

<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

//  Schéma de validation Zod — type-safe et puissant
const schema = toTypedSchema(z.object({
  email: z
    .string({ required_error: 'Email obligatoire.' })
    .email('Format d\'email invalide.'),
  motDePasse: z
    .string({ required_error: 'Mot de passe obligatoire.' })
    .min(8, 'Minimum 8 caractères.')
    .regex(/[A-Z]/, 'Au moins une majuscule.')
    .regex(/[0-9]/, 'Au moins un chiffre.'),
}))

const { handleSubmit, errors, meta } = useForm({ validationSchema: schema })

const { field: emailField }       = useField<string>('email')
const { field: motDePasseField }  = useField<string>('motDePasse')

const onSubmit = handleSubmit(async (values) => {
  console.log('Valeurs validées :', values)
  // await authStore.connecter(values.email, values.motDePasse)
})
</script>

10. Connexion aux backends — Spring Boot et Symfony

10.1. Configuration d’Axios

npm install axios
// src/services/api.ts
import axios from 'axios'
import type { AxiosInstance } from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'

const api: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json',
    'Accept':       'application/json',
  },
})

//  Intercepteur de requête — ajout automatique du JWT
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

//  Intercepteur de réponse — gestion centralisée des erreurs
api.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      const authStore = useAuthStore()
      authStore.deconnecter()
      router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
    }
    return Promise.reject(error)
  }
)

export default api
# .env.development
VITE_API_URL=http://localhost:8080/api

# .env.production
VITE_API_URL=https://api.votredomaine.com/api

10.2. Services typés

// src/types/index.ts
export interface Utilisateur {
  id:       number
  nom:      string
  prenom:   string
  email:    string
  roles:    string[]
  createdAt: string
}

export interface Projet {
  id:          number
  nom:         string
  description?: string
  statut:      'actif' | 'pause' | 'termine' | 'archive'
  createdAt:   string
  chef?:       Utilisateur
}

export interface CreerProjetDTO {
  nom:         string
  description?: string
  statut:      string
}

export interface PageResponse<T> {
  content:       T[]
  totalElements: number
  totalPages:    number
  number:        number   // Page courante (0-indexed Spring)
  size:          number
}
// src/services/projetService.ts
import api from './api'
import type { Projet, CreerProjetDTO, PageResponse } from '@/types'

export const projetService = {

  async trouverTous(): Promise<Projet[]> {
    const { data } = await api.get<Projet[]>('/projets')
    return data
  },

  async trouverAvecPagination(page = 0, size = 10, statut?: string): Promise<PageResponse<Projet>> {
    const { data } = await api.get<PageResponse<Projet>>('/projets', {
      params: { page, size, statut }
    })
    return data
  },

  async trouverParId(id: number): Promise<Projet> {
    const { data } = await api.get<Projet>(`/projets/${id}`)
    return data
  },

  async creer(dto: CreerProjetDTO): Promise<Projet> {
    const { data } = await api.post<Projet>('/projets', dto)
    return data
  },

  async mettreAJour(id: number, dto: Partial<CreerProjetDTO>): Promise<Projet> {
    const { data } = await api.put<Projet>(`/projets/${id}`, dto)
    return data
  },

  async changerStatut(id: number, statut: string): Promise<Projet> {
    const { data } = await api.patch<Projet>(`/projets/${id}/statut`, { statut })
    return data
  },

  async supprimer(id: number): Promise<void> {
    await api.delete(`/projets/${id}`)
  },

  async importerCSV(fichier: File): Promise<{ importes: number; erreurs: string[] }> {
    const formData = new FormData()
    formData.append('fichier', fichier)
    const { data } = await api.post('/projets/import', formData, {
      headers: { 'Content-Type': 'multipart/form-data' }
    })
    return data
  },
}

10.3. Configuration CORS Spring Boot

//  Configuration CORS — Spring Boot
@Configuration
public class CorsConfig {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                    .allowedOrigins(
                        "http://localhost:5173",  // Vite dev server
                        "http://localhost:4173",  // Vite preview
                        "https://votredomaine.com"
                    )
                    .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                    .allowedHeaders("*")
                    .allowCredentials(true)
                    .maxAge(3600);
            }
        };
    }
}

10.4. Configuration CORS Symfony

# config/packages/nelmio_cors.yaml
# composer require nelmio/cors-bundle

nelmio_cors:
  paths:
    '^/api/':
      allow_credentials: true
      allow_origin:
        - 'http://localhost:5173'
        - 'http://localhost:4173'
        - 'https://votredomaine.com'
      allow_headers: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With']
      allow_methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
      max_age: 3600

10.5. Composable useFetch — Abstraction des appels API

// src/composables/useFetch.ts
import { ref, type Ref } from 'vue'

interface UseFetchResult<T> {
  data:          Ref<T | null>
  chargement:    Ref<boolean>
  erreur:        Ref<string>
  charger:       () => Promise<void>
  reinitialiser: () => void
}

export function useFetch<T>(
  fetchFn: () => Promise<T>,
  options: { immediate?: boolean } = {}
): UseFetchResult<T> {

  const data       = ref<T | null>(null) as Ref<T | null>
  const chargement = ref(false)
  const erreur     = ref('')

  async function charger() {
    chargement.value = true
    erreur.value     = ''
    try {
      data.value = await fetchFn()
    } catch (e: any) {
      erreur.value = e.response?.data?.message ?? e.message ?? 'Erreur inconnue.'
    } finally {
      chargement.value = false
    }
  }

  function reinitialiser() {
    data.value       = null
    chargement.value = false
    erreur.value     = ''
  }

  if (options.immediate !== false) {
    charger()
  }

  return { data, chargement, erreur, charger, reinitialiser }
}
<!-- Utilisation ultra-simple dans un composant -->
<template>
  <div>
    <div v-if="chargement">Chargement...</div>
    <div v-else-if="erreur" style="color: red">{{ erreur }}</div>
    <div v-else>
      <ProjetCard v-for="p in data" :key="p.id" :projet="p" />
    </div>
    <button @click="charger">🔄 Recharger</button>
  </div>
</template>

<script setup lang="ts">
import { useFetch }       from '@/composables/useFetch'
import { projetService }  from '@/services/projetService'
import ProjetCard         from '@/components/ProjetCard.vue'

const { data, chargement, erreur, charger } = useFetch(
  () => projetService.trouverTous()
)
</script>

11. Design System avec Vue

11.1. Qu’est-ce qu’un Design System ?

Un Design System est un ensemble de règles, composants et ressources qui garantissent la cohérence visuelle et fonctionnelle d’une application. Dans Vue, il se traduit par :

Design System Vue
├── Tokens          → Variables CSS (couleurs, tailles, espacement, typographie)
├── Composants de base  → Bouton, Input, Carte, Badge, Modal, Toast...
├── Composants composés → Formulaire, Tableau, Navigation, DataGrid...
├── Layouts         → MainLayout, SidebarLayout, AuthLayout
└── Documentation   → Storybook ou pages de démonstration

11.2. Tokens CSS — Fondation du Design System

/* src/assets/tokens.css */

:root {
  /* ─── Palette de couleurs ────────────────────────────────── */
  --color-primary-50:   #eff6ff;
  --color-primary-100:  #dbeafe;
  --color-primary-500:  #3b82f6;
  --color-primary-600:  #2563eb;
  --color-primary-700:  #1d4ed8;

  --color-success-500:  #22c55e;
  --color-warning-500:  #f59e0b;
  --color-danger-500:   #ef4444;
  --color-neutral-50:   #f8fafc;
  --color-neutral-100:  #f1f5f9;
  --color-neutral-200:  #e2e8f0;
  --color-neutral-500:  #64748b;
  --color-neutral-700:  #334155;
  --color-neutral-900:  #0f172a;

  /* ─── Typographie ────────────────────────────────────────── */
  --font-sans:     'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono:     'Fira Code', 'Cascadia Code', monospace;

  --text-xs:   .75rem;     /* 12px */
  --text-sm:   .875rem;    /* 14px */
  --text-base: 1rem;       /* 16px */
  --text-lg:   1.125rem;   /* 18px */
  --text-xl:   1.25rem;    /* 20px */
  --text-2xl:  1.5rem;     /* 24px */
  --text-3xl:  1.875rem;   /* 30px */

  --font-normal:   400;
  --font-medium:   500;
  --font-semibold: 600;
  --font-bold:     700;

  /* ─── Espacement (multiple de 4px) ──────────────────────── */
  --space-1:  .25rem;   /* 4px  */
  --space-2:  .5rem;    /* 8px  */
  --space-3:  .75rem;   /* 12px */
  --space-4:  1rem;     /* 16px */
  --space-5:  1.25rem;  /* 20px */
  --space-6:  1.5rem;   /* 24px */
  --space-8:  2rem;     /* 32px */
  --space-10: 2.5rem;   /* 40px */
  --space-12: 3rem;     /* 48px */

  /* ─── Bordures et ombres ─────────────────────────────────── */
  --radius-sm:  4px;
  --radius-md:  8px;
  --radius-lg:  12px;
  --radius-xl:  16px;
  --radius-full: 9999px;

  --shadow-sm:  0 1px 2px rgba(0,0,0,.05);
  --shadow-md:  0 4px 6px rgba(0,0,0,.07);
  --shadow-lg:  0 10px 15px rgba(0,0,0,.10);
  --shadow-xl:  0 20px 25px rgba(0,0,0,.10);

  /* ─── Transitions ────────────────────────────────────────── */
  --transition-fast:   150ms ease;
  --transition-normal: 250ms ease;
  --transition-slow:   400ms ease;

  /* ─── Z-index ────────────────────────────────────────────── */
  --z-dropdown: 100;
  --z-sticky:   200;
  --z-overlay:  300;
  --z-modal:    400;
  --z-toast:    500;
}

/* ─── Mode sombre ────────────────────────────────────────────── */
[data-theme="sombre"] {
  --color-neutral-50:  #0f172a;
  --color-neutral-100: #1e293b;
  --color-neutral-200: #334155;
  --color-neutral-900: #f8fafc;
}

11.3. Composant Bouton du Design System

<!-- src/components/ui/DsBouton.vue -->
<template>
  <component
    :is="tag"
    :href="tag === 'a' ? href : undefined"
    :disabled="tag === 'button' ? (disabled || chargement) : undefined"
    :class="classes"
    v-bind="$attrs"
    @click="!disabled && !chargement && $emit('click', $event)"
  >
    <!-- Spinner de chargement -->
    <span v-if="chargement" class="btn__spinner" aria-hidden="true" />

    <!-- Icône gauche -->
    <span v-if="iconeGauche && !chargement" class="btn__icone btn__icone--gauche">
      <component :is="iconeGauche" />
    </span>

    <!-- Texte -->
    <span class="btn__texte">
      <slot />
    </span>

    <!-- Icône droite -->
    <span v-if="iconeDroite" class="btn__icone btn__icone--droite">
      <component :is="iconeDroite" />
    </span>
  </component>
</template>

<script setup lang="ts">
import { computed, type Component } from 'vue'

type Variante   = 'primaire' | 'secondaire' | 'outline' | 'ghost' | 'danger' | 'succes'
type Taille     = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
type TagElement = 'button' | 'a' | 'RouterLink'

interface Props {
  variante?:    Variante
  taille?:      Taille
  pleineLargeur?: boolean
  disabled?:    boolean
  chargement?:  boolean
  arrondi?:     boolean
  iconeGauche?: Component
  iconeDroite?: Component
  tag?:         TagElement
  href?:        string
}

const props = withDefaults(defineProps<Props>(), {
  variante:     'primaire',
  taille:       'md',
  pleineLargeur: false,
  disabled:     false,
  chargement:   false,
  arrondi:      false,
  tag:          'button',
})

defineEmits<{ click: [event: MouseEvent] }>()

const classes = computed(() => [
  'btn',
  `btn--${props.variante}`,
  `btn--${props.taille}`,
  {
    'btn--pleine-largeur': props.pleineLargeur,
    'btn--arrondi':        props.arrondi,
    'btn--disabled':       props.disabled || props.chargement,
    'btn--chargement':     props.chargement,
  },
])
</script>

<style scoped>
.btn {
  display:         inline-flex;
  align-items:     center;
  justify-content: center;
  gap:             var(--space-2);
  font-family:     var(--font-sans);
  font-weight:     var(--font-semibold);
  border-radius:   var(--radius-md);
  border:          2px solid transparent;
  cursor:          pointer;
  transition:      all var(--transition-fast);
  text-decoration: none;
  white-space:     nowrap;
}

/* Variantes */
.btn--primaire   { background: var(--color-primary-600); color: #fff; border-color: var(--color-primary-600); }
.btn--primaire:hover:not(.btn--disabled) { background: var(--color-primary-700); border-color: var(--color-primary-700); }
.btn--secondaire { background: var(--color-neutral-100); color: var(--color-neutral-700); border-color: var(--color-neutral-200); }
.btn--outline    { background: transparent; color: var(--color-primary-600); border-color: var(--color-primary-600); }
.btn--ghost      { background: transparent; color: var(--color-neutral-700); border-color: transparent; }
.btn--danger     { background: var(--color-danger-500); color: #fff; border-color: var(--color-danger-500); }
.btn--succes     { background: var(--color-success-500); color: #fff; border-color: var(--color-success-500); }

/* Tailles */
.btn--xs { padding: var(--space-1) var(--space-2);  font-size: var(--text-xs); }
.btn--sm { padding: var(--space-1) var(--space-3);  font-size: var(--text-sm); }
.btn--md { padding: var(--space-2) var(--space-4);  font-size: var(--text-base); }
.btn--lg { padding: var(--space-3) var(--space-6);  font-size: var(--text-lg); }
.btn--xl { padding: var(--space-4) var(--space-8);  font-size: var(--text-xl); }

.btn--pleine-largeur { width: 100%; }
.btn--arrondi        { border-radius: var(--radius-full); }
.btn--disabled       { opacity: .5; cursor: not-allowed; }

/* Spinner */
.btn__spinner {
  width: 1em; height: 1em;
  border: 2px solid transparent;
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

11.4. Composant Badge

<!-- src/components/ui/DsBadge.vue -->
<template>
  <span :class="classes">
    <span v-if="point" class="badge__point" />
    <slot />
  </span>
</template>

<script setup lang="ts">
import { computed } from 'vue'

type Variante = 'primaire' | 'succes' | 'avertissement' | 'danger' | 'neutre' | 'info'
type Taille   = 'sm' | 'md' | 'lg'

const props = withDefaults(defineProps<{
  variante?: Variante
  taille?:   Taille
  arrondi?:  boolean
  point?:    boolean
}>(), {
  variante: 'neutre',
  taille:   'md',
  arrondi:  false,
  point:    false,
})

const classes = computed(() => [
  'badge',
  `badge--${props.variante}`,
  `badge--${props.taille}`,
  { 'badge--arrondi': props.arrondi },
])
</script>

<style scoped>
.badge {
  display:     inline-flex;
  align-items: center;
  gap:         var(--space-1);
  font-family: var(--font-sans);
  font-weight: var(--font-semibold);
  border-radius: var(--radius-sm);
}
.badge--sm { padding: 1px var(--space-2); font-size: var(--text-xs); }
.badge--md { padding: var(--space-1) var(--space-2); font-size: var(--text-xs); }
.badge--lg { padding: var(--space-1) var(--space-3); font-size: var(--text-sm); }

.badge--primaire     { background: var(--color-primary-100);  color: var(--color-primary-700);  }
.badge--succes       { background: #dcfce7; color: #166534; }
.badge--avertissement { background: #fef3c7; color: #92400e; }
.badge--danger       { background: #fee2e2; color: #991b1b; }
.badge--neutre       { background: var(--color-neutral-100); color: var(--color-neutral-700); }
.badge--info         { background: #e0f2fe; color: #075985; }

.badge--arrondi      { border-radius: var(--radius-full); }
.badge__point        { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
</style>

11.5. Composant Modal générique

<!-- src/components/ui/DsModal.vue -->
<template>
  <Teleport to="body">
    <Transition name="modal">
      <div v-if="modelValue" class="modal-overlay" @click.self="surFermeture">
        <div
          class="modal"
          :class="`modal--${taille}`"
          role="dialog"
          aria-modal="true"
          :aria-labelledby="titreId"
        >
          <!-- En-tête -->
          <header class="modal__entete">
            <h2 :id="titreId" class="modal__titre">
              <slot name="titre">{{ titre }}</slot>
            </h2>
            <button
              v-if="fermeturePossible"
              class="modal__fermer"
              @click="surFermeture"
              aria-label="Fermer"
            ></button>
          </header>

          <!-- Corps -->
          <div class="modal__corps">
            <slot />
          </div>

          <!-- Pied (optionnel) -->
          <footer v-if="$slots.pied" class="modal__pied">
            <slot name="pied" />
          </footer>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
import { computed, watch, onUnmounted } from 'vue'

type TailleModal = 'sm' | 'md' | 'lg' | 'xl' | 'plein'

const props = withDefaults(defineProps<{
  modelValue:       boolean
  titre?:           string
  taille?:          TailleModal
  fermeturePossible?: boolean
  fermerEchap?:     boolean
}>(), {
  taille:           'md',
  fermeturePossible: true,
  fermerEchap:      true,
})

const emit = defineEmits<{ 'update:modelValue': [val: boolean] }>()

const titreId = computed(() => `modal-titre-${Math.random().toString(36).slice(2)}`)

function surFermeture() {
  if (props.fermeturePossible) {
    emit('update:modelValue', false)
  }
}

// Fermeture avec Échap
function surTouche(e: KeyboardEvent) {
  if (e.key === 'Escape' && props.fermerEchap) surFermeture()
}

watch(() => props.modelValue, (ouvert) => {
  if (ouvert) {
    document.addEventListener('keydown', surTouche)
    document.body.style.overflow = 'hidden'  // Bloquer le scroll du body
  } else {
    document.removeEventListener('keydown', surTouche)
    document.body.style.overflow = ''
  }
})

onUnmounted(() => {
  document.removeEventListener('keydown', surTouche)
  document.body.style.overflow = ''
})
</script>

<style scoped>
.modal-overlay {
  position:        fixed;
  inset:           0;
  background:      rgba(0,0,0,.5);
  display:         flex;
  align-items:     center;
  justify-content: center;
  z-index:         var(--z-modal);
  padding:         var(--space-4);
}
.modal {
  background:    #fff;
  border-radius: var(--radius-lg);
  box-shadow:    var(--shadow-xl);
  width:         100%;
  max-height:    90vh;
  display:       flex;
  flex-direction: column;
  overflow:      hidden;
}
.modal--sm { max-width: 400px; }
.modal--md { max-width: 560px; }
.modal--lg { max-width: 760px; }
.modal--xl { max-width: 960px; }
.modal--plein { max-width: 100%; height: 90vh; }

.modal__entete {
  display:         flex;
  align-items:     center;
  justify-content: space-between;
  padding:         var(--space-5) var(--space-6);
  border-bottom:   1px solid var(--color-neutral-200);
}
.modal__titre   { margin: 0; font-size: var(--text-xl); }
.modal__fermer  {
  background: none; border: none; cursor: pointer;
  font-size: var(--text-lg); color: var(--color-neutral-500);
  padding: var(--space-1); border-radius: var(--radius-sm);
  transition: background var(--transition-fast);
}
.modal__fermer:hover { background: var(--color-neutral-100); }
.modal__corps  { padding: var(--space-6); overflow-y: auto; flex: 1; }
.modal__pied   { padding: var(--space-4) var(--space-6); border-top: 1px solid var(--color-neutral-200); display: flex; gap: var(--space-3); justify-content: flex-end; }

.modal-enter-active, .modal-leave-active { transition: opacity var(--transition-normal); }
.modal-enter-from,   .modal-leave-to     { opacity: 0; }
.modal-enter-active .modal, .modal-leave-active .modal { transition: transform var(--transition-normal); }
.modal-enter-from .modal { transform: scale(.95) translateY(-10px); }
.modal-leave-to .modal   { transform: scale(.95) translateY(10px); }
</style>

11.6. Enregistrement global du Design System

// src/plugins/designSystem.ts
import type { App } from 'vue'
import DsBouton  from '@/components/ui/DsBouton.vue'
import DsBadge   from '@/components/ui/DsBadge.vue'
import DsModal   from '@/components/ui/DsModal.vue'
// ... autres composants

export default {
  install(app: App) {
    app.component('DsBouton', DsBouton)
    app.component('DsBadge',  DsBadge)
    app.component('DsModal',  DsModal)
    // Enregistrés globalement — utilisables dans tout le projet sans import
  }
}
// src/main.ts
import designSystem from '@/plugins/designSystem'
app.use(designSystem)

11.7. Bibliothèques UI populaires pour Vue

Si vous préférez utiliser un Design System existant plutôt que de tout construire :

Bibliothèque Style Points forts
PrimeVue Flexible Le plus complet (>90 composants), thèmes multiples
Vuetify 3 Material Design Très riche, génération automatique de thèmes
Element Plus Clean / Enterprise Idéal pour les back-offices, excellente doc
Naive UI Moderne Entièrement TypeScript, très performant
Headless UI Sans style À combiner avec Tailwind CSS, accessibilité maximale
shadcn-vue Composants copiables Inspiré de shadcn/ui React, Tailwind
# Installation PrimeVue (exemple)
npm install primevue @primevue/themes

# Installation Vuetify
npm install vuetify @mdi/font

12. Composables — Logique réutilisable

12.1. Qu’est-ce qu’un Composable ?

Un Composable est une fonction qui encapsule de la logique réactive réutilisable entre plusieurs composants. C’est l’équivalent des Custom Hooks en React.

Convention : les composables commencent par use et sont placés dans src/composables/.

12.2. Composable useLocalStorage

// src/composables/useLocalStorage.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useLocalStorage<T>(
  cle:           string,
  valeurDefaut:  T
): [Ref<T>, (valeur: T) => void] {

  // Lire la valeur initiale depuis localStorage
  const lireDepuisStorage = (): T => {
    try {
      const item = localStorage.getItem(cle)
      return item ? JSON.parse(item) : valeurDefaut
    } catch {
      return valeurDefaut
    }
  }

  const donnee = ref<T>(lireDepuisStorage()) as Ref<T>

  // Synchroniser automatiquement avec localStorage
  watch(donnee, (nouvelleValeur) => {
    try {
      localStorage.setItem(cle, JSON.stringify(nouvelleValeur))
    } catch {
      console.warn(`Impossible d'écrire dans localStorage : ${cle}`)
    }
  }, { deep: true })

  function mettre(valeur: T) {
    donnee.value = valeur
  }

  return [donnee, mettre]
}
<!-- Utilisation -->
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'

const [theme, setTheme]          = useLocalStorage('theme', 'clair')
const [favoris, setFavoris]      = useLocalStorage<number[]>('favoris', [])
const [preferences, setPrefs]    = useLocalStorage('prefs', { langue: 'fr', notifications: true })
</script>

12.3. Composable usePagination

// src/composables/usePagination.ts
import { ref, computed } from 'vue'

export function usePagination<T>(options: {
  pageSize?:    number
  pageInitiale?: number
} = {}) {

  const pageActuelle  = ref(options.pageInitiale ?? 1)
  const taillePage    = ref(options.pageSize ?? 10)
  const totalElements = ref(0)

  const totalPages = computed(() =>
    Math.ceil(totalElements.value / taillePage.value)
  )

  const pageDebut = computed(() =>
    (pageActuelle.value - 1) * taillePage.value + 1
  )

  const pageFin = computed(() =>
    Math.min(pageActuelle.value * taillePage.value, totalElements.value)
  )

  const estPremierePage = computed(() => pageActuelle.value === 1)
  const estDernierePage = computed(() => pageActuelle.value >= totalPages.value)

  const numeroPages = computed(() => {
    const pages: (number | '...')[] = []
    const total = totalPages.value
    const courante = pageActuelle.value

    if (total <= 7) {
      return Array.from({ length: total }, (_, i) => i + 1)
    }

    pages.push(1)
    if (courante > 3) pages.push('...')
    for (let i = Math.max(2, courante - 1); i <= Math.min(total - 1, courante + 1); i++) {
      pages.push(i)
    }
    if (courante < total - 2) pages.push('...')
    pages.push(total)

    return pages
  })

  function allerPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      pageActuelle.value = page
    }
  }

  function pageSuivante() { allerPage(pageActuelle.value + 1) }
  function pagePrecedente() { allerPage(pageActuelle.value - 1) }
  function premiereePage() { allerPage(1) }
  function dernierePage()  { allerPage(totalPages.value) }

  return {
    pageActuelle, taillePage, totalElements,
    totalPages, pageDebut, pageFin,
    estPremierePage, estDernierePage, numeroPages,
    allerPage, pageSuivante, pagePrecedente,
    premiereePage, dernierePage,
  }
}

12.4. Composable useDebounce

// src/composables/useDebounce.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useDebounce<T>(valeur: Ref<T>, delai = 300): Ref<T> {
  const valeurDebounced = ref<T>(valeur.value) as Ref<T>
  let timer: ReturnType<typeof setTimeout>

  watch(valeur, (nouvelleValeur) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      valeurDebounced.value = nouvelleValeur
    }, delai)
  })

  return valeurDebounced
}
<template>
  <input v-model="recherche" placeholder="Rechercher..." />
  <p>Recherche effective : {{ rechercheDebounced }}</p>
  <ul>
    <li v-for="r in resultats" :key="r.id">{{ r.nom }}</li>
  </ul>
</template>

<script setup lang="ts">
import { ref, watch }     from 'vue'
import { useDebounce }    from '@/composables/useDebounce'
import { projetService }  from '@/services/projetService'

const recherche         = ref('')
const rechercheDebounced = useDebounce(recherche, 400)
const resultats         = ref<any[]>([])

// L'appel API n'est déclenché qu'après 400ms d'inactivité de frappe
watch(rechercheDebounced, async (terme) => {
  if (terme.length >= 2) {
    // resultats.value = await projetService.rechercher(terme)
  }
})
</script>

12.5. Composable useTheme

// src/composables/useTheme.ts
import { ref, watch, onMounted } from 'vue'

type Theme = 'clair' | 'sombre' | 'systeme'

export function useTheme() {
  const theme = ref<Theme>('systeme')

  function appliquerTheme(valeur: Theme) {
    const prefereNoir = window.matchMedia('(prefers-color-scheme: dark)').matches
    const estSombre   = valeur === 'sombre' || (valeur === 'systeme' && prefereNoir)

    document.documentElement.setAttribute('data-theme', estSombre ? 'sombre' : 'clair')
    localStorage.setItem('theme', valeur)
  }

  onMounted(() => {
    const sauvegarde = localStorage.getItem('theme') as Theme | null
    theme.value = sauvegarde ?? 'systeme'
    appliquerTheme(theme.value)
  })

  watch(theme, appliquerTheme)

  function basculer() {
    theme.value = theme.value === 'clair' ? 'sombre' : 'clair'
  }

  return { theme, basculer }
}

13. Tests et qualité

13.1. Tests unitaires avec Vitest

Vitest est intégré nativement avec Vite et utilise la même configuration. Il est 10x plus rapide que Jest pour les projets Vue.

// src/stores/__tests__/auth.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia }           from 'pinia'
import { useAuthStore }                          from '../auth'

// Mock du service d'authentification
vi.mock('@/services/authService', () => ({
  authService: {
    connecter: vi.fn(),
  }
}))

import { authService } from '@/services/authService'

describe('Store Auth', () => {

  beforeEach(() => {
    setActivePinia(createPinia())
    localStorage.clear()
  })

  it('est déconnecté par défaut', () => {
    const store = useAuthStore()
    expect(store.estConnecte).toBe(false)
    expect(store.utilisateur).toBeNull()
  })

  it('se connecte avec succès', async () => {
    const mockUser = { id: 1, nom: 'Martini', prenom: 'Alicia', email: 'alicia@ex.com', roles: ['ROLE_USER'] }

    vi.mocked(authService.connecter).mockResolvedValue({
      token: 'jwt-token-123',
      user:  mockUser,
    } as any)

    const store = useAuthStore()
    await store.connecter('alicia@ex.com', 'password')

    expect(store.estConnecte).toBe(true)
    expect(store.utilisateur).toEqual(mockUser)
    expect(store.nomComplet).toBe('Alicia Martini')
    expect(localStorage.getItem('token')).toBe('jwt-token-123')
  })

  it('gère les erreurs de connexion', async () => {
    vi.mocked(authService.connecter).mockRejectedValue(
      { response: { data: { message: 'Identifiants incorrects.' } } }
    )

    const store = useAuthStore()

    await expect(store.connecter('bad@ex.com', 'wrong')).rejects.toBeDefined()
    expect(store.erreur).toBe('Identifiants incorrects.')
    expect(store.estConnecte).toBe(false)
  })

  it('se déconnecte proprement', async () => {
    const store = useAuthStore()
    localStorage.setItem('token', 'ancien-token')

    store.deconnecter()

    expect(store.estConnecte).toBe(false)
    expect(localStorage.getItem('token')).toBeNull()
  })
})

13.2. Tests de composants avec Vue Test Utils

// src/components/__tests__/CarteUtilisateur.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CarteUtilisateur from '../CarteUtilisateur.vue'

describe('CarteUtilisateur', () => {

  const propsDefaut = {
    nom:   'Alicia Martini',
    email: 'alicia@exemple.com',
    role:  'admin' as const,
  }

  it('affiche le nom et l\'email', () => {
    const wrapper = mount(CarteUtilisateur, { props: propsDefaut })

    expect(wrapper.text()).toContain('Alice Martin')
    expect(wrapper.text()).toContain('alice@example.com')
  })

  it('affiche le badge correct selon le rôle', () => {
    const wrapper = mount(CarteUtilisateur, { props: propsDefaut })
    const badge   = wrapper.find('.badge')

    expect(badge.text()).toBe('admin')
    expect(badge.classes()).toContain('badge--admin')
  })

  it('affiche les initiales si pas d\'avatar', () => {
    const wrapper = mount(CarteUtilisateur, { props: propsDefaut })
    expect(wrapper.find('.initiales').text()).toBe('AM')
  })

  it('émet l\'événement "modifier" au clic', async () => {
    const wrapper = mount(CarteUtilisateur, { props: propsDefaut })
    await wrapper.find('button:first-child').trigger('click')

    expect(wrapper.emitted('modifier')).toBeTruthy()
    expect(wrapper.emitted('modifier')?.[0]).toEqual([{
      nom:   'Alice Martin',
      email: 'alice@example.com',
      role:  'admin',
    }])
  })

  it('émet l\'événement "supprimer" avec l\'email', async () => {
    const wrapper = mount(CarteUtilisateur, { props: propsDefaut })
    await wrapper.find('.btn-danger').trigger('click')

    expect(wrapper.emitted('supprimer')?.[0]).toEqual(['alice@example.com'])
  })
})

13.3. Configuration ESLint pour Vue

// .eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:vue/vue3-recommended",
    "@vue/typescript/recommended"
  ],
  "rules": {
    "vue/component-name-in-template-casing": ["error", "PascalCase"],
    "vue/no-unused-vars":                    "error",
    "vue/require-default-prop":              "warn",
    "vue/multi-word-component-names":        "error",
    "@typescript-eslint/no-explicit-any":    "warn",
    "no-console":                            ["warn", { "allow": ["warn", "error"] }]
  }
}

14. Projet fil rouge — Application de gestion complète

14.1. Présentation — GestionPro

L’application GestionPro intègre tous les concepts du cours dans un projet d’entreprise réaliste :

Fonctionnalité Concept Vue Chapitre
Authentification JWT Pinia store + Axios interceptors 8, 10
Dashboard avec stats computed, reactive 4
CRUD Projets Services + Store + Composants 5, 8, 10
Formulaire projet v-model, validation, VeeValidate 9
Filtres et recherche computed + useDebounce 4, 12
Pagination usePagination composable 12
Design System maison DsBouton, DsBadge, DsModal… 11
Thème sombre useTheme + variables CSS 11, 12
Navigation protégée Vue Router guards 7
Tests unitaires Vitest + Vue Test Utils 13

14.2. Structure complète

gestion-pro/
├── src/
│   ├── assets/
│   │   └── tokens.css          ← Variables CSS du Design System
│   ├── components/
│   │   ├── ui/                 ← Composants du Design System
│   │   │   ├── DsBouton.vue
│   │   │   ├── DsBadge.vue
│   │   │   ├── DsModal.vue
│   │   │   ├── DsInput.vue
│   │   │   ├── DsAlert.vue
│   │   │   └── DsPagination.vue
│   │   ├── projet/             ← Composants métier projets
│   │   │   ├── ProjetCard.vue
│   │   │   ├── ProjetForm.vue
│   │   │   └── ProjetFiltres.vue
│   │   └── layout/
│   │       ├── AppSidebar.vue
│   │       ├── AppHeader.vue
│   │       └── AppBreadcrumb.vue
│   ├── composables/
│   │   ├── useDebounce.ts
│   │   ├── useFetch.ts
│   │   ├── useLocalStorage.ts
│   │   ├── usePagination.ts
│   │   └── useTheme.ts
│   ├── layouts/
│   │   ├── MainLayout.vue      ← Sidebar + Header
│   │   └── AuthLayout.vue      ← Page de connexion centrée
│   ├── plugins/
│   │   └── designSystem.ts     ← Enregistrement global des composants DS
│   ├── router/
│   │   └── index.ts            ← Routes + guards auth
│   ├── services/
│   │   ├── api.ts              ← Instance Axios
│   │   ├── authService.ts
│   │   └── projetService.ts
│   ├── stores/
│   │   ├── auth.ts
│   │   ├── projets.ts
│   │   └── ui.ts
│   ├── types/
│   │   └── index.ts            ← Interfaces TypeScript globales
│   └── views/
│       ├── auth/
│       │   └── LoginView.vue
│       ├── dashboard/
│       │   └── DashboardView.vue
│       └── projets/
│           ├── ProjetsListeView.vue
│           ├── ProjetDetailView.vue
│           └── ProjetNouveauView.vue

14.3. Vue complète du Dashboard

<!-- src/views/dashboard/DashboardView.vue -->
<template>
  <div class="dashboard">

    <header class="dashboard__header">
      <div>
        <h1>Bonjour, {{ authStore.nomComplet }} 👋</h1>
        <p>Voici l'état de vos projets au {{ dateAujourdhui }}</p>
      </div>
      <DsBouton @click="actualiser" variante="secondaire" :chargement="projetsStore.chargement">
        🔄 Actualiser
      </DsBouton>
    </header>

    <!-- Cartes de statistiques -->
    <section class="dashboard__stats">
      <div
        v-for="stat in statistiques"
        :key="stat.label"
        class="stat-card"
        :style="{ '--couleur': stat.couleur }"
      >
        <div class="stat-card__icone">{{ stat.icone }}</div>
        <div class="stat-card__valeur">{{ stat.valeur }}</div>
        <div class="stat-card__label">{{ stat.label }}</div>
        <div class="stat-card__tendance" v-if="stat.tendance">
          {{ stat.tendance > 0 ? '↗️' : '↘️' }} {{ Math.abs(stat.tendance) }}%
        </div>
      </div>
    </section>

    <!-- Projets récents -->
    <section class="dashboard__projets">
      <h2>Projets récents</h2>

      <div v-if="projetsStore.chargement" class="etat-chargement">
        <span class="spinner" /> Chargement...
      </div>

      <div v-else-if="projetsStore.erreur" class="etat-erreur">
        ⚠️ {{ projetsStore.erreur }}
        <DsBouton variante="ghost" @click="projetsStore.charger">Réessayer</DsBouton>
      </div>

      <div v-else-if="projetsRecents.length === 0" class="etat-vide">
        <p>Aucun projet pour l'instant.</p>
        <DsBouton variante="primaire" @click="$router.push('/projets/nouveau')">
          ➕ Créer un projet
        </DsBouton>
      </div>

      <div v-else class="projets-grille">
        <ProjetCard
          v-for="projet in projetsRecents"
          :key="projet.id"
          :projet="projet"
          @modifier="surModification"
          @supprimer="surSuppression"
        />
      </div>

      <RouterLink to="/projets" class="voir-tous">
        Voir tous les projets →
      </RouterLink>
    </section>

  </div>
</template>

<script setup lang="ts">
import { computed, onMounted }  from 'vue'
import { useRouter }            from 'vue-router'
import { useAuthStore }         from '@/stores/auth'
import { useProjetsStore }      from '@/stores/projets'
import DsBouton                 from '@/components/ui/DsBouton.vue'
import ProjetCard               from '@/components/projet/ProjetCard.vue'
import type { Projet }          from '@/types'

const router       = useRouter()
const authStore    = useAuthStore()
const projetsStore = useProjetsStore()

onMounted(() => projetsStore.charger())

const dateAujourdhui = new Date().toLocaleDateString('fr-FR', {
  weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
})

const projetsRecents = computed(() =>
  [...projetsStore.projets]
    .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
    .slice(0, 6)
)

const statistiques = computed(() => {
  const stats = projetsStore.nombreParStatut
  return [
    { label: 'Total',    valeur: stats.total,    icone: '📁', couleur: '#3b82f6', tendance: null },
    { label: 'Actifs',   valeur: stats.actifs,   icone: '🟢', couleur: '#22c55e', tendance: 8   },
    { label: 'En pause', valeur: stats.pauses,   icone: '⏸️', couleur: '#f59e0b', tendance: -2  },
    { label: 'Terminés', valeur: stats.termines, icone: '', couleur: '#8b5cf6', tendance: 15  },
  ]
})

function actualiser() {
  projetsStore.charger()
}

function surModification(projet: Projet) {
  router.push(`/projets/${projet.id}/modifier`)
}

async function surSuppression(id: number) {
  if (confirm('Supprimer ce projet ?')) {
    await projetsStore.supprimer(id)
  }
}
</script>

<style scoped>
.dashboard               { max-width: 1200px; margin: 0 auto; padding: var(--space-6); }
.dashboard__header       { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-8); }
.dashboard__header h1    { font-size: var(--text-2xl); margin: 0 0 var(--space-1); }
.dashboard__header p     { color: var(--color-neutral-500); margin: 0; }

.dashboard__stats        { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
.stat-card               { background: white; border-radius: var(--radius-lg); padding: var(--space-5); box-shadow: var(--shadow-md); border-top: 4px solid var(--couleur); }
.stat-card__icone        { font-size: var(--text-2xl); margin-bottom: var(--space-2); }
.stat-card__valeur       { font-size: var(--text-3xl); font-weight: var(--font-bold); margin-bottom: var(--space-1); }
.stat-card__label        { color: var(--color-neutral-500); font-size: var(--text-sm); }
.stat-card__tendance     { font-size: var(--text-xs); color: var(--color-neutral-500); margin-top: var(--space-2); }

.dashboard__projets h2   { margin-bottom: var(--space-4); }
.projets-grille          { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-4); }
.voir-tous               { display: inline-block; margin-top: var(--space-4); color: var(--color-primary-600); text-decoration: none; font-weight: var(--font-semibold); }
.etat-chargement, .etat-erreur, .etat-vide { text-align: center; padding: var(--space-12); color: var(--color-neutral-500); }
.spinner                 { display: inline-block; width: 1rem; height: 1rem; border: 2px solid #ccc; border-top-color: var(--color-primary-600); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin          { to { transform: rotate(360deg); } }
</style>

15. Exercices d’application

15.1. Exercices fondamentaux

Exercice 1 — Composition API

Créez un composant Minuteur.vue qui :

  1. Affiche un compte à rebours (minutes:secondes) à partir d’une durée en props.
  2. Propose des boutons Démarrer, Pauser, Réinitialiser.
  3. Affiche un message différent selon l’état (en attente, en cours, terminé).
  4. Utilise onUnmounted pour nettoyer l’intervalle.
  5. Émet un événement termine quand le compte arrive à zéro.

Exercice 2 — Composants et communication

Créez un système de notation (étoiles) :

  1. Composant NoteEtoiles.vue : affiche 1 à 5 étoiles cliquables, reçoit la note actuelle en prop, émet la nouvelle note.
  2. Composant CommentaireForm.vue : contient NoteEtoiles + un textarea + un bouton soumettre.
  3. Composant ListeCommentaires.vue : affiche tous les commentaires soumis avec leur note.
  4. Gérez le tout dans une AvisPage.vue qui orchestre les trois composants.

Exercice 3 — Vue Router

Créez une application de catalogue avec :

  1. Route /catalogue : liste de produits (v-for, filtres par catégorie).
  2. Route /catalogue/:id : détail d’un produit (avec les paramètres de route en props).
  3. Route /panier : liste des produits ajoutés (avec store Pinia).
  4. Guard de navigation : si le panier est vide, rediriger vers /catalogue avec un message.

15.2. Exercices intermédiaires

Exercice 4 — Design System

Construisez les composants UI suivants, tous basés sur les tokens CSS :

  1. DsAlert.vue : 4 variantes (info, succès, avertissement, danger), avec icône, titre optionnel, bouton de fermeture.
  2. DsTable.vue : tableau générique acceptant des colonnes et des données en props, avec tri au clic sur les en-têtes, lignes cliquables.
  3. DsPagination.vue : utilise le composable usePagination, affiche les numéros de pages avec ..., boutons précédent/suivant.

Exercice 5 — Composables

Créez les composables suivants et testez-les :

  1. useWindowSize : expose largeur et hauteur de la fenêtre, réactifs au redimensionnement (avec cleanup dans onUnmounted).
  2. useClipboard : expose une fonction copier(texte) et un booléen vientDeCopier (revient à false après 2 secondes).
  3. useIntersectionObserver : retourne un booléen estVisible selon si un élément est dans le viewport (utile pour les animations au scroll).

Exercice 6 — Connexion backend

Connectez l’application à votre backend Spring Boot ou Symfony :

  1. Créez les types TypeScript qui correspondent à vos entités.
  2. Configurez Axios avec l’intercepteur JWT.
  3. Créez un service avec les 5 opérations CRUD.
  4. Créez un store Pinia avec les getters filtrés et les actions.
  5. Créez une page liste avec chargement, erreur, vide, infinite scroll.

15.3. Exercice final — Application complète

Exercice 7 — Tableau de bord de gestion

Construisez une application complète :

  1. Authentification : page de connexion avec validation, store auth, JWT, protection des routes.
  2. Layout : sidebar navigation, header avec info utilisateur, bouton thème sombre/clair.
  3. Dashboard : statistiques animées, graphique simple (avec une lib comme Chart.js ou ECharts).
  4. Module CRUD : choisissez votre entité (produits, clients, commandes…) et implémentez liste + détail + création + modification + suppression, avec filtres, tri et pagination.
  5. Design System : au moins 4 composants UI maison utilisés dans l’application (Bouton, Badge, Modal, Alert).
  6. Composables : au moins 2 composables maison (debounce, pagination ou autres).
  7. Tests : au moins 5 tests unitaires (store + 2 composants).

Annexe — Commandes, outils et ressources

Commandes essentielles

# ─── Création de projet ─────────────────────────────────────────
npm create vue@latest              # Créer avec l'assistant officiel
npm create vite@latest -- --template vue-ts  # Vite direct (sans assistant)

# ─── Développement ──────────────────────────────────────────────
npm run dev                        # Serveur de dev (HMR)
npm run dev -- --host              # Accessible depuis le réseau local (ex: mobile)
npm run dev -- --port 3000         # Changer le port

# ─── Build ──────────────────────────────────────────────────────
npm run build                      # Build de production
npm run build -- --mode staging    # Build avec .env.staging
npm run preview                    # Prévisualiser le build

# ─── Qualité ────────────────────────────────────────────────────
npm run lint                       # ESLint
npm run lint -- --fix              # Corriger automatiquement
npm run format                     # Prettier

# ─── Tests ──────────────────────────────────────────────────────
npm run test:unit                  # Tests unitaires (Vitest)
npm run test:unit -- --watch       # Mode watch
npm run test:unit -- --coverage    # Rapport de couverture

# ─── Dépendances populaires ─────────────────────────────────────
npm install axios                  # HTTP client
npm install pinia-plugin-persistedstate  # Persistance Pinia
npm install vee-validate @vee-validate/zod zod  # Validation formulaires
npm install @vueuse/core           # 200+ composables utiles
npm install date-fns               # Manipulation de dates
npm install chart.js vue-chartjs   # Graphiques

Récapitulatif des concepts clés

Concept API Vue 3 Équivalent React Chapitre
État local primitif ref(valeur) useState 4
État local objet reactive({}) useState({}) 4
Valeur dérivée computed(() => ...) useMemo 4
Réagir aux changements watch(source, cb) useEffect([dep]) 4
Réagir à tout watchEffect(() => ...) useEffect 4
Au montage onMounted(() => ...) useEffect(() => {}, []) 4
Au démontage onUnmounted(() => ...) Retour de useEffect 4
Props (TypeScript) defineProps<T>() FC<Props> 5
Événements defineEmits<T>() Callbacks en props 5
Two-way binding v-model value + onChange 5
Communication indirecte provide/inject useContext 5
Composition <slot> children 5
Condition v-if / v-show {cond && ...} 6
Liste v-for .map() 6
État global Pinia defineStore Redux / Zustand 8
Logique réutilisable Composable use* Custom Hook use* 12

Ressources officielles

Ressource URL
Documentation Vue 3 https://vuejs.org/guide
Vue Router 4 https://router.vuejs.org
Pinia https://pinia.vuejs.org
Vite https://vitejs.dev
VueUse (composables) https://vueuse.org
Vue DevTools https://devtools.vuejs.org
Vitest https://vitest.dev
VeeValidate https://vee-validate.logaretm.com
Playground en ligne https://play.vuejs.org

Checklist avant mise en production

Code
☐ Pas de console.log() oubliés
☐ Tous les TODO résolus ou documentés
☐ TypeScript : zéro erreur (npm run build)
☐ ESLint : zéro erreur (npm run lint)
☐ Tests passants (npm run test:unit)

Performance
☐ Lazy loading des routes (import() dynamique)
☐ Images optimisées (WebP, lazy loading)
☐ Pas de computed gourmands dans les v-for
☐ Bundle analysé (npm run build puis npx vite-bundle-visualizer)

Sécurité
☐ Pas de clé API dans le code (utiliser .env)
☐ .env.* ajouté dans .gitignore
☐ Token JWT dans localStorage (ou mieux : httpOnly cookie)
☐ Validation côté serveur (ne pas compter uniquement sur le front)
☐ CSP configuré côté serveur

Accessibilité
☐ Images avec attribut alt
☐ Boutons avec labels (aria-label si icône seule)
☐ Contraste couleurs suffisant (WCAG AA : 4.5:1)
☐ Navigation clavier fonctionnelle (focus visible)
☐ Formulaires avec labels liés aux inputs