Stockage de données côté navigateur

Présentation

Le stockage côté navigateur permet d'enregistrer et de récupérer des données directement dans le navigateur. Ce mécanisme sert par exemple à conserver un thème, une langue, un panier, un état d'interface, ou un brouillon. Ce chapitre présente trois solutions courantes : les cookies, le localStorage et le sessionStorage.

  • Cookies : Petites données clé-valeur associées à un site. Ils sont en principe renvoyés automatiquement au serveur à chaque requête HTTP. Limités à environ 4 Ko par cookie et une vingtaine de cookies par domaine.
  • localStorage : Stockage clé-valeur persistant côté navigateur. Les données restent disponibles après fermeture et réouverture du navigateur. Capacité d'environ 5 à 10 Mo selon le navigateur.
  • sessionStorage : Stockage clé-valeur lié à un onglet. Les données disparaissent lorsque l'onglet est fermé. Même capacité que le localStorage.

En règle générale : Préférer le Web Storage (localStorage ou sessionStorage) pour les données qui restent côté navigateur (thème, brouillon, préférences d'interface). Utiliser les cookies uniquement lorsque le serveur a besoin de lire la donnée, par exemple un identifiant de session.

Cas particulier : Dans un contexte SSR (Server Side Rendering), lorsqu'une préférence influence directement le rendu généré par le serveur un cookie peut être plus adapté, car il est transmis automatiquement au serveur avec la requête. Par exemple, pour un thème clair ou sombre, le serveur peut produire directement le bon rendu, ce qui évite un flash visuel au chargement.

Comparatif

Ce comparatif résume les différences principales entre cookies, localStorage et sessionStorage.

  • Capacité
    • Cookies : ~4 Ko par cookie
    • localStorage : ~5 à 10 Mo
    • sessionStorage : ~5 à 10 Mo
  • Persistance
    • Cookies : configurable (session ou durée définie)
    • localStorage : permanente (jusqu'à suppression explicite)
    • sessionStorage : durée de l'onglet
  • Envoi au serveur
    • Cookies : oui, automatiquement à chaque requête HTTP
    • localStorage : non
    • sessionStorage : non
  • Accès JavaScript
    • Cookies : oui (sauf si HttpOnly)
    • localStorage : oui
    • sessionStorage : oui
  • Portée
    • Cookies : domaine + chemin
    • localStorage : origine (protocole + domaine + port)
    • sessionStorage : origine + onglet
  • Cas d'usage typique
    • Cookies : session d'authentification, préférence lue par le serveur
    • localStorage : thème, langue, panier, brouillon
    • sessionStorage : état temporaire d'un formulaire multi-étapes

Les cookies

Un cookie est une information clé-valeur gérée par le navigateur. Il est associé à un domaine, un chemin, et des options. Le navigateur peut l'envoyer automatiquement au serveur dans l'en-tête HTTP Cookie.

Usages fréquents :

  • Conserver un identifiant de session (pas un mot de passe).
  • Mémoriser une préférence simple (langue, thème).
  • Mesurer l'audience ou analyser le comportement de navigation (ex.: nombre de visites, pages consultées), sous réserve du respect des règles légales en vigueur.

Côté JavaScript, la création d'un cookie se fait via document.cookie. Une écriture dans document.cookie ajoute ou modifie un cookie. Elle ne remplace pas tous les cookies.

Pour définir la durée de vie, deux approches existent : max-age et expires. Dans du code moderne, max-age est souvent plus simple, car il exprime une durée en secondes.

Un cookie défini sans Max-Age ni Expires est un cookie de session : il est automatiquement supprimé à la fermeture du navigateur.


                    // Cookie de session : supprimé à la fermeture du navigateur
                    document.cookie = 'prenom=Claudy; Path=/';

                    // Cookie persistant 30 jours (30 * 24 * 60 * 60 = 2 592 000 secondes)
                    document.cookie = 'nom=Focan; Max-Age=2592000; Path=/';
                

Important : si la valeur peut contenir des caractères spéciaux (=, ;, espaces, accents, etc.), il faut l'encoder avec encodeURIComponent() lors de l'écriture. Cela évite que ces caractères soient interprétés comme des séparateurs du format cookie.


                    const ville = 'Braine-l\'Alleud; centre';

                    document.cookie = `ville=${encodeURIComponent(ville)}; Max-Age=2592000; Path=/`;
                

Quelques options utiles :

  • Path=/ : le cookie est envoyé pour toutes les pages du site. L'attribut Path fonctionne par préfixe, le cookie est envoyé si le chemin de l'URL commence par la valeur indiquée.
  • Max-Age=... : durée de vie en secondes.
  • Expires=... : date d'expiration. Si une date est nécessaire, utiliser une date en format UTC avec toUTCString().

                    // Créer un objet Date représentant le moment actuel.
                    const dateActuelle = new Date();

                    // Ajouter 30 jours à cette date.
                    // getDate() renvoie le jour du mois.
                    // setDate() permet de modifier ce jour.
                    // Le moteur JavaScript ajuste automatiquement le mois si nécessaire.
                    dateActuelle.setDate(dateActuelle.getDate() + 30);

                    // Convertir la date au format attendu par l'attribut Expires.
                    // L'attribut Expires exige une date au format HTTP-date
                    // (ex. : "Wed, 21 Oct 2026 07:28:00 GMT").
                    // La méthode toUTCString() génère automatiquement ce format.
                    const dateExpiration = dateActuelle.toUTCString();

                    // Écrire le cookie.
                    // Expires définit la date de fin de validité.
                    document.cookie = `nom=${encodeURIComponent('Focan')}; Expires=${dateExpiration}; Path=/`;
                

La lecture se fait via document.cookie. Cette propriété renvoie une seule chaîne de caractères contenant tous les cookies accessibles depuis JavaScript pour la page courante, séparés par ; (point-virgule puis espace).

Par exemple, si deux cookies existent, document.cookie renvoie :


                    "prenom=Claudy; nom=Focan"
                

Il n'existe pas de méthode native pour accéder directement à un cookie par son nom. Il faut donc découper cette chaîne pour retrouver la valeur souhaitée. Voici une fonction utilitaire commentée pas à pas :


                    const nomCookie = 'prenom';
                    let valeurCookie = null;

                    const cookieString = document.cookie;

                    // Si aucun cookie accessible, inutile de continuer.
                    if (cookieString !== '')
                    {
                        // Découper la chaîne en un tableau de paires "clé=valeur".
                        // Ex.: "prenom=Claudy; nom=Focan" -> ["prenom=Claudy", "nom=Focan"]
                        const parts = cookieString.split('; ');

                        // Préparer le préfixe recherché
                        // Ex.: "prenom="
                        const prefix = `${nomCookie}=`;

                        for (const part of parts)
                        {
                            // Vérifier si cette paire commence par le nom recherché suivi de "=".
                            if (part.startsWith(prefix))
                            {
                                // Découper la paire "nom=valeur" avec "=" comme séparateur.
                                // Exemple simple : "prenom=Claudy" -> ["prenom", "Claudy"]
                                // Exemple piégeux : "token=a=b=c" -> ["token", "a", "b", "c"]
                                const morceaux = part.split('=');

                                // Récupérer uniquement la partie "valeur".
                                // slice(1) prend tous les éléments à partir de l'index 1 inclus.
                                // Exemple simple : ["prenom", "Claudy"] -> ["Claudy"]
                                // Exemple piégeux : ["token", "a", "b", "c"] -> ["a", "b", "c"]
                                const morceauxValeur = morceaux.slice(1);

                                // Reconstituer la valeur complète en remettant les "=" éventuels.
                                // Exemple simple : ["Claudy"] -> ["Claudy"]
                                // Exemple piégeux : ["a", "b", "c"] -> join('=') -> "a=b=c"
                                const valeur = morceauxValeur.join('=');

                                // Décoder les caractères spéciaux encodés à l'écriture.
                                valeurCookie = decodeURIComponent(valeur);
                                break;
                            }
                        }
                    }

                    console.log(valeurCookie); // "Claudy" ou null si le cookie n'existe pas
                

Pour supprimer un cookie, il faut le réécrire avec une durée nulle ou une date passée. Utiliser Max-Age=0 est l'écriture la plus directe. Le Path doit correspondre à celui du cookie à supprimer.


                    document.cookie = 'nom=; Max-Age=0; Path=/';
                

Les cookies disposent d'options qui renforcent la sécurité. Certaines options peuvent être définies côté JavaScript, d'autres uniquement côté serveur.

  • Secure : le cookie n'est envoyé que sur HTTPS. Cette option est recommandée sur un site en production.
    
                                document.cookie = `nom=${encodeURIComponent('Focan')}; Max-Age=2592000; Path=/; Secure`;
                            
  • SameSite : contrôle l'envoi du cookie lors de navigations ou requêtes cross-site. Valeurs possibles : Strict, Lax, None.
    • Strict : le cookie n'est envoyé que si la requête provient exactement du même site. Si l'utilisateur clique sur un lien depuis un autre site vers le vôtre, le cookie n'est pas envoyé.

      Cela peut poser problème pour un identifiant de session : si un utilisateur connecté arrive sur le site via un lien externe (par exemple depuis un e-mail ou un moteur de recherche), son cookie de session ne sera pas transmis lors de la première requête. Le serveur ne reconnaîtra donc pas la session, et l'utilisateur pourra apparaître comme non connecté.
    • Lax : le cookie est envoyé lors d'une navigation de premier niveau déclenchée par l'utilisateur (par exemple un clic sur un lien vers le site), mais il n'est pas envoyé pour les requêtes automatiques comme le chargement d'une image, d'une iframe ou un appel fetch() provenant d'un autre site. C'est la valeur par défaut dans les navigateurs modernes lorsqu'aucune valeur SameSite n'est spécifiée.

      Cette valeur est souvent adaptée aux cookies de session, car elle protège contre de nombreuses attaques CSRF tout en permettant à un utilisateur connecté d'arriver sur le site via un lien externe.
    • None : le cookie est envoyé dans tous les contextes, y compris pour les requêtes cross-site automatiques (images, iframes, appels API, etc.). En pratique, cette valeur impose aussi Secure.
    
                                document.cookie = `nom=${encodeURIComponent('Focan')}; Max-Age=2592000; Path=/; SameSite=Strict; Secure`;
                            
  • HttpOnly : empêche l'accès au cookie depuis JavaScript. Cette option réduit l'impact d'une attaque XSS visant à voler des cookies. HttpOnly ne peut pas être défini avec document.cookie. Il doit être défini côté serveur via l'en-tête HTTP Set-Cookie.

En JavaScript, un cookie marqué HttpOnly n'apparaît pas dans document.cookie. C'est normal : le navigateur le protège.

Web Storage

Le Web Storage regroupe deux stockages : localStorage et sessionStorage. Ils stockent des paires clé-valeur sous forme de chaînes de caractères. Ils ne sont pas envoyés automatiquement au serveur.

  • localStorage : conserver des données de manière persistante.
  • sessionStorage : conserver des données uniquement pour un onglet donné.

Le Web Storage est bien adapté aux préférences d'interface, états d'application, caches légers et brouillons. Éviter d'y stocker des données sensibles. Ces données restent accessibles à JavaScript sur le même site, donc une faille XSS peut les exposer.

Stocker des données

Utiliser setItem(cle, valeur). La clé et la valeur sont enregistrées sous forme de chaînes.


                    // localStorage : conserver des données de manière persistante. 
                    localStorage.setItem('theme', 'sombre');

                    // sessionStorage : conserver des données uniquement pour un onglet donné.
                    sessionStorage.setItem('etapeInscription', '2');
                

Pour stocker un objet ou un tableau, le convertir en JSON avec JSON.stringify().


                    const panier = [
                        { id: 1, nom: 'Livre', quantite: 2 },
                        { id: 2, nom: 'Stylo', quantite: 1 }
                    ];

                    localStorage.setItem('panier', JSON.stringify(panier));
                

Récupérer des données

Utiliser getItem(cle). Si la clé n'existe pas, la méthode renvoie null.


                    const theme = localStorage.getItem('theme');

                    if (theme !== null)
                    {
                        console.log(`Thème enregistré : ${theme}`);
                    }
                

Pour relire un objet ou un tableau, utiliser JSON.parse(). Attention : si la valeur stockée n'est pas du JSON valide (modification manuelle, donnée corrompue, ancien format), JSON.parse() lève une exception. Il est recommandé d'encadrer l'appel avec try...catch.


                    const panierJson = localStorage.getItem('panier');

                    if (panierJson !== null)
                    {
                        try
                        {
                            // Tenter de convertir le format JSON en donnée exploitable.
                            const panier = JSON.parse(panierJson);
                            console.log(panier);
                        }
                        catch (erreur)
                        {
                            console.error('Données du panier invalides :', erreur.message);

                            // Le format stocké est corrompu ou ancien.
                            // On supprime la clé pour repartir d'un état sain.
                            localStorage.removeItem('panier');
                        }
                    }
                

Supprimer des données

Utiliser removeItem(cle) pour une clé, ou clear() pour tout effacer.


                    // Supprimer une clé et sa valeur.
                    localStorage.removeItem('theme');
                    sessionStorage.removeItem('etapeInscription');

                    // Tout effacer.
                    localStorage.clear();
                    sessionStorage.clear();
                

Réagir aux changements

Lorsque localStorage est modifié dans un autre onglet du même site, l'événement storage est déclenché dans les autres onglets. Cela permet par exemple de synchroniser un thème ou une déconnexion entre plusieurs onglets.

Attention : l'onglet qui effectue la modification ne reçoit pas l'événement. Seuls les autres onglets ouverts sur la même origine le reçoivent.


                    window.addEventListener('storage', (event) =>
                    {
                        // event.key contient le nom de la clé qui a changé dans le storage.
                        // Ici, on ne réagit que si c'est la clé "theme".
                        if (event.key === 'theme')
                        {
                            // event.newValue contient la nouvelle valeur enregistrée pour cette clé.
                            // Si la clé a été supprimée, event.newValue vaut null.
                            console.log('Nouveau thème détecté :', event.newValue);
                        }
                    });
                

Exercices

Exo 01: Boîte à outils pour les cookies

Cet exercice porte sur la création d'un module utilitaire pour manipuler les cookies du navigateur. En JavaScript, l'interface native pour gérer les cookies est document.cookie. Contrairement à localStorage qui propose des méthodes claires comme setItem et getItem, document.cookie fonctionne avec une simple chaîne de caractères qu'il faut construire soi-même pour écrire un cookie et découper soi-même pour en lire un.

L'objectif est de construire trois fonctions utilitaires — écrire, lire, supprimer — qui encapsulent cette mécanique brute dans un module réutilisable. Une page HTML de test permettra de vérifier chaque fonction en direct. Ce module sera réutilisé dans l'exercice suivant.

Attendu

Une page affiche trois zones d'interaction : un formulaire pour écrire un cookie (nom, valeur, durée en jours), un champ pour lire un cookie par son nom, et un champ pour supprimer un cookie par son nom. Sous ces contrôles, une zone affiche en permanence le contenu brut de document.cookie afin de visualiser l'état réel des cookies après chaque action.

Structure

Créer un dossier nommé exo-01-stockage-boite-a-outils-cookies, puis organiser les fichiers et dossiers en respectant l'arborescence suivante :


                    📁 exo-01-stockage-boite-a-outils-cookies/
                    ├── 📄 index.html
                    ├── 📁 css/
                    │   └── 📄 style.css
                    └── 📁 js/
                        ├── 📄 app.js
                        └── 📁 modules/
                            ├── 📄 utilitairesCookies.js
                            └── 📄 testeurCookies.js
                

Fichiers fournis

Copier les fichiers suivants à l'identique.

index.html


                    <!DOCTYPE html>
                    <html lang="fr">
                    <head>
                        <meta charset="UTF-8">
                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                        <link rel="stylesheet" href="./css/style.css">
                        <title>JS: Stockage des données — Exo 01</title>
                    </head>
                    <body>
                        <main>
                            <h1>Boîte à outils cookies</h1>

                            <section class="panneau">
                                <h2>Écrire un cookie</h2>
                                <label>
                                    Nom
                                    <input type="text" id="ecrire-nom" placeholder="theme">
                                </label>
                                <label>
                                    Valeur
                                    <input type="text" id="ecrire-valeur" placeholder="sombre">
                                </label>
                                <label>
                                    Durée (jours)
                                    <input type="number" id="ecrire-jours" value="7" min="1">
                                </label>
                                <button id="btn-ecrire" class="btn">Écrire</button>
                            </section>

                            <section class="panneau">
                                <h2>Lire un cookie</h2>
                                <label>
                                    Nom
                                    <input type="text" id="lire-nom" placeholder="theme">
                                </label>
                                <button id="btn-lire" class="btn">Lire</button>
                                <p id="lire-resultat" class="resultat"></p>
                            </section>

                            <section class="panneau">
                                <h2>Supprimer un cookie</h2>
                                <label>
                                    Nom
                                    <input type="text" id="supprimer-nom" placeholder="theme">
                                </label>
                                <button id="btn-supprimer" class="btn">Supprimer</button>
                            </section>

                            <section class="panneau panneau-etat">
                                <h2>État brut de document.cookie</h2>
                                <pre id="etat-cookies" class="etat-brut">(vide)</pre>
                            </section>
                        </main>

                        <script src="./js/app.js" type="module"></script>
                    </body>
                    </html>
                

css/style.css


                    /* VARIABLES */
                    :root
                    {
                        --fond-principal: #0e1319;
                        --fond-secondaire: #151c24;
                        --texte-principal: #e7e1ca;
                        --texte-secondaire: #b9b39f;
                        --accent-visuel: orange;

                        --espace-s: 0.75rem;
                        --espace-m: 1.5rem;

                        --police-corps: Arial, sans-serif;

                        --rayon-s: 0.5rem;
                        --bordure: 1px solid rgba(231, 225, 202, 0.15);
                    }

                    /* RESET */
                    html, body, main, section, h1, h2, p, pre, label, input, button
                    {
                        margin: 0;
                        padding: 0;
                        box-sizing: border-box;
                    }

                    /* BASE */
                    html
                    {
                        font-family: var(--police-corps);
                        color: var(--texte-principal);
                        background-color: var(--fond-principal);
                    }

                    body
                    {
                        min-height: 100vh;
                    }

                    main
                    {
                        margin: 0 auto;
                        padding: var(--espace-m);
                        width: min(700px, 100%);
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-m);
                    }

                    h1
                    {
                        text-align: center;
                    }

                    /* PANNEAUX */
                    .panneau
                    {
                        border: var(--bordure);
                        border-radius: var(--rayon-s);
                        padding: var(--espace-m);
                        background: var(--fond-secondaire);
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-s);
                    }

                    .panneau h2
                    {
                        font-size: 1.1rem;
                        color: var(--texte-secondaire);
                    }

                    label
                    {
                        display: flex;
                        flex-direction: column;
                        gap: 0.3rem;
                        font-size: 0.95rem;
                    }

                    input
                    {
                        border: var(--bordure);
                        border-radius: var(--rayon-s);
                        padding: 0.6rem 0.8rem;
                        outline: none;
                        color: var(--texte-principal);
                        background: transparent;
                        font-size: 1rem;
                    }

                    input:focus
                    {
                        border-color: var(--accent-visuel);
                    }

                    /* BOUTONS */
                    .btn
                    {
                        border: none;
                        border-radius: var(--rayon-s);
                        padding: 0.65rem 1.2rem;
                        font-weight: bold;
                        cursor: pointer;
                        font-size: 1rem;
                        color: var(--fond-principal);
                        background-color: var(--accent-visuel);
                        align-self: flex-start;
                        transition: opacity 0.2s;
                    }

                    .btn:hover
                    {
                        opacity: 0.9;
                    }

                    .btn:active
                    {
                        opacity: 0.8;
                    }

                    /* RÉSULTAT */
                    .resultat
                    {
                        color: var(--accent-visuel);
                        font-weight: bold;
                        min-height: 1.4em;
                    }

                    /* ÉTAT BRUT */
                    .panneau-etat
                    {
                        border-color: var(--accent-visuel);
                    }

                    .etat-brut
                    {
                        color: var(--texte-secondaire);
                        white-space: pre-wrap;
                        word-break: break-all;
                        font-family: monospace;
                        font-size: 0.95rem;
                        line-height: 1.5;
                    }

                    /* RESPONSIVE */
                    @media (max-width: 520px)
                    {
                        main
                        {
                            padding: 1rem;
                        }

                        .panneau
                        {
                            padding: 1rem;
                        }
                    }
                

