Aller au contenu

Composants et API

Dans la page précédente, nous avons rendu un portfolio HTML interactif avec Vue, chargé depuis un CDN. Nous avons aussi découvert la structure d’un composant .vue.

Nous allons maintenant créer une application Vue avec Vite, répartir son interface en composants et leur faire échanger des informations. Nous utiliserons d’abord des données locales pour nous concentrer sur la conception, puis nous les remplacerons par des données chargées avec fetch.

Notre application présentera un répertoire de cafés étudiants, avec des cartes, des favoris et des filtres rapides. Nous prendrons comme référence le domaine de Café sans-fil, un projet qui répertorie les cafés étudiants de l’Université de Montréal.

Plutôt que de réunir toute l’interface dans un seul fichier, nous répartirons les responsabilités à travers plusieurs composants (chacun dans un fichier) :

App.vue État commun : cafés, favoris et filtres
│ Chargement des données et composition de la page
│
└── CarteCafe.vue Affichage d'un café et de son bouton favori
Réutilisé pour chaque élément de la liste
  1. Un composant doit avoir une seule responsabilité. CarteCafe affiche un café ; il ne décide ni de la provenance des données, ni des filtres à appliquer.
  2. L’état est placé là où il doit être partagé. Les favoris concernent plusieurs cartes et le filtre « Mes favoris » : App.vue les conserve.
  3. Les données descendent, les événements remontent. Le parent transmet des valeurs par des props ; l’enfant signale les actions par des événements.
Un composant ne devrait pas avoir à connaître toute l’application pour remplir sa responsabilité. On définit les informations qu’il reçoit et les événements qu’il peut émettre.

La page précédente a présenté Vite et les composants monofichiers.
Nous reprenons ici les commandes essentielles :

Fenêtre de terminal
npm create vue@latest
cd cafes-udem
npm install
npm run dev

Lors de la création, nommer le projet cafes-udem, conserver JavaScript (sans TypeScript) et ne sélectionner aucune fonctionnalité facultative : nous n’utiliserons pas encore Vue Router ni d’outil de test.

Nous travaillerons principalement avec cette structure :

cafes-udem/
├── index.html
├── public/
│ └── cafes.json à ajouter lors de l'étape sur les API
└── src/
├── main.js
├── style.css styles communs
├── cafes-exemples.js données locales pour débuter
├── App.vue
└── components/
└── CarteCafe.vue à créer
Dans src/main.js, monter l’application et importer la feuille de style commune :
Section intitulée « Dans src/main.js, monter l’application et importer la feuille de style commune : »
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

Créer src/style.css :

:root {
--encre: #1f2430;
--gris: #667085;
--bord: #e2e5ec;
--accent: #7a2e3b;
--fond: #faf8f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
color: var(--encre);
background: var(--fond);
line-height: 1.5;
}

Pour débuter, nous utiliserons quelques cafés fictifs. Ils servent uniquement aux exercices : leurs noms, emplacements, moyens de paiement et statuts ne représentent pas des informations réelles ou à jour.

Créer src/cafes-exemples.js :

export const cafesExemples = [
{
slug: 'demo-agora',
name: 'Café Démo Agora',
description: 'Boissons et collations.',
banner_url: null,
is_open: true,
status_message: '',
location: { pavillon: 'Pavillon A', local: 'A-101' },
payment_details: [{ method: 'CASH' }, { method: 'DEBIT' }],
},
{
slug: 'demo-jardin',
name: 'Café Démo Jardin',
description: 'Sandwichs et repas légers.',
banner_url: null,
is_open: false,
status_message: 'Fermé pour le moment',
location: { pavillon: 'Pavillon B', local: 'B-205' },
payment_details: [{ method: 'CASH' }],
},
{
slug: 'demo-atelier',
name: 'Café Démo Atelier',
description: 'Café et pâtisseries.',
banner_url: null,
is_open: true,
status_message: '',
location: { pavillon: 'Pavillon A', local: 'A-215' },
payment_details: [{ method: 'CREDIT' }],
},
]