Instructions

Le travail se fait dans trois fichiers : js/modules/utilitairesCookies.js (les fonctions utilitaires), js/modules/testeurCookies.js (le branchement sur la page de test) et js/app.js (le point d'entrée). Chaque fonction doit être testée progressivement avec des console.log() avant de passer à la suivante.

Avant de commencer, un point important sur le fonctionnement de document.cookie. Cette propriété se comporte différemment selon qu'on la lit ou qu'on y écrit.

En écriture, on lui assigne une chaîne de caractères qui décrit un seul cookie avec ses options. Par exemple, la chaîne "theme=sombre; max-age=604800; path=/; SameSite=Lax" crée un cookie nommé theme avec la valeur sombre. L'écriture n'écrase pas les autres cookies existants : elle ajoute ou met à jour uniquement celui dont le nom correspond.

En lecture, document.cookie retourne une seule chaîne contenant tous les cookies de la page, séparés par "; " (point-virgule suivi d'un espace). Par exemple : "theme=sombre; langue=fr; consentement=accepte". Les options (max-age, path, etc.) n'apparaissent pas dans cette chaîne de lecture. Pour retrouver la valeur d'un cookie précis, il faut donc découper cette chaîne et chercher le bon nom.

Étape 01 : Écrire un cookie

L'objectif est de créer la première fonction utilitaire qui permet d'enregistrer un cookie avec un nom, une valeur et une durée de vie.

  1. Créer le fichier js/modules/utilitairesCookies.js.
  2. Dans ce fichier, créer une fonction nommée ecrireCookie. Cette fonction reçoit trois paramètres :
    • nom : le nom du cookie (une chaîne de caractères, par exemple "theme")
    • valeur : la valeur à stocker (une chaîne de caractères, par exemple "sombre")
    • jours : la durée de vie du cookie en jours (un nombre, par exemple 7)
  3. Dans le corps de ecrireCookie, commencer par calculer la durée de vie en secondes. Créer une constante nommée maxAge. L'option max-age attend un nombre de secondes. Or le paramètre jours est exprimé en jours, ce qui est plus naturel pour l'utilisateur de la fonction. Pour convertir des jours en secondes, multiplier le nombre de jours par 24 (heures dans un jour), puis par 60 (minutes dans une heure), puis par 60 (secondes dans une minute). Stocker le résultat dans maxAge.
  4. Construire la chaîne complète du cookie. Créer une constante nommée chaineCookie. Cette chaîne doit assembler plusieurs segments séparés par "; " :
    • nom=valeur : le nom et la valeur du cookie, reliés par le signe =. C'est la partie principale du cookie, celle qu'on retrouvera en lecture.
    • max-age= suivi de la valeur de maxAge : cette option indique au navigateur combien de secondes le cookie doit rester valide. Passé ce délai, le navigateur le supprime automatiquement.
    • path=/ : cette option rend le cookie accessible depuis toutes les pages du site. Sans cette option, le cookie ne serait lisible que depuis le dossier de la page qui l'a créé.
    • SameSite=Lax : cette option de sécurité empêche le cookie d'être envoyé lors de requêtes provenant d'un autre site. La valeur Lax est le réglage recommandé par défaut : elle protège contre les attaques courantes tout en permettant le fonctionnement normal de la navigation (cliquer sur un lien externe vers le site).
    Pour assembler ces segments, utiliser un gabarit de chaîne (template literal) ou une concaténation classique. Chaque segment est séparé du précédent par "; ".
  5. Assigner la chaîne chaineCookie à document.cookie. Cette affectation ne remplace pas les cookies existants : le navigateur ajoute ou met à jour uniquement le cookie dont le nom correspond.
  6. Exporter la fonction ecrireCookie en utilisant un export nommé, afin qu'elle puisse être importée depuis d'autres fichiers.
  7. Tester provisoirement depuis js/app.js.
    • Créer le fichier js/app.js.
    • Importer ecrireCookie depuis ./modules/utilitairesCookies.js.
    • Appeler ecrireCookie en lui passant le nom "testExo", la valeur "bonjour", et la durée 1 (un jour).
    • Juste après cet appel, afficher document.cookie dans la console.
    • Recharger la page. La console doit afficher une chaîne contenant testExo=bonjour. Si la chaîne est vide ou ne contient pas ce cookie, vérifier la construction de chaineCookie.
    • Appeler une seconde fois ecrireCookie avec un autre nom (par exemple "testExo2" et la valeur "salut"), puis afficher à nouveau document.cookie. Les deux cookies doivent être présents, séparés par "; ". Cela confirme que l'écriture n'écrase pas les cookies existants.
    • Supprimer le code de test dans app.js après vérification. Ne pas supprimer l'import, il servira plus tard.

Étape 02 : Lire un cookie

L'objectif est de créer une fonction capable de retrouver la valeur d'un cookie précis à partir de son nom. La difficulté vient du fait que document.cookie retourne tous les cookies dans une seule chaîne de caractères. Il faut donc découper cette chaîne pour isoler le bon cookie.

  1. Dans js/modules/utilitairesCookies.js, à la suite de la fonction ecrireCookie, créer une fonction nommée lireCookie. Cette fonction reçoit un seul paramètre :
    • nom : le nom du cookie recherché (une chaîne de caractères)
    La fonction doit retourner la valeur du cookie s'il existe, ou null s'il n'existe pas.
  2. Dans le corps de lireCookie, commencer par récupérer le contenu complet de document.cookie et le stocker dans une constante nommée chaineComplete. Cette chaîne ressemble par exemple à "theme=sombre; langue=fr".
  3. Vérifier si chaineComplete est vide (c'est-à-dire si sa longueur vaut 0). Si c'est le cas, retourner immédiatement null avec return car il n'y a aucun cookie à chercher.
  4. Découper chaineComplete en un tableau de paires. Utiliser la méthode split avec le séparateur "; " (point-virgule suivi d'un espace). Stocker le résultat dans une constante nommée paires.
    • La méthode split prend une chaîne en paramètre et découpe la chaîne sur laquelle elle est appelée à chaque endroit où le séparateur apparaît. Elle retourne un tableau contenant les morceaux.
    • Par exemple, découper "theme=sombre; langue=fr" avec le séparateur "; " produit le tableau ["theme=sombre", "langue=fr"].
  5. Parcourir le tableau paires avec une structure itérative. À chaque itération, la paire courante est une chaîne du type "theme=sombre". Découper cette paire avec split en utilisant le séparateur "=". Le premier élément du tableau obtenu est le nom du cookie, le second est sa valeur. Stocker ces deux éléments dans deux constantes nommées nomCookie et valeurCookie.
  6. À chaque itération, comparer nomCookie avec le paramètre nom. Si les deux sont identiques, le cookie recherché a été trouvé : retourner immédiatement valeurCookie avec return. Le return à l'intérieur d'une boucle interrompt la boucle et sort de la fonction en même temps, ce qui est le comportement souhaité ici : dès qu'on a trouvé le bon cookie, il est inutile de continuer à chercher.
  7. Après la boucle (c'est-à-dire si aucune paire ne correspondait), retourner null. Cela indique que le cookie demandé n'existe pas.
  8. Ajouter lireCookie à la liste des exports nommés du fichier.
  9. Tester provisoirement depuis js/app.js.
    • Importer lireCookie depuis ./modules/utilitairesCookies.js (l'ajouter à l'import existant).
    • Appeler ecrireCookie avec le nom "testLecture", la valeur "coucou", et la durée 1.
    • Appeler lireCookie avec le nom "testLecture" et afficher le résultat dans la console. La console doit afficher coucou.
    • Appeler lireCookie avec un nom qui n'existe pas (par exemple "inexistant") et afficher le résultat. La console doit afficher null.
    • Supprimer le code de test après vérification.

Étape 03 : Supprimer un cookie

L'objectif est de créer la dernière fonction utilitaire. En JavaScript, il n'existe pas de méthode native pour supprimer un cookie. L'astuce consiste à réécrire le cookie avec une durée de vie de zéro seconde, ce qui indique au navigateur que le cookie est déjà expiré et qu'il doit le retirer.

  1. Dans js/modules/utilitairesCookies.js, à la suite de la fonction lireCookie, créer une fonction nommée supprimerCookie. Cette fonction reçoit un seul paramètre :
    • nom : le nom du cookie à supprimer (une chaîne de caractères)
  2. Dans le corps de supprimerCookie, construire une chaîne de cookie identique à celle de ecrireCookie mais avec max-age=0. La valeur 0 indique au navigateur que ce cookie expire immédiatement, ce qui revient à le supprimer. La chaîne doit contenir :
    • nom= suivi d'une chaîne vide (la valeur n'a pas d'importance puisque le cookie va être supprimé)
    • max-age=0
    • path=/ (il est important d'utiliser le même path que celui utilisé lors de l'écriture, sinon le navigateur considère qu'il s'agit d'un cookie différent et ne le supprimera pas)
    Assigner cette chaîne à document.cookie.
  3. Ajouter supprimerCookie à la liste des exports nommés du fichier.
  4. Tester provisoirement depuis js/app.js.
    • Importer supprimerCookie (l'ajouter à l'import existant).
    • Écrire un cookie de test : appeler ecrireCookie avec le nom "testSuppression", la valeur "temporaire", et la durée 1.
    • Afficher document.cookie dans la console. La chaîne doit contenir testSuppression=temporaire.
    • Appeler supprimerCookie avec le nom "testSuppression".
    • Afficher à nouveau document.cookie dans la console. La chaîne ne doit plus contenir testSuppression.
    • Vérifier également que lireCookie avec le nom "testSuppression" retourne bien null.
    • Supprimer le code de test après vérification. Laisser les imports en place, ils seront utiles à l'étape suivante.

Étape 04 : Créer le module testeur

Les trois fonctions utilitaires sont prêtes. L'objectif est maintenant de les connecter à la page HTML de test pour pouvoir les utiliser visuellement, sans passer par la console. La logique d'interface est isolée dans un module dédié testeurCookies.js, afin que le module utilitairesCookies.js reste indépendant de toute page HTML et puisse être réutilisé tel quel dans d'autres projets.

  1. Créer le fichier js/modules/testeurCookies.js.
  2. En haut du fichier, importer les trois fonctions ecrireCookie, lireCookie et supprimerCookie depuis ./utilitairesCookies.js. Le chemin commence par ./ car les deux fichiers se trouvent dans le même dossier modules.
  3. À la suite de l'import, créer une variable nommée etatCookiesElem initialisée à null. Cette variable servira à stocker la référence vers la balise pre qui affiche l'état brut de document.cookie dans la page.
  4. Créer une fonction nommée rafraichirAffichage qui ne reçoit aucun paramètre. Son rôle est de mettre à jour la zone d'affichage de l'état brut. Dans le corps de cette fonction, modifier le contenu textuel de etatCookiesElem (via textContent) en lui assignant le contenu actuel de document.cookie. Si document.cookie est une chaîne vide, afficher le texte "(vide)" à la place, afin que la zone ne reste pas visuellement vide.
  5. Créer une fonction nommée initialiserTesteurCookies qui ne reçoit aucun paramètre. Cette fonction sera le point d'entrée du module, appelée depuis app.js.
  6. Dans initialiserTesteurCookies, sélectionner les éléments HTML nécessaires à l'aide de querySelector. Stocker chaque référence dans une constante locale :
    • btnEcrireElem : le bouton dont l'identifiant est btn-ecrire
    • btnLireElem : le bouton dont l'identifiant est btn-lire
    • btnSupprimerElem : le bouton dont l'identifiant est btn-supprimer
    Stocker également la référence de la zone d'état brut (balise dont l'identifiant est etat-cookies) dans la variable de module etatCookiesElem (celle déclarée en haut du fichier).
  7. Toujours dans initialiserTesteurCookies, ajouter un test de sécurité. Si l'un des quatre éléments sélectionnés vaut null (ce qui signifie que le sélecteur CSS n'a trouvé aucune balise correspondante), afficher un avertissement dans la console avec console.warn() contenant le message "Testeur cookies non initialisé : un ou plusieurs éléments introuvables." puis sortir immédiatement de la fonction avec return.
  8. Toujours dans initialiserTesteurCookies, après le test de sécurité, appeler rafraichirAffichage afin que la zone d'état brut affiche les cookies existants dès le chargement de la page.
  9. Exporter la fonction initialiserTesteurCookies en utilisant un export nommé.
  10. Dans js/app.js, remplacer les imports de test par un import de initialiserTesteurCookies depuis ./modules/testeurCookies.js. Appeler cette fonction.
  11. Tester :
    • recharger la page
    • vérifier que la zone d'état brut en bas de page affiche le contenu actuel de document.cookie (ou le texte "(vide)" s'il n'y a aucun cookie)
    • vérifier qu'aucun avertissement n'apparaît dans la console

Étape 05 : Brancher les boutons

L'objectif est de connecter les trois boutons de la page aux fonctions utilitaires et de rafraîchir la zone d'état brut après chaque action.

  1. Dans js/modules/testeurCookies.js, créer une fonction nommée gererEcriture qui ne reçoit aucun paramètre. Son rôle est de récupérer les valeurs saisies par l'utilisateur dans les champs d'écriture, d'appeler ecrireCookie, puis de rafraîchir l'affichage.
  2. Dans le corps de gererEcriture, récupérer la valeur de chacun des trois champs d'écriture via leur propriété value. Utiliser querySelector pour cibler chaque champ par son identifiant HTML :
    • l'identifiant ecrire-nom pour le nom du cookie — stocker la valeur dans une constante nommée nom
    • l'identifiant ecrire-valeur pour la valeur du cookie — stocker dans une constante nommée valeur
    • l'identifiant ecrire-jours pour la durée en jours — stocker dans une constante nommée jours
  3. La valeur récupérée depuis un champ input est toujours une chaîne de caractères, même pour un champ de type number. Convertir jours en nombre à l'aide de la fonction Number. Number prend une chaîne en paramètre et retourne le nombre correspondant (par exemple, Number("7") retourne le nombre 7).
  4. Ajouter une vérification de sécurité : si nom est une chaîne vide ou si la conversion de jours a produit NaN (ce qui signifie que la saisie n'était pas un nombre valide — vérifier avec la fonction isNaN), sortir immédiatement de la fonction avec return sans rien écrire.
  5. Si la vérification est passée, appeler ecrireCookie en lui passant nom, valeur, et le nombre de jours converti. Puis appeler rafraichirAffichage pour mettre à jour la zone d'état brut.
  6. Créer une fonction nommée gererLecture qui ne reçoit aucun paramètre. Dans son corps :
    • récupérer la valeur du champ dont l'identifiant est lire-nom et la stocker dans une constante nommée nom
    • appeler lireCookie en passant nom et stocker le résultat dans une constante nommée resultat
    • sélectionner la balise dont l'identifiant est lire-resultat et modifier son contenu textuel (via textContent). Si resultat vaut null, afficher le texte "Cookie introuvable". Sinon, afficher la valeur trouvée.
  7. Créer une fonction nommée gererSuppression qui ne reçoit aucun paramètre. Dans son corps :
    • récupérer la valeur du champ dont l'identifiant est supprimer-nom et la stocker dans une constante nommée nom
    • si nom est une chaîne vide, sortir immédiatement avec return
    • appeler supprimerCookie en passant nom
    • appeler rafraichirAffichage
  8. Dans initialiserTesteurCookies, après l'appel à rafraichirAffichage, ajouter un écouteur d'événement click sur chacun des trois boutons :
    • btnEcrireElem déclenche gererEcriture
    • btnLireElem déclenche gererLecture
    • btnSupprimerElem déclenche gererSuppression
  9. Tester :
    • recharger la page
    • dans la section « Écrire un cookie », saisir le nom couleur, la valeur bleu, la durée 7, puis cliquer sur Écrire. La zone d'état brut doit se mettre à jour et contenir couleur=bleu.
    • écrire un second cookie (par exemple langue avec la valeur fr). La zone d'état brut doit maintenant afficher les deux cookies.
    • dans la section « Lire un cookie », saisir couleur et cliquer sur Lire. Le résultat affiché doit être bleu.
    • saisir un nom qui n'existe pas (par exemple inconnu) et cliquer sur Lire. Le résultat affiché doit être Cookie introuvable.
    • dans la section « Supprimer un cookie », saisir couleur et cliquer sur Supprimer. La zone d'état brut ne doit plus contenir couleur=bleu.
    • fermer l'onglet, le rouvrir. Les cookies non supprimés doivent toujours être présents dans la zone d'état brut, car ils ont été enregistrés avec une durée de vie de plusieurs jours.
  10. Quand tout fonctionne, supprimer les éventuels logs de debug qui ne sont plus utiles.

Exo 02 : Bannière de consentement

Cet exercice porte sur l'utilisation concrète des cookies dans un cas rencontré sur la quasi-totalité des sites web : la bannière de consentement. Le règlement européen sur la protection des données (RGPD) impose aux sites d'obtenir le consentement de l'utilisateur avant de déposer certains cookies. Une bannière apparaît lors de la première visite, et si l'utilisateur accepte, un cookie de consentement est enregistré pour ne plus réafficher la bannière lors des visites suivantes.

Le module utilitairesCookies.js construit dans l'exercice précédent est réutilisé tel quel. L'objectif est de se concentrer sur la logique métier — afficher, masquer, mémoriser — sans avoir à manipuler directement document.cookie. C'est exactement l'intérêt d'avoir factorisé ces fonctions dans un module séparé.

Attendu

Au chargement de la page, une bannière fixée en bas de l'écran demande le consentement de l'utilisateur. Si l'utilisateur clique sur Accepter, un cookie nommé consentement est enregistré avec la valeur accepte et une durée de vie de 30 jours. La bannière disparaît. Si l'utilisateur recharge la page ou revient plus tard (dans les 30 jours), la bannière ne réapparaît pas car le cookie est toujours présent. Un bouton Réinitialiser le consentement, placé dans la page pour les besoins du test, permet de supprimer le cookie et de réafficher la bannière.

Structure

Créer un dossier nommé exo-02-stockage-banniere-consentement, puis organiser les fichiers et dossiers en respectant l'arborescence suivante :


                    📁 exo-02-stockage-banniere-consentement/
                    ├── 📄 index.html
                    ├── 📁 css/
                    │   └── 📄 style.css
                    └── 📁 js/
                        ├── 📄 app.js
                        └── 📁 modules/
                            ├── 📄 banniereConsentement.js
                            └── 📄 utilitairesCookies.js (reprendre depuis l'exo "boîte à outils cookies")
                

Le fichier utilitairesCookies.js est celui construit dans l'exercice 01. Le copier tel quel dans le dossier js/modules/ de ce nouveau projet.

Fichiers fournis

Copier les fichiers suivants à l'identique.

index.html


                    <!DOCTYPE html>
                    <html lang="fr">
                    <head>
                        <meta charset="UTF-8">
                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                        <link rel="stylesheet" href="./css/style.css">
                        <title>JS: Stockage des données — Exo 02</title>
                    </head>
                    <body>
                        <main>
                            <h1>Bannière de consentement</h1>

                            <section class="contenu">
                                <h2>Bienvenue sur le site</h2>
                                <p>
                                    Cette page simule un site qui utilise une bannière de consentement cookies.
                                    La bannière apparaît en bas de l'écran lors de la première visite.
                                    Si le consentement est donné, elle ne réapparaît plus pendant 30 jours.
                                </p>
                                <button id="btn-reinitialiser" class="btn btn-reinitialiser">Réinitialiser le consentement</button>
                            </section>
                        </main>

                        <aside id="banniere" class="banniere" hidden tabindex="-1" aria-label="Bannière de consentement">
                            <p class="banniere-texte">
                                Ce site utilise des cookies pour améliorer votre expérience.
                                En continuant, vous acceptez leur utilisation.
                            </p>
                            <div class="banniere-actions">
                                <button id="btn-accepter" class="btn btn-accepter">Accepter</button>
                            </div>
                        </aside>

                        <p id="annonce-a11y" class="sr-only" aria-live="polite"></p>

                        <script src="./js/app.js" type="module"></script>
                    </body>
                    </html>
                

La bannière possède l'attribut HTML hidden par défaut. Elle est donc masquée au chargement de la page. C'est le JavaScript qui décidera de la rendre visible ou non, selon que le cookie de consentement existe déjà. Ce choix évite un « flash » visuel désagréable : sans cela, la bannière apparaîtrait brièvement puis disparaîtrait si le consentement a déjà été donné, le temps que le script s'exécute.

css/style.css


                    /* VARIABLES */
                    :root
                    {
                        --fond-principal: #0e1319;
                        --fond-secondaire: #151c24;
                        --fond-banniere: #1c2533;
                        --texte-principal: #e7e1ca;
                        --texte-secondaire: #b9b39f;
                        --accent-visuel: orange;
                        --couleur-reinitialiser: #f44336;

                        --espace-s: 0.75rem;
                        --espace-m: 1.5rem;

                        --police-corps: Arial, sans-serif;

                        --rayon-s: 0.5rem;
                        --bordure: 1px solid rgba(231, 225, 202, 0.15);
                    }

                    /* RESET */
                    html, body, main, section, aside, h1, h2, p, div, button
                    {
                        margin: 0;
                        padding: 0;
                        box-sizing: border-box;
                    }

                    /* BASE */
                    html
                    {
                        font-family: var(--police-corps);
                        color: var(--texte-principal);
                        background-color: var(--fond-principal);
                    }

                    body
                    {
                        min-height: 100vh;
                    }

                    main
                    {
                        margin: 0 auto;
                        padding: var(--espace-m);
                        padding-bottom: 8rem;
                        width: min(700px, 100%);
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-m);
                    }

                    h1
                    {
                        text-align: center;
                    }

                    /* CONTENU */
                    .contenu
                    {
                        border: var(--bordure);
                        border-radius: var(--rayon-s);
                        padding: var(--espace-m);
                        background: var(--fond-secondaire);
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-s);
                    }

                    .contenu h2
                    {
                        font-size: 1.1rem;
                        color: var(--texte-secondaire);
                    }

                    .contenu p
                    {
                        line-height: 1.5;
                        color: var(--texte-secondaire);
                    }

                    /* BOUTONS */
                    .btn
                    {
                        border: none;
                        border-radius: var(--rayon-s);
                        padding: 0.65rem 1.2rem;
                        font-weight: bold;
                        cursor: pointer;
                        font-size: 1rem;
                        transition: opacity 0.2s;
                    }

                    .btn:hover
                    {
                        opacity: 0.9;
                    }

                    .btn:active
                    {
                        opacity: 0.8;
                    }

                    .btn-accepter
                    {
                        color: var(--fond-principal);
                        background-color: var(--accent-visuel);
                    }

                    .btn-reinitialiser
                    {
                        color: white;
                        background-color: var(--couleur-reinitialiser);
                        align-self: flex-start;
                    }

                    /* BANNIÈRE */
                    .banniere
                    {
                        position: fixed;
                        bottom: 0;
                        left: 0;
                        right: 0;
                        background: var(--fond-banniere);
                        border-top: 2px solid var(--accent-visuel);
                        padding: var(--espace-m);
                        display: flex;
                        flex-wrap: wrap;
                        align-items: center;
                        justify-content: center;
                        gap: var(--espace-m);
                        z-index: 1000;
                    }

                    .banniere[hidden]
                    {
                        display: none;
                    }

                    .banniere-texte
                    {
                        flex: 1 1 300px;
                        line-height: 1.5;
                    }

                    .banniere-actions
                    {
                        display: flex;
                        gap: var(--espace-s);
                    }

                    /* UTILITAIRE A11Y : masqué visuellement, lisible par lecteur d'écran */
                    .sr-only
                    {
                        position: absolute;
                        width: 1px;
                        height: 1px;
                        padding: 0;
                        margin: -1px;
                        overflow: hidden;
                        clip: rect(0, 0, 0, 0);
                        white-space: nowrap;
                        border: 0;
                    }

                    /* RESPONSIVE */
                    @media (max-width: 520px)
                    {
                        main
                        {
                            padding: 1rem;
                            padding-bottom: 10rem;
                        }

                        .contenu
                        {
                            padding: 1rem;
                        }

                        .banniere
                        {
                            flex-direction: column;
                            text-align: center;
                            padding: 1rem;
                        }
                    }
                

Instructions

Le travail se fait dans js/modules/banniereConsentement.js et js/app.js. Le module utilitairesCookies.js est réutilisé sans modification. Chaque étape doit être testée avant de passer à la suivante.

Étape 01 : Préparer le module et sélectionner les éléments

L'objectif est de créer le module de la bannière, d'importer les fonctions utilitaires nécessaires et de récupérer les références vers les éléments HTML de la page.

  1. Créer le fichier js/modules/banniereConsentement.js.
  2. En haut du fichier, importer les fonctions ecrireCookie, lireCookie et supprimerCookie depuis ./utilitairesCookies.js. Ces trois fonctions ont été construites dans l'exercice 01. Le fait de les importer ici montre l'intérêt d'un module utilitaire : on réutilise un code déjà testé sans le réécrire.
  3. À la suite de l'import, déclarer deux constantes de configuration. Ces constantes centralisent les valeurs utilisées par le module afin d'éviter de répéter des chaînes en dur à plusieurs endroits dans le code :
    • NOM_COOKIE avec la valeur "consentement" — c'est le nom du cookie qui sera lu, écrit et supprimé
    • DUREE_JOURS avec la valeur 30 — c'est la durée de vie du cookie, en jours
    Le fait d'écrire ces noms en majuscules est une convention courante en JavaScript. Elle signale aux autres développeurs que ces valeurs sont des constantes de configuration qui ne doivent pas être modifiées par le programme.
  4. Déclarer trois variables de module initialisées à null :
    • banniereElem — contiendra la référence vers la bannière
    • btnAccepterElem — contiendra la référence vers le bouton « Accepter »
    • btnReinitialiserElem — contiendra la référence vers le bouton « Réinitialiser le consentement »
    Ces variables sont déclarées en dehors des fonctions (au niveau du module) pour être accessibles par toutes les fonctions du fichier.
  5. Créer une fonction nommée initialiserBanniereConsentement qui ne reçoit aucun paramètre. Cette fonction sera le point d'entrée du module, appelée depuis app.js.
  6. Dans initialiserBanniereConsentement, sélectionner les trois éléments HTML à l'aide de querySelector et stocker chaque référence dans la variable de module correspondante :
    • banniereElem : la balise dont l'identifiant est banniere
    • btnAccepterElem : la balise dont l'identifiant est btn-accepter
    • btnReinitialiserElem : la balise dont l'identifiant est btn-reinitialiser
  7. Ajouter un test de sécurité. Si l'un des trois éléments vaut null, afficher un avertissement dans la console avec console.warn() contenant le message "Bannière consentement non initialisée : un ou plusieurs éléments introuvables." puis sortir immédiatement de la fonction avec return.
  8. Ajouter provisoirement un console.log() après le test de sécurité, affichant le message "Bannière consentement initialisée".
  9. Exporter la fonction initialiserBanniereConsentement en utilisant un export nommé.
  10. Créer le fichier js/app.js. Importer initialiserBanniereConsentement depuis ./modules/banniereConsentement.js et l'appeler.
  11. Tester :
    • recharger la page
    • ouvrir la console
    • vérifier que le message "Bannière consentement initialisée" apparaît, ce qui confirme que les trois éléments ont été trouvés et que le test de sécurité est passé

Étape 02 : Vérifier le consentement au chargement

L'objectif est de lire le cookie de consentement au chargement de la page et de décider si la bannière doit être affichée ou non.

  1. Dans js/modules/banniereConsentement.js, créer une fonction nommée verifierConsentement qui ne reçoit aucun paramètre. Son rôle est de lire le cookie de consentement et de rendre la bannière visible s'il n'existe pas.
  2. Dans le corps de verifierConsentement, appeler lireCookie en lui passant NOM_COOKIE comme argument. Stocker le résultat dans une constante nommée valeur.
    • Si le cookie existe, lireCookie retourne sa valeur (ici "accepte").
    • Si le cookie n'existe pas, lireCookie retourne null.
  3. Vérifier si valeur vaut null. Cela signifie que l'utilisateur n'a pas encore donné son consentement (ou que le cookie a expiré). Dans ce cas, rendre la bannière visible en passant la propriété hidden de banniereElem à false.
    • Rappel : dans le HTML fourni, la bannière possède l'attribut hidden par défaut. Passer hidden à false retire cet attribut et rend l'élément visible.
  4. Dans initialiserBanniereConsentement, après le test de sécurité, appeler verifierConsentement.
  5. Tester :
    • avant de recharger, ouvrir les outils de développement du navigateur et supprimer manuellement tous les cookies du site (dans l'onglet Application > Cookies sous Chrome, ou Stockage > Cookies sous Firefox)
    • recharger la page : la bannière doit apparaître en bas de l'écran, car le cookie consentement n'existe pas
    • ajouter manuellement un cookie nommé consentement avec la valeur accepte depuis la console du navigateur (en utilisant la page de test de l'exercice 01, ou directement via document.cookie = "consentement=accepte; max-age=86400; path=/")
    • recharger la page : la bannière ne doit pas apparaître, car le cookie existe déjà
    • supprimer à nouveau le cookie manuellement et recharger pour confirmer que la bannière réapparaît

Étape 03 : Accepter le consentement

L'objectif est de brancher le bouton « Accepter » pour qu'il enregistre le cookie de consentement et masque la bannière.

  1. Dans js/modules/banniereConsentement.js, créer une fonction nommée accepterConsentement qui ne reçoit aucun paramètre.
  2. Dans le corps de accepterConsentement, appeler ecrireCookie en lui passant trois arguments :
    • NOM_COOKIE comme nom du cookie
    • la chaîne "accepte" comme valeur
    • DUREE_JOURS comme durée en jours
    L'utilisation des constantes de configuration garantit que le nom et la durée sont cohérents partout dans le module.
  3. Toujours dans accepterConsentement, masquer la bannière en passant la propriété hidden de banniereElem à true.
  4. Dans initialiserBanniereConsentement, après l'appel à verifierConsentement, ajouter un écouteur d'événement click sur btnAccepterElem. À chaque clic, la fonction accepterConsentement est exécutée.
  5. Tester :
    • supprimer le cookie consentement depuis les outils de développement si nécessaire, puis recharger
    • la bannière doit apparaître
    • cliquer sur Accepter : la bannière doit disparaître
    • recharger la page : la bannière ne doit pas réapparaître, car le cookie consentement a été enregistré
    • vérifier dans les outils de développement (onglet Application > Cookies) que le cookie consentement est bien présent avec la valeur accepte

Étape 04 : Réinitialiser le consentement

L'objectif est de brancher le bouton « Réinitialiser le consentement » pour supprimer le cookie et réafficher la bannière. Ce bouton n'existerait pas sur un vrai site — il est présent uniquement pour faciliter les tests pendant le développement.

  1. Dans js/modules/banniereConsentement.js, créer une fonction nommée reinitialiserConsentement qui ne reçoit aucun paramètre.
  2. Dans le corps de reinitialiserConsentement, appeler supprimerCookie en lui passant NOM_COOKIE comme argument. Cette fonction, construite dans l'exercice 01, réécrit le cookie avec un max-age de 0, ce qui provoque sa suppression immédiate par le navigateur.
  3. Toujours dans reinitialiserConsentement, rendre la bannière visible en passant la propriété hidden de banniereElem à false.
  4. Dans initialiserBanniereConsentement, après l'écouteur du bouton « Accepter », ajouter un écouteur d'événement click sur btnReinitialiserElem. À chaque clic, la fonction reinitialiserConsentement est exécutée.
  5. Tester le scénario complet :
    • supprimer tous les cookies du site depuis les outils de développement, puis recharger
    • la bannière doit apparaître (pas de cookie de consentement)
    • cliquer sur Accepter : la bannière disparaît
    • recharger la page : la bannière ne réapparaît pas (le cookie est présent)
    • cliquer sur Réinitialiser le consentement : la bannière réapparaît
    • vérifier dans les outils de développement que le cookie consentement a bien été supprimé
    • recharger la page : la bannière doit être visible, car le cookie n'existe plus
    • cliquer sur Accepter, puis fermer l'onglet. Rouvrir la page : la bannière ne doit pas apparaître, car le cookie a une durée de vie de 30 jours et subsiste entre les sessions du navigateur
  6. Quand tout fonctionne, supprimer les logs de debug qui ne sont plus utiles.

Étape 05 : Surcouche accessibilité (focus + annonces)

La bannière est déjà fonctionnelle : elle s'affiche quand le cookie n'existe pas, puis se masque après acceptation. L'objectif de cette étape est d'améliorer l'expérience au clavier et avec un lecteur d'écran. Une interface dynamique peut poser deux problèmes courants :

  • Quand la bannière apparaît, le focus clavier reste souvent "derrière" (sur un élément de la page). Atteindre le bouton Accepter peut demander plusieurs pressions sur Tab.
  • Quand la bannière apparaît ou disparaît, un lecteur d'écran n'est pas forcément averti du changement. Une annonce courte permet de confirmer ce qui se passe.

Le HTML fourni doit déjà contenir deux éléments indispensables à cette surcouche : tabindex="-1" sur la bannière pour pouvoir la focaliser en JavaScript, et une zone aria-live pour annoncer des messages. Si ce n'est pas encore fait, modifier le gabarit avant de commencer cette étape.

  1. Dans index.html, vérifier que la bannière (#banniere) possède bien tabindex="-1". Ce réglage permet de déplacer le focus sur la bannière par script, sans l'ajouter à l'ordre naturel de tabulation.
  2. Dans index.html, vérifier la présence d'un élément #annonce-a11y avec aria-live="polite" et la classe sr-only. Cette zone sert à annoncer des messages brefs (apparition de la bannière, consentement enregistré), sans perturber l'affichage.
  3. Dans js/modules/banniereConsentement.js, déclarer une variable de module destinée à mémoriser l'élément qui avait le focus avant l'ouverture de la bannière. Cette mémorisation permet de rendre le focus à un endroit logique après fermeture.
  4. Toujours dans js/modules/banniereConsentement.js, sélectionner l'élément #annonce-a11y lors de l'initialisation (comme pour les autres éléments). Ajouter un test de sécurité : si cet élément est introuvable, afficher un avertissement dans la console et continuer sans annonces (la bannière doit rester utilisable).
  5. Créer une petite fonction utilitaire interne (dans le module) pour écrire un message dans #annonce-a11y. L'objectif est d'éviter de répéter la même logique à plusieurs endroits. Utiliser textContent pour mettre à jour le message.
  6. Au moment d'afficher la bannière (dans la logique qui décide de la rendre visible), mémoriser l'élément actuellement focus via document.activeElement. Puis rendre la bannière visible (passer hidden à false).
  7. Juste après l'affichage de la bannière, déplacer le focus vers le bouton Accepter. Ce bouton est l'action principale : y placer le focus permet de valider rapidement au clavier. Si, pour une raison quelconque, le bouton est introuvable, déplacer le focus vers la bannière elle-même (#banniere) grâce à tabindex="-1".
  8. Après avoir déplacé le focus, annoncer l'apparition de la bannière via la zone aria-live. Utiliser un message court, par exemple : "Bannière de consentement affichée." L'objectif n'est pas de répéter tout le texte, mais de signaler l'événement.
  9. Lors d'un clic sur Accepter, une fois le cookie écrit et la bannière masquée, annoncer la confirmation via la zone aria-live, par exemple : "Consentement enregistré."
  10. Après la fermeture (après avoir masqué la bannière), restaurer le focus. Priorité :
    • si l'élément mémorisé existe toujours dans la page et n'est pas masqué, lui redonner le focus
    • sinon, déplacer le focus sur un élément stable, par exemple le bouton #btn-reinitialiser
    Cela évite que le focus reste sur un élément caché, ce qui bloque souvent la navigation clavier.
  11. Lors d'un clic sur Réinitialiser le consentement, appliquer la même logique que pour une ouverture normale : mémoriser le focus actuel, afficher la bannière, déplacer le focus vers Accepter, puis annoncer le retour de la bannière.
  12. Tester uniquement au clavier :
    • supprimer le cookie de consentement, recharger : la bannière apparaît et le focus arrive sur Accepter
    • valider Accepter au clavier : la bannière disparaît et le focus reste sur un élément visible
    • activer Réinitialiser le consentement : la bannière réapparaît et le focus revient sur Accepter

Exo 03 : Liste de courses persistante

Dans un site e-commerce, une fonctionnalité très courante est la liste de courses : l'utilisateur note ce qu'il veut acheter, et il retrouve cette liste plus tard, même après avoir fermé le navigateur. Cet exercice te fait construire une petite application qui ajoute et supprime des produits, puis enregistre la liste dans le localStorage pour qu'elle reste disponible au prochain chargement de la page.

Tu utiliseras uniquement des tableaux classiques (pas de Map, pas de Set), pas de POO, pas de regex. Pour stocker un tableau dans le localStorage, tu passeras par JSON.stringify et JSON.parse.

Attendu

L'utilisateur peut :

  • taper un produit (ex: "Lait") puis cliquer sur Ajouter
  • voir la liste s'afficher immédiatement
  • supprimer un produit via un bouton Supprimer sur chaque ligne
  • vider la liste via un bouton Tout effacer
  • recharger la page : la liste est restaurée automatiquement (persistance)

Un message d'aide s'affiche quand la liste est vide. Un petit compteur indique le nombre d'éléments.

Structure


                    📁 exo-03-liste-courses-localstorage/
                    ├── 📁 css/
                    │   └── 📄 style.css
                    ├── 📁 js/
                    │   ├── 📄 main.js
                    │   └── 📁 modules/
                    │       └── 📄 listeCourses.js
                    └── 📄 index.html
                

Fichiers fournis

Copier les fichiers suivants à l'identique. Tu écriras ta logique dans js/modules/listeCourses.js.

index.html


                    <!DOCTYPE html>
                    <html lang="fr">
                    <head>
                        <meta charset="UTF-8">
                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                        <link rel="stylesheet" href="./css/style.css">
                        <title>Liste de courses (localStorage)</title>
                    </head>
                    <body>
                        <header class="site-header">
                            <h1>🛒 Liste de courses</h1>
                            <p class="intro">
                                Ajoute des produits, puis recharge la page : ta liste doit rester.
                            </p>
                        </header>

                        <main class="container">
                            <section class="card" aria-labelledby="titre-formulaire">
                                <h2 id="titre-formulaire">Ajouter un produit</h2>

                                <form id="form-ajout">
                                    <div class="field">
                                        <label for="produit">Nom du produit</label>
                                        <input id="produit" name="produit" type="text" autocomplete="off" required>
                                        <p class="help" id="help-produit">Exemples : Pain, Lait, Pâtes…</p>
                                    </div>

                                    <div class="actions">
                                        <button id="btn-ajouter" type="submit">Ajouter</button>
                                        <button id="btn-effacer" type="button">Tout effacer</button>
                                    </div>
                                </form>

                                <!-- Zone de retours utilisateur (déjà accessible en HTML ; la mise à jour se fera en JS à la fin) -->
                                <p id="feedback" class="feedback" role="status" aria-live="polite"></p>
                            </section>

                            <section class="card" aria-labelledby="titre-liste">
                                <div class="liste-header">
                                    <h2 id="titre-liste">Ma liste</h2>
                                    <p class="compteur">
                                        <span id="nb-items">0</span> élément(s)
                                    </p>
                                </div>

                                <p id="etat-vide" class="empty">Ta liste est vide. Ajoute ton premier produit 👆</p>

                                <ul id="liste" class="liste"></ul>
                            </section>
                        </main>

                        <script type="module" src="./js/main.js"></script>
                    </body>
                    </html>
                

css/style.css


                    :root{
                        font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
                    }

                    body{
                        margin: 0;
                        background: #f6f7fb;
                    }

                    .site-header{
                        padding: 24px 16px;
                        background: white;
                        border-bottom: 1px solid #e7e7ef;
                    }

                    .intro{ margin: 8px 0 0; }

                    .container{
                        max-width: 900px;
                        margin: 0 auto;
                        padding: 16px;
                        display: grid;
                        gap: 16px;
                    }

                    .card{
                        background: white;
                        border: 1px solid #e7e7ef;
                        border-radius: 12px;
                        padding: 16px;
                    }

                    .field{
                        display: grid;
                        gap: 6px;
                        margin-bottom: 12px;
                    }

                    input{
                        padding: 10px;
                        border-radius: 10px;
                        border: 1px solid #cfd2e3;
                    }

                    .actions{
                        display: flex;
                        gap: 8px;
                        flex-wrap: wrap;
                    }

                    button{
                        padding: 10px 12px;
                        border-radius: 10px;
                        border: 1px solid #cfd2e3;
                        background: #fff;
                        cursor: pointer;
                    }

                    button:disabled{
                        opacity: 0.6;
                        cursor: not-allowed;
                    }

                    .liste-header{
                        display: flex;
                        align-items: baseline;
                        justify-content: space-between;
                        gap: 12px;
                        flex-wrap: wrap;
                    }

                    .empty{
                        margin: 8px 0 0;
                        color: #4a4f68;
                    }

                    .liste{
                        margin: 12px 0 0;
                        padding: 0;
                        list-style: none;
                        display: grid;
                        gap: 8px;
                    }

                    .ligne{
                        display: flex;
                        align-items: center;
                        justify-content: space-between;
                        gap: 12px;
                        padding: 10px;
                        border: 1px solid #e7e7ef;
                        border-radius: 12px;
                    }

                    .feedback{
                        margin: 12px 0 0;
                        min-height: 1.2em;
                    }
                

js/main.js


                    import { demarrerListeCourses } from "./modules/listeCourses.js";

                    demarrerListeCourses();
                

Étapes

Dans cet exercice, tu vas construire une mini "liste de courses" comme on en voit partout (wishlist, panier, pense-bête). L'idée centrale est simple : un tableau JavaScript représente la liste, et ce tableau est persisté dans le localStorage pour survivre à un rechargement de page (et même à la fermeture du navigateur).

Pour progresser sans te perdre, on va avancer par petites couches : d'abord récupérer les éléments HTML, ensuite gérer les données, ensuite afficher, puis brancher les actions (ajout / suppression / effacement), et seulement à la fin ajouter une surcouche accessibilité en JavaScript.

Étape 01 : Préparer le module et récupérer le DOM

Objectif : mettre en place un fichier module clair, et stocker dans des variables les éléments de la page dont on aura besoin. Pourquoi ? Parce que si tu "re-cherches" le DOM partout, ton code devient vite confus et difficile à maintenir.

  1. Créer le fichier js/modules/listeCourses.js.

    Ce fichier contiendra toute la logique liée à la liste de courses (c'est plus propre que de tout mettre dans app.js).

  2. En haut du fichier, créer une constante CLE_STORAGE avec la valeur "listeCourses".

    Pourquoi ? Le localStorage est une grande "boîte" de paires clé/valeur. Si la clé change, tu ne retrouveras plus tes données.

    Comment ? Une constante évite les fautes de frappe : tu réutilises toujours la même clé au lieu d'écrire du texte à la main partout.

  3. Créer des variables (initialisées à null) pour stocker les éléments HTML utiles :
    • formAjoutElem
    • inputProduitElem
    • listeElem
    • etatVideElem
    • nbItemsElem
    • btnEffacerElem
    • zoneMessageElem (zone de feedback déjà prévue dans le gabarit)

    Pourquoi ? Ces variables serviront dans plusieurs fonctions (afficher la liste, ajouter, supprimer…). Les avoir "à portée" évite de répéter des querySelector partout.

  4. Créer une fonction initialiserListeCourses qui reçoit des sélecteurs CSS en paramètres (ex : sélecteur du formulaire, de la liste, etc.).

    Pourquoi ? Un module réutilisable ne doit pas dépendre d'un HTML "figé". Si un jour le sélecteur change, tu n'as qu'un seul endroit à modifier : l'appel depuis app.js.

    Comment ? À l'intérieur, tu utilises document.querySelector pour remplir tes variables DOM à partir des sélecteurs reçus.

  5. Dans app.js, importer la fonction et l'appeler une seule fois au chargement.

    Pourquoi ? On centralise l'initialisation : un point d'entrée clair (comme un "on/off" de l'application).

Étape 02 : Modèle de données + chargement depuis le localStorage

Objectif : avoir un tableau en mémoire (ex : produits) qui représente la liste. Puis, au démarrage, le remplir à partir du localStorage si une sauvegarde existe.
Pourquoi ? Parce que l'interface (HTML) n'est qu'une "vue" : la vraie source de vérité, c'est ton tableau.

  1. Déclarer un tableau (ex : produits = []) en dehors des fonctions.

    Pourquoi ? Il doit être accessible aux fonctions d'ajout/suppression/affichage.

  2. Créer une fonction chargerDepuisStorage.

    Ce qu'on fait : lire la valeur associée à CLE_STORAGE.

    Pourquoi ? Le localStorage stocke des textes, pas des tableaux. Donc si on a stocké un tableau, il a été transformé en texte.

    Comment ? Utiliser localStorage.getItem. Si le résultat est null, cela veut dire "rien n'a été sauvegardé". Sinon, convertir le texte en tableau avec JSON.parse.

  3. Dans initialiserListeCourses, appeler chargerDepuisStorage avant d'afficher quoi que ce soit.

    Pourquoi ? Sinon, tu afficherais une liste vide même si des données existent déjà.

  4. Créer une fonction sauvegarderDansStorage.

    Ce qu'on fait : transformer le tableau en texte et l'enregistrer.

    Pourquoi ? Le stockage ne comprend que des chaînes de caractères.

    Comment ? Utiliser JSON.stringify puis localStorage.setItem.

Étape 03 : Afficher la liste à partir du tableau

Objectif : écrire une fonction unique qui "rend" l'interface à partir du tableau.
Pourquoi ? Comme ça, après chaque action (ajout, suppression, effacement), tu n'as qu'à appeler cette fonction : la page se remet dans un état cohérent automatiquement.

  1. Créer une fonction afficherListe.

    Ce qu'on fait : vider le contenu actuel de listeElem, puis recréer les éléments en fonction du tableau.

    Comment ? Utiliser une boucle for sur le tableau. Pour chaque produit, créer un li et un bouton "Supprimer".

  2. Pour relier un bouton "Supprimer" à un élément du tableau, ajouter un attribut data-index sur le bouton.

    Pourquoi ? Le DOM n'a pas "magiquement" l'index du tableau. L'attribut data-* sert à transporter une petite info utile depuis le HTML vers le JS.

    Comment ? Lors de la création du bouton, tu lui donnes l'index courant (celui de ta boucle). Plus tard, au clic, tu reliras cet index pour savoir quoi supprimer dans le tableau.

  3. Mettre à jour l'état de l'interface :
    • le compteur nbItemsElem
    • le message "liste vide" (etatVideElem)
    • le bouton "Tout effacer" (désactivé si la liste est vide)

    Pourquoi ? L'utilisateur doit comprendre immédiatement ce qu'il se passe (et éviter de cliquer sur un bouton inutile).

Étape 04 : Ajouter un produit

Objectif : quand l'utilisateur soumet le formulaire, ajouter un élément dans le tableau, sauvegarder, puis ré-afficher.
Pourquoi ? C'est le cycle standard : données → stockage → interface.

  1. Dans initialiserListeCourses, ajouter un écouteur d'événement sur le formulaire (événement submit).

    Pourquoi ? Un formulaire déclenche un rechargement par défaut : on veut empêcher ce comportement pour rester dans la même page.

    Comment ? Dans la fonction de gestion, appeler preventDefault().

  2. Récupérer le texte saisi, le nettoyer simplement (espaces début/fin), puis vérifier qu'il n'est pas vide.

    Pourquoi ? On évite d'ajouter des entrées "vides" (mauvaise expérience utilisateur).

    Comment ? Utiliser trim(), puis une condition if. Si c'est vide, afficher un message dans la zone de feedback (sans alerte bloquante).

  3. Ajouter le produit au tableau.

    Comment ? Utiliser push sur le tableau.

  4. Sauvegarder puis ré-afficher.

    Pourquoi ? Si tu oublies la sauvegarde, la liste disparaîtra au prochain rechargement. Si tu oublies l'affichage, l'utilisateur ne verra pas le résultat.

    Comment ? Appeler sauvegarderDansStorage puis afficherListe.

  5. Réinitialiser le champ de saisie.

    Pourquoi ? L'utilisateur peut enchaîner rapidement plusieurs ajouts.

    Comment ? Vider la valeur de l'input et remettre le focus dans le champ. (Le focus "propre" fera partie de la surcouche accessibilité finale.)

Étape 05 : Supprimer un produit (gestion d'événements sur la liste)

Objectif : supprimer l'élément correspondant au bouton cliqué.
Pourquoi ? Les boutons sont créés dynamiquement : plutôt que de mettre un écouteur sur chaque bouton, on peut écouter un seul clic sur la liste et déterminer ce qui a été cliqué (technique simple et robuste).

  1. Ajouter un seul écouteur de clic sur listeElem.

    Comment ? Dans la fonction de clic, regarder event.target : si ce n'est pas un bouton "Supprimer", tu ne fais rien.

  2. Lire l'index depuis data-index.

    Pourquoi ? C'est le lien entre l'élément HTML cliqué et la position dans le tableau.

    Comment ? Accéder à dataset du bouton. Comme un dataset est du texte, convertir en nombre avant de l'utiliser comme index.

  3. Supprimer l'élément du tableau.

    Comment ? Utiliser splice avec l'index et une quantité de 1.

  4. Sauvegarder puis ré-afficher.

    Pourquoi ? Même logique que l'ajout : données → stockage → interface.

Étape 06 : Tout effacer

Objectif : vider la liste d'un coup.
Pourquoi ? C'est une action fréquente sur un pense-bête : l'utilisateur veut repartir de zéro.

  1. Ajouter un écouteur sur le bouton btnEffacerElem.

    Comment ? Au clic, vider le tableau (par exemple en remettant sa longueur à 0), puis sauvegarder et ré-afficher.

  2. En plus, supprimer la donnée du localStorage.

    Pourquoi ? Si tu laisses l'ancienne valeur stockée, le prochain chargement pourrait restaurer une liste que l'utilisateur pensait avoir effacée.

    Comment ? Utiliser localStorage.removeItem avec la clé.

Étape 07 : Surcouche accessibilité

  1. Mettre à jour la zone aria-live (déjà dans le HTML) après chaque action importante (ajout, suppression, effacement).

    Pourquoi ? Les lecteurs d'écran ne "devinent" pas toujours qu'une liste a changé. Une annonce courte aide l'utilisateur à comprendre ce qui vient de se passer.

    Comment ? Après l'action, modifier le texte de zoneMessageElem (ex : "Produit ajouté : …", "Produit supprimé", "Liste vidée").

  2. Gérer le focus après les actions dynamiques.

    Pourquoi ? Au clavier, l'utilisateur ne doit pas "perdre" sa position.

    Comment ? Après un ajout : remettre le focus sur le champ de saisie. Après une suppression : déplacer le focus vers le bouton "Supprimer" suivant s'il existe, sinon vers le champ de saisie.

  3. Désactiver/activer intelligemment le bouton "Tout effacer" selon l'état de la liste.

    Pourquoi ? C'est à la fois une bonne UX et un indice clair pour les technologies d'assistance.

    Comment ? Dans afficherListe, mettre disabled à true si le tableau est vide.

Exo 04 : Mode sombre / clair

Objectif : construire un système de thème clair / sombre avec un troisième mode système (suivre la préférence de l'OS). Le thème doit rester identique après rechargement, et éviter le "flash" (page affichée en clair puis basculée en sombre après coup).

Contexte important : la page est rendue en SSR via PHP. SSR (Server-Side Rendering) signifie que le serveur génère le HTML et l'envoie au navigateur déjà prêt à afficher. Le JavaScript s'exécute ensuite, après réception du HTML.

Conséquence : si le serveur connaît le thème au moment de générer le HTML, il peut appliquer le bon thème avant le premier affichage. C'est la clé pour éviter le "flash".

Pourquoi utiliser des cookies plutôt que localStorage ?

localStorage est accessible uniquement côté navigateur. En SSR, PHP ne peut pas lire localStorage au moment de générer le HTML. Un cookie, lui, est envoyé au serveur avec la requête HTTP : PHP peut donc le lire immédiatement et rendre la page avec le bon thème dès le départ.

Résumé : SSR → cookie (préférence lisible par PHP), alors que site 100% front → localStorage (préférence purement côté client).

Attendu

  • 3 choix : Clair, Sombre, Système
  • Basculement immédiat sans recharger la page
  • Mémorisation du choix dans un cookie theme
  • Au rechargement : thème appliqué dès le HTML (SSR)
  • En mode Système : suivi de l'OS, y compris si l'OS change pendant que la page est ouverte

Structure

Créer un dossier nommé exo-04-theme-sombre-clair-ssr-php, puis organiser les fichiers ainsi :


                    📁 exo-04-theme-sombre-clair-ssr-php/
                    ├── 📄 index.php
                    ├── 📁 css/
                    │   └── 📄 style.css
                    └── 📁 js/
                        ├── 📄 app.js
                        └── 📁 modules/
                            ├── 📄 theme.js
                            └── 📄 utilitairesCookies.js   (reprendre depuis l'exo "boîte à outils cookies")
                

Fichiers fournis

Copier ces fichiers à l'identique. Le HTML est déjà construit avec des bonnes pratiques (structure, labels, groupement de radios, zone de messages). Le travail porte principalement sur JavaScript.

index.php


                    <?php
                    /*
                        NOTE (cours JavaScript) :
                        Cette partie PHP sert uniquement à simuler le contexte SSR.

                        - Le serveur génère le HTML.
                        - Le serveur peut lire un cookie "theme".
                        - Le serveur applique un thème dans l'attribut data-theme dès le HTML initial (réduction du "flash").

                        Aucun objectif PHP : ne pas "apprendre PHP" ici.
                    */

                    $theme = $_COOKIE["theme"] ?? "system";

                    /*
                        PHP ne peut pas connaître prefers-color-scheme (préférence OS).
                        Si le cookie vaut "system" (ou est absent), appliquer un fallback SSR :
                        - Ici : "light"
                        Un mini script dans le <head> ajustera vers "dark" si l'OS préfère le sombre.
                    */
                    $themeAttr = ($theme === "light" || $theme === "dark") ? $theme : "light";
                    ?>

                    <!DOCTYPE html>
                    <html lang="fr" data-theme="<?= htmlspecialchars($themeAttr, ENT_QUOTES) ?>">
                    <head>
                        <meta charset="UTF-8">
                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                        <link rel="stylesheet" href="./css/style.css">
                        <title>JS : Thème clair / sombre (SSR)</title>

                        <!--
                            Mini script anti-flash :
                            - Objectif : si le cookie vaut "system" (ou absent), appliquer le thème effectif (light/dark) avant affichage.
                            - Contrainte : pas de regex.
                            - Important : réutiliser la logique de la boîte à outils cookies (lireCookie),
                            car les modules ES importés plus bas ne sont pas disponibles à cet instant.
                        -->
                        <script>
                            (function () {
                                // TODO :
                                // Récupérer la valeur du cookie "theme" sans regex (copier la logique de lireCookie).
                                // Si valeur "system" ou null : détecter prefers-color-scheme.
                                // Poser data-theme sur document.documentElement avec "light" ou "dark".
                            })();
                        </script>
                    </head>

                    <body>
                        <main>
                            <h1>Thème clair / sombre</h1>

                            <section class="panneau">
                                <h2>Choisir un thème</h2>

                                <fieldset class="choix-theme">
                                    <legend>Mode</legend>

                                    <label>
                                        <input type="radio" name="theme" value="light" id="theme-light">
                                        Clair
                                    </label>

                                    <label>
                                        <input type="radio" name="theme" value="dark" id="theme-dark">
                                        Sombre
                                    </label>

                                    <label>
                                        <input type="radio" name="theme" value="system" id="theme-system">
                                        Système
                                    </label>
                                </fieldset>

                                <p id="theme-info" class="info"></p>

                                <!-- Zone d'annonce (ARIA) : mise à jour en JS à la dernière étape -->
                                <p id="theme-live" class="sr-only" aria-live="polite"></p>
                            </section>

                            <section class="panneau">
                                <h2>Aperçu</h2>
                                <p>
                                    Ce bloc sert à vérifier les contrastes et le changement de thème.
                                    L'objectif : changer le thème en ne pilotant qu'un seul attribut sur la balise html.
                                </p>

                                <div class="cartes">
                                    <article class="carte">
                                        <h3>Carte A</h3>
                                        <p>Texte de démonstration.</p>
                                        <button class="btn">Action</button>
                                    </article>

                                    <article class="carte">
                                        <h3>Carte B</h3>
                                        <p>Texte de démonstration.</p>
                                        <button class="btn">Action</button>
                                    </article>
                                </div>
                            </section>
                        </main>

                        <script src="./js/app.js" type="module"></script>
                    </body>
                    </html>
                

css/style.css


                    /* VARIABLES (valeurs par défaut = thème clair) */
                    :root
                    {
                        --fond-principal: #f6f3ea;
                        --fond-secondaire: #ffffff;
                        --texte-principal: #1e2227;
                        --texte-secondaire: #4b5563;
                        --accent-visuel: #ff8a00;

                        --espace-s: 0.75rem;
                        --espace-m: 1.5rem;

                        --police-corps: Arial, sans-serif;

                        --rayon-s: 0.5rem;
                        --bordure: 1px solid rgba(0, 0, 0, 0.12);
                    }

                    /* THÈME SOMBRE (piloté par l'attribut) */
                    html[data-theme="dark"]
                    {
                        --fond-principal: #0e1319;
                        --fond-secondaire: #151c24;
                        --texte-principal: #e7e1ca;
                        --texte-secondaire: #b9b39f;
                        --bordure: 1px solid rgba(231, 225, 202, 0.15);
                    }

                    /* Aide le navigateur à choisir des styles natifs cohérents */
                    html[data-theme="light"] { color-scheme: light; }
                    html[data-theme="dark"] { color-scheme: dark; }

                    /* RESET */
                    html, body, main, section, h1, h2, h3, p, fieldset, legend, label, input, button, article, div
                    {
                        margin: 0;
                        padding: 0;
                        box-sizing: border-box;
                    }

                    /* BASE */
                    html
                    {
                        font-family: var(--police-corps);
                        color: var(--texte-principal);
                        background-color: var(--fond-principal);
                    }

                    body { min-height: 100vh; }

                    main
                    {
                        margin: 0 auto;
                        padding: var(--espace-m);
                        width: min(760px, 100%);
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-m);
                    }

                    h1 { text-align: center; }

                    .panneau
                    {
                        border: var(--bordure);
                        border-radius: var(--rayon-s);
                        padding: var(--espace-m);
                        background: var(--fond-secondaire);
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-s);
                    }

                    .panneau h2
                    {
                        font-size: 1.1rem;
                        color: var(--texte-secondaire);
                    }

                    .choix-theme
                    {
                        border: var(--bordure);
                        border-radius: var(--rayon-s);
                        padding: var(--espace-s);
                        display: flex;
                        flex-direction: column;
                        gap: 0.5rem;
                    }

                    label
                    {
                        display: flex;
                        gap: 0.6rem;
                        align-items: center;
                        font-size: 1rem;
                    }

                    input[type="radio"] { accent-color: var(--accent-visuel); }

                    .info
                    {
                        color: var(--texte-secondaire);
                        min-height: 1.4em;
                    }

                    /* CARTES */
                    .cartes
                    {
                        display: grid;
                        grid-template-columns: 1fr 1fr;
                        gap: var(--espace-s);
                    }

                    .carte
                    {
                        border: var(--bordure);
                        border-radius: var(--rayon-s);
                        padding: var(--espace-m);
                        background: transparent;
                        display: flex;
                        flex-direction: column;
                        gap: var(--espace-s);
                    }

                    .btn
                    {
                        border: none;
                        border-radius: var(--rayon-s);
                        padding: 0.65rem 1.2rem;
                        font-weight: bold;
                        cursor: pointer;
                        font-size: 1rem;
                        color: var(--fond-secondaire);
                        background-color: var(--accent-visuel);
                        align-self: flex-start;
                        transition: opacity 0.2s;
                    }

                    .btn:hover { opacity: 0.92; }
                    .btn:active { opacity: 0.84; }

                    .sr-only
                    {
                        position: absolute;
                        width: 1px;
                        height: 1px;
                        padding: 0;
                        margin: -1px;
                        overflow: hidden;
                        clip: rect(0, 0, 0, 0);
                        white-space: nowrap;
                        border: 0;
                    }

                    /* RESPONSIVE */
                    @media (max-width: 640px)
                    {
                        .cartes { grid-template-columns: 1fr; }
                        main { padding: 1rem; }
                        .panneau { padding: 1rem; }
                    }
                

js/app.js


                    import { initialiserThemeUI } from "./modules/theme.js";

                    initialiserThemeUI();
                

js/modules/theme.js


                    // TODO (exercice) : placer ici toute la logique du thème.
                    // Contraintes : pas de POO, pas de regex.

                    export function initialiserThemeUI() {
                        // TODO
                    }
                

js/modules/utilitairesCookies.js

Reprendre utilitairesCookies.js depuis l'exercice "Boîte à outils pour les cookies" (fonctions écrire / lire / supprimer). Ne pas réécrire la logique : réutilisation du module.


Étape 1 : Définir le "contrat" du thème

Clarifier le comportement attendu avant de coder. Un thème "pro" doit toujours répondre aux mêmes règles, sinon le rendu devient incohérent (thème différent selon les pages, basculement imprévisible, flash au chargement).

  1. Lister les trois valeurs possibles du cookie theme : light, dark, system. Stocker la préférence (pas le thème effectif).
  2. Définir le thème effectif (celui appliqué au CSS) :
    • si préférence light → thème effectif light
    • si préférence dark → thème effectif dark
    • si préférence system → thème effectif basé sur prefers-color-scheme
  3. Choisir le point de bascule unique : appliquer le thème en modifiant seulement data-theme sur document.documentElement. Éviter tout "styling" en JavaScript : laisser le CSS faire.

Étape 2 : Vérifier le CSS piloté par data-theme

Avant JavaScript, valider que le CSS répond correctement à l'attribut. Si le CSS n'est pas fiable, le JS ne pourra pas "sauver" le rendu.

  1. Ouvrir la page, puis forcer manuellement l'attribut data-theme dans l'inspecteur sur la balise <html>.
  2. Tester data-theme="light" puis data-theme="dark". Vérifier le changement de fond, de texte et de bordures sur toute la page.
  3. Corriger le CSS si une couleur reste "figée" (ex : une couleur codée en dur). L'objectif : tout piloter via variables CSS.

Étape 3 : Appliquer le thème dès le SSR (PHP)

En SSR, le HTML initial est produit par PHP. Lire le cookie côté serveur permet d'écrire directement data-theme dans le HTML, ce qui réduit fortement le flash quand le cookie vaut light ou dark.

  1. Vérifier la présence du bloc PHP au début de index.php. L'objectif : récupérer $_COOKIE["theme"] et calculer $themeAttr.
  2. Vérifier que la balise <html> contient bien data-theme="..." basé sur $themeAttr.
  3. Tester rapidement :
    • écrire manuellement un cookie theme=dark (via la boîte à outils cookies ou l'inspecteur)
    • recharger
    • constater que la page arrive déjà en sombre

Étape 4 : Corriger le cas "système" avant affichage (anti-flash)

Cas particulier : si la préférence est system (ou si aucun cookie n'existe), PHP applique un fallback (ici light) car il ne connaît pas l'OS. Pour éviter un flash chez un OS "sombre", appliquer le thème effectif dans le <head>, avant l'affichage.

  1. Dans le mini script du <head>, récupérer la préférence en lisant le cookie theme sans regex. Réutiliser la logique de la fonction lireCookie de la boîte à outils :
    • ne pas manipuler document.cookie "à la main"
    • reprendre exactement la stratégie de découpage utilisée dans l'exo 01
    Raison : conserver une seule manière fiable de lire un cookie dans tous les exercices.
  2. Si la préférence vaut system ou null, détecter la préférence de l'OS avec matchMedia("(prefers-color-scheme: dark)"). Transformer alors le mode "système" en une valeur concrète : light ou dark.
  3. Appliquer le thème effectif en posant data-theme sur document.documentElement via setAttribute.
  4. Tester l'absence de flash :
    • mettre l'OS en sombre
    • mettre le cookie theme sur system ou le supprimer
    • recharger
    • constater l'arrivée directe en sombre (sans "coup de clair")

Étape 5 : Initialiser l'UI et persister la préférence (module JS)

Objectif : relier les radios au thème, persister le choix, et maintenir une logique claire : préférence (cookie) → thème effectif (data-theme).

  1. Dans js/modules/theme.js, importer la boîte à outils cookies : ecrireCookie, lireCookie, supprimerCookie depuis ./utilitairesCookies.js. Raison : éviter la manipulation manuelle de document.cookie et garder une API lisible.
  2. Sélectionner les éléments UI : les trois radios (par name="theme") et la zone #theme-info.
  3. Définir une fonction utilitaire (ex : appliquerThemeEffectif) : recevoir une préférence (light/dark/system), calculer le thème effectif, puis poser data-theme sur document.documentElement. Raison : centraliser la logique et éviter de dupliquer des conditions partout.
  4. À l'initialisation :
    • lire la préférence via lireCookie("theme")
    • si null : considérer la préférence comme system
    • cocher la radio correspondante
    • appeler la fonction d'application du thème
  5. Sur changement de radio :
    • récupérer la valeur sélectionnée
    • écrire le cookie theme via ecrireCookie (durée conseillée : 365 jours)
    • appliquer le thème effectif
    • mettre à jour le texte d'info (ex : "Mode : système (OS sombre)")

Étape 6 : Suivre les changements OS uniquement en mode système

En mode Système, le thème effectif dépend d'une préférence externe : celle de l'OS. L'OS peut changer pendant que la page est ouverte (ex : bascule automatique jour/nuit). Objectif : détecter ce changement et mettre à jour la page immédiatement, sans rechargement. À l'inverse, si un choix explicite Clair ou Sombre est sélectionné, ignorer ces changements pour respecter le choix de l'utilisateur.

  1. Créer une variable de module qui représente la préférence courante. Cette variable doit contenir l'une des trois valeurs : light, dark, system.
    • Initialiser cette variable au moment de l'initialisation de l'interface, juste après lecture du cookie.
    • Mettre à jour cette variable à chaque changement de radio, en même temps que l'écriture du cookie.
    Raison : l'écouteur OS doit savoir si le mode system est encore actif au moment où l'événement arrive.
  2. Créer un objet matchMedia une seule fois, et le conserver. Appeler window.matchMedia avec la requête "(prefers-color-scheme: dark)", puis stocker le résultat dans une constante.
    • Ne pas recréer cet objet à chaque clic : le créer une fois rend le code plus simple et évite des écoutes multiples.
  3. Ajouter un écouteur de changement sur l'objet matchMedia. Utiliser l'événement change.
    • Le navigateur déclenche cet événement lorsque la préférence système change (clair ↔ sombre).
    • Cet événement peut arriver à n'importe quel moment, même sans action sur la page.
  4. Dans le gestionnaire de l'événement change, commencer par filtrer le cas non pertinent. Vérifier la variable de préférence courante :
    • si la préférence n'est pas system, sortir immédiatement du gestionnaire.
    • si la préférence est system, continuer.
    Raison : en mode Clair ou Sombre, le thème doit rester stable même si l'OS change.
  5. Si la préférence courante est system, recalculer le thème effectif à partir de la nouvelle valeur système. Utiliser l'information fournie par matchMedia (propriété matches) pour déterminer si l'OS préfère le sombre.
    • Si l'OS préfère le sombre, choisir le thème effectif dark.
    • Sinon, choisir le thème effectif light.
    Puis appliquer ce thème effectif en modifiant uniquement data-theme sur document.documentElement.
  6. Mettre à jour la zone d'information #theme-info après un changement OS en mode système. Afficher un texte qui explicite la situation (ex : "Mode : système (OS sombre)" ou "Mode : système (OS clair)"). Raison : sans ce texte, le changement peut sembler "mystérieux" pour un débutant en phase de test.
  7. Tester le suivi OS :
    • sélectionner Système
    • changer la préférence de thème de l'OS (clair ↔ sombre)
    • constater la mise à jour immédiate de l'attribut data-theme et du texte #theme-info
  8. Tester le respect d'un choix explicite :
    • sélectionner Clair (ou Sombre)
    • changer l'OS (clair ↔ sombre)
    • constater que le thème de la page ne change pas

Étape 7 : Tests de cohérence (scénarios)

  1. Scénario A — préférence explicite : sélectionner Sombre, recharger, constater le sombre dès l'arrivée.
  2. Scénario B — système (OS sombre) : sélectionner Système, mettre l'OS en sombre, recharger, constater l'arrivée en sombre sans flash.
  3. Scénario C — changement OS pendant la session : rester en Système, changer l'OS (clair ↔ sombre), constater la mise à jour immédiate.
  4. Scénario D — respect du choix : sélectionner Clair, changer l'OS, constater que le thème reste clair.

Étape 8 : Surcouche accessibilité en JavaScript

Le HTML est déjà accessible : les radios sont de vrais contrôles natifs, chaque radio a un label, le groupe est structuré avec fieldset / legend, et une zone aria-live existe pour annoncer des messages. Cette étape ajoute uniquement ce qui nécessite du JavaScript : écrire des annonces au bon moment et gérer le cas particulier du mode système (changement automatique sans clic).

  1. Comprendre le rôle de la zone aria-live. Une zone aria-live sert à annoncer un texte au lecteur d'écran quand ce texte change. Ici, le changement de thème est souvent très visible (couleurs qui changent), mais un lecteur d'écran n'a aucun moyen de le deviner. L'objectif est donc d'écrire un message court dans #theme-live chaque fois que le thème effectif change.
    • Sélectionner l'élément #theme-live au chargement, comme les autres éléments.
    • Vérifier que l'élément existe : si la sélection retourne null, afficher un avertissement dans la console et continuer (le thème doit fonctionner même sans annonces).
    • Utiliser textContent pour changer le message (pas innerHTML). Cela évite d'injecter du HTML et reste plus simple et sûr.
  2. Centraliser l'annonce dans une petite fonction. Écrire la même logique d'annonce à plusieurs endroits rend le code difficile à maintenir. L'objectif est de créer une petite fonction (dans le module) qui ne fait qu'une seule chose : écrire un message dans #theme-live.
    • Créer une fonction qui reçoit un paramètre message (une chaîne de caractères).
    • Dans cette fonction, si l'élément #theme-live existe, mettre son textContent à jour.
    • Laisser la fonction ne rien faire si #theme-live est introuvable. L'objectif est d'éviter de casser l'application pour un détail d'annonce.
  3. Annoncer le thème effectif après chaque application. Le thème "choisi" (light/dark/system) et le thème "effectif" (light ou dark réellement appliqué) ne sont pas toujours identiques. Exemple : "system" peut conduire à sombre ou clair selon l'OS. L'annonce doit donc se baser sur le thème effectivement appliqué sur <html> (ex : data-theme="dark").
    • Juste après avoir appliqué le thème (au même endroit où data-theme est mis à jour), lire la valeur réellement posée sur document.documentElement.
    • Construire un message court :
      • si le thème effectif est sombre : "Thème sombre activé"
      • si le thème effectif est clair : "Thème clair activé"
    • Si le mode choisi est Système, préciser le contexte :
      • si le système donne sombre : "Mode système : sombre"
      • si le système donne clair : "Mode système : clair"
      L'objectif est d'expliquer pourquoi le thème est celui-ci, sans obliger l'utilisateur à deviner.
    • Appeler la petite fonction d'annonce avec ce message.
  4. Annoncer aussi les changements automatiques en mode Système. En mode Système, un changement de thème peut venir de l'OS, sans clic sur la page. Visuellement, le fond et les couleurs changent, mais un lecteur d'écran n'a aucun signal automatique. L'objectif est donc d'écrire une annonce dans #theme-live lorsque le thème change à cause de l'OS.
    • Réutiliser le même endroit que celui qui réagit au changement OS : le gestionnaire de l'événement change ajouté sur l'objet matchMedia à l'Étape 6. À cet endroit, le nouveau thème effectif est recalculé puis appliqué. Ajouter l'annonce juste après l'application de data-theme. Raison : annoncer ce qui est réellement visible (thème effectif), pas une intention.
    • Avant d'écrire l'annonce, vérifier que la préférence courante est toujours system. Si la préférence est light ou dark, sortir immédiatement du gestionnaire sans annoncer. Raison : un choix explicite ne doit pas être contredit, et il ne faut pas annoncer un changement qui n'a pas lieu.
    • Construire une annonce courte qui explique l'origine du changement, par exemple : "Mode système : passage en sombre" ou "Mode système : passage en clair". Le mot "passage" indique qu'il s'agit d'un changement automatique.
    • Mettre à jour #theme-live avec textContent (via la fonction d'annonce créée plus haut dans cette étape). Raison : déclencher l'annonce via aria-live sans ajouter de HTML.
  5. Ne pas déplacer le focus après sélection. Les radios gèrent déjà le focus et le clavier correctement : flèches pour changer d'option, Tab pour sortir du groupe. Déplacer le focus automatiquement crée souvent une surprise (le curseur "saute" ailleurs). L'objectif est donc de laisser le focus sur la radio utilisée.
    • Lors du changement de radio, ne pas appeler focus() sur un autre élément.
    • Se limiter à : appliquer le thème + écrire le cookie/localStorage selon le choix + annoncer dans #theme-live.
  6. Tester sans souris (scénario clavier + annonces). Tester le parcours complet au clavier permet de vérifier que le comportement est stable.
    • Tabuler jusqu'au groupe de radios.
    • Utiliser les flèches pour changer de mode, puis vérifier que le thème change.
    • Vérifier que le texte de #theme-live change à chaque application effective. Même sans lecteur d'écran, la mise à jour du texte confirme que l'annonce sera déclenchée.
    • Passer en mode Système, puis simuler un changement (si possible) ou vérifier que la logique est prête : en mode système, un changement OS doit déclencher une annonce et un changement de thème.