Chaque café est représenté par un objet, et les objets sont regroupés dans un tableau. La propriété slug sert d’identifiant stable pour chaque café.

La structure de ces objets sera réutilisée lors du chargement des données.

Nous voulons afficher chaque café dans une carte. Au lieu de répéter le HTML dans App.vue, nous allons créer CarteCafe.vue et lui transmettre le café à afficher.

Café Beagel : ouvertLa Planck : fermé

Dans cet exemple réduit, le composant enfant déclare les informations qu’il accepte :

<script setup>
defineProps({
nom: { type: String, required: true },
ouvert: { type: Boolean, default: false },
})
</script>
<template>
<span>{{ nom }} : {{ ouvert ? 'ouvert' : 'fermé' }}</span>
</template>

Le parent utilise ce composant et lui transmet des données :

<script setup>
import { ref } from 'vue'
import BadgeStatut from './BadgeStatut.vue'
const nom = ref('Café Démo Agora')
const ouvert = ref(true)
</script>
<template>
<input v-model="nom" aria-label="Nom du café" />
<label><input v-model="ouvert" type="checkbox" /> Ouvert</label>
<BadgeStatut :nom="nom" :ouvert="ouvert" />
<BadgeStatut nom="Café Démo Jardin" />
</template>

Créer le fichier src/components/CarteCafe.vue :

<script setup>
import { computed } from 'vue'
const props = defineProps({
cafe: { type: Object, required: true },
})
const NOMS_PAIEMENT = {
CASH: 'Comptant',
DEBIT: 'Débit',
CREDIT: 'Crédit',
}
const paiements = computed(() =>
(props.cafe.payment_details ?? [])
.map((p) => NOMS_PAIEMENT[p.method] ?? p.method)
.join(', ') || 'Non précisé',
)
</script>
<template>
<article class="carte">
<img
v-if="cafe.banner_url"
:src="cafe.banner_url"
:alt="`Photo du café ${cafe.name}`"
loading="lazy"
/>
<div class="contenu">
<header><h2>{{ cafe.name }}</h2></header>
<p class="statut" :class="cafe.is_open ? 'ouvert' : 'ferme'">
{{ cafe.is_open ? 'Ouvert' : 'Fermé' }}
<span v-if="cafe.status_message"> · {{ cafe.status_message }}</span>
</p>
<p>{{ cafe.description || 'Aucune description disponible.' }}</p>
<p class="infos">
{{ cafe.location?.pavillon || 'Pavillon non précisé' }}
<span v-if="cafe.location?.local">, {{ cafe.location.local }}</span>
<br />Paiement : {{ paiements }}
</p>
</div>
</article>
</template>
<style scoped>
.carte { background: #fff; border: 1px solid var(--bord); border-radius: 12px; overflow: hidden; }
img { width: 100%; height: 140px; object-fit: cover; }
.contenu { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
header { display: flex; justify-content: space-between; align-items: start; gap: 8px; }
h2, p { margin: 0; }
h2 { font-size: 1.1rem; }
.statut { font-size: .85rem; font-weight: 600; }
.ouvert { color: #067647; }
.ferme { color: #b42318; }
.infos { font-size: .85rem; color: var(--gris); }
</style>
  • Dans le script, nous utilisons props.cafe ;
  • Dans le gabarit (<template>), cafe est directement accessible.
  • La propriété calculée paiements convertit les codes de paiement en libellés lisibles.
  • L’expression ?? [] prévoit le cas où payment_details est absent ou vaut null.

    Lorsque les données proviennent d’une source externe, il faut éviter de supposer que tous les champs facultatifs seront toujours présents.

Remplacer le contenu de src/App.vue par une première version complète et fonctionnelle :

<script setup>
import { ref } from 'vue'
import CarteCafe from './components/CarteCafe.vue'
import { cafesExemples } from './cafes-exemples.js'
const cafes = ref(cafesExemples)
</script>
<template>
<main>
<h1>Cafés étudiants — démonstration</h1>
<div class="grille">
<CarteCafe v-for="cafe in cafes" :key="cafe.slug" :cafe="cafe" />
</div>
</main>
</template>
<style scoped>
main { max-width: 1100px; margin: 0 auto; padding: 24px 20px 48px; }
h1 { margin: 0 0 16px; }
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
</style>

Vérification : trois cartes doivent apparaître.

Modifier le nom d’un café dans src/cafes-exemples.js : sa carte doit afficher la nouvelle valeur après actualisation par Vite.

VérificationDans cette première version, quel composant doit connaître la liste complète des cafés ? Et pourquoi CarteCafe ne devrait-il pas chercher lui-même les cafés ?

App.vue connaît la collection et crée une carte par café. CarteCafe reçoit un seul objet et l’affiche. Il demeure réutilisable sans dépendre du fichier de données, d’une API particulière ou de l’existence d’autres cartes.

Ajoutons un bouton permettant d’indiquer les cafés favoris. Le bouton se trouve dans CarteCafe, mais la liste des favoris appartient à App.vue, car elle concerne plusieurs cartes et servira aussi à filtrer la liste.

  • Café Beagel
  • La Planck
  • Café-In

Favoris : aucun

Un composant enfant peut déclarer et émettre un événement :

<script setup>
defineProps({ actif: { type: Boolean, default: false } })
const emit = defineEmits(['basculer'])
</script>
<template>
<button type="button" aria-label="Favori" :aria-pressed="actif" @click="emit('basculer')">
{{ actif ? '★' : '☆' }}
</button>
</template>

Le parent reçoit cet événement et décide comment modifier sa liste :

<script setup>
import { ref } from 'vue'
import BoutonFavori from './BoutonFavori.vue'
const noms = ['Café Démo Agora', 'Café Démo Jardin']
const favoris = ref([])
function basculer(nom) {
if (favoris.value.includes(nom)) {
favoris.value = favoris.value.filter((f) => f !== nom)
} else {
favoris.value.push(nom)
}
}
</script>
<template>
<p v-for="nom in noms" :key="nom">
{{ nom }}
<BoutonFavori :actif="favoris.includes(nom)" @basculer="basculer(nom)" />
</p>
</template>
Les données descendent par les props et les interactions remontent par les événements émis.

Dans CarteCafe.vue, remplacer la déclaration des props par :

const props = defineProps({
cafe: { type: Object, required: true },
favori: { type: Boolean, default: false },
})
const emit = defineEmits(['basculer-favori'])

Dans son <template>, remplacer la ligne <header><h2>…</h2></header> par :

<header>
<h2>{{ cafe.name }}</h2>
<button type="button" class="favori"
:aria-pressed="favori"
:aria-label="`Favori : ${cafe.name}`"
@click="emit('basculer-favori', cafe.slug)"
>
{{ favori ? '★' : '☆' }}
</button>
</header>

Ajouter dans son <style scoped> :

.favori { border: 0; background: none; font-size: 1.3rem; color: #b54708; cursor: pointer; }

Le libellé accessible du bouton reste stable, tandis que aria-pressed indique si le café est favori. Chaque bouton identifie son café dans le libellé.

Dans App.vue, après const cafes = ..., ajouter :

const favoris = ref([]) // contient des slugs, pas des noms affichés
function basculerFavori(slug) {
if (favoris.value.includes(slug)) {
favoris.value = favoris.value.filter((s) => s !== slug)
} else {
favoris.value.push(slug)
}
}

Remplacer l’utilisation de <CarteCafe /> dans la grille par :

<CarteCafe v-for="cafe in cafes" :key="cafe.slug"
:cafe="cafe"
:favori="favoris.includes(cafe.slug)"
@basculer-favori="basculerFavori"
/>

Sous la grille, afficher le nombre de favoris :

<p>Favoris sélectionnés : {{ favoris.length }}</p>

La valeur émise est le slug : avec @basculer-favori="basculerFavori", Vue la transmet comme argument de la fonction du parent.

Vérification : cliquer sur différentes étoiles. Une carte se met à jour sans changer les autres ; le nombre de favoris évolue dans App.vue.

Nous avons maintenant une interface qui fonctionne sans connexion (réseau). Pour l’alimenter avec des données distantes, nous n’avons pas besoin de modifier CarteCafe : c’est App.vue qui remplacera sa collection de cafés.

L’API publique de Café sans-fil est présentée à l’adresse https://api.cafesansfil.ca/docs. Une requête envisagée pour la liste est :

GET https://api.cafesansfil.ca/v1/cafes?size=100

Dans cette activité, nous travaillons avec le format de réponse suivant :

{
"items": [
{
"slug": "demo-agora",
"name": "Café Démo Agora",
"is_open": true,
"location": { "pavillon": "Pavillon A", "local": "A-101" },
"payment_details": [{ "method": "DEBIT" }]
}
],
"total": 1,
"page": 1,
"size": 1,
"pages": 1
}

Chargement…

La démonstration simule une réponse réussie ou un échec réseau :

<script setup>
import { ref, onMounted } from 'vue'
const cafes = ref([])
const chargement = ref(true)
const erreur = ref('')
const simulerErreur = ref(false)
function fausseRequete() {
return new Promise((resoudre, rejeter) => {
setTimeout(() => {
if (simulerErreur.value) rejeter(new Error('réseau indisponible'))
else resoudre(['Café Démo Agora', 'Café Démo Jardin'])
}, 1000)
})
}
async function charger() {
chargement.value = true
erreur.value = ''
try {
cafes.value = await fausseRequete()
} catch (e) {
erreur.value = `Échec du chargement : ${e.message}`
} finally {
chargement.value = false
}
}
onMounted(charger)
</script>
<template>
<p v-if="chargement">Chargement…</p>
<p v-else-if="erreur" role="alert">{{ erreur }}</p>
<ul v-else>
<li v-for="cafe in cafes" :key="cafe">{{ cafe }}</li>
</ul>
<label><input type="checkbox" v-model="simulerErreur" /> Simuler une erreur</label>
<button :disabled="chargement" @click="charger">Recharger</button>
</template>

Dans <script setup> de App.vue :

  • ajouter onMounted à l’import venant de vue ;
  • supprimer l’import cafesExemples ;
  • remplacer const cafes = ref(cafesExemples) par le code suivant.
const SOURCE = '/cafes.json' // test local stable ; l'API distante pourra être essayée ensuite
const cafes = ref([])
const chargement = ref(true)
const erreur = ref('')
async function chargerCafes() {
chargement.value = true
erreur.value = ''
try {
const reponse = await fetch(SOURCE)
if (!reponse.ok) throw new Error(`statut HTTP ${reponse.status}`)
const page = await reponse.json()
if (!Array.isArray(page.items)) throw new Error('format de réponse inattendu')
cafes.value = page.items
} catch (e) {
erreur.value = `Impossible de charger les cafés (${e.message}).`
} finally {
chargement.value = false
}
}
onMounted(chargerCafes)

Dans le gabarit, remplacer la grille actuelle par ces éléments. La grille et ses cartes sont désormais affichées uniquement après un chargement réussi :

<p v-if="chargement" class="message" role="status">Chargement des cafés…</p>
<p v-else-if="erreur" class="message erreur" role="alert">{{ erreur }}</p>
<template v-else>
<div class="grille">
<CarteCafe
v-for="cafe in cafes"
:key="cafe.slug"
:cafe="cafe"
:favori="favoris.includes(cafe.slug)"
@basculer-favori="basculerFavori"
/>
</div>
</template>

Ajouter les styles suivants au <style scoped> de App.vue :

.message { margin: 0 0 12px; color: var(--gris); }
.erreur { color: #b42318; }

Vérification : les cafés apparaissent avec /cafes.json. Dans l’onglet Réseau des outils de développement, observer la requête, son statut et sa réponse. Renommer temporairement l’URL en /absent.json pour tester le message d’erreur, puis rétablir la bonne adresse.

Le composant CarteCafe n’a pas changé : l’affichage dépend de la structure des données reçues, pas de leur provenance. Si l’API réelle utilise une autre structure, il faudra adapter les données à celle attendue par les cartes